vpn.nix (14618B)
1 # modules/features/pentest/vpn.nix — the HTB VPN, as a systemd template unit. 2 # 3 # htbvpn list profiles in daemon.pentest.htb.vpnDir, active marked 4 # htbvpn up <profile> start it (stops whatever was up first) 5 # htbvpn down stop it 6 # htbvpn status unit state and the tunnel address 7 # htbip just the tunnel address, for pasting into payloads 8 # 9 # A template unit rather than a backgrounded `sudo openvpn`: it survives 10 # closing the terminal, its output goes to the journal (`journalctl -u 11 # htbvpn@lab_eu_free`), and `htbvpn down` reliably kills it instead of leaving 12 # an orphan holding tun0. 13 # 14 # Profiles are NOT Nix-managed. They are per-account files that rotate every 15 # time you regenerate them on the HTB website, so the directory is created for 16 # you and left alone otherwise. Drop the .ovpn in and `htbvpn list` sees it. 17 { lib, self, ... }: 18 { 19 flake.nixosModules.pentest-vpn = 20 { config, pkgs, lib, user, ... }: 21 let 22 cfg = config.daemon.pentest; 23 on = cfg.enable; 24 vpnDir = cfg.htb.vpnDir; 25 sudo = "/run/wrappers/bin/sudo"; 26 # The sudoers rules below name this exact path, and sudo matches on the 27 # resolved command. Calling a bare `systemctl` would resolve through PATH 28 # to a /nix/store/... path, match no rule, and ask for a password that 29 # `sudo -n` cannot supply. 30 systemctl = "/run/current-system/sw/bin/systemctl"; 31 32 # Shared by htbvpn and htbip: the tunnel is whichever tun* exists, not 33 # necessarily tun0 — a second VPN, or a profile with `dev tun1`, moves it. 34 tunAddr = pkgs.writeShellScriptBin "htbip" '' 35 set -uo pipefail 36 for i in $(${pkgs.iproute2}/bin/ip -br link show type tun 2>/dev/null | ${pkgs.gawk}/bin/awk '{print $1}'); do 37 a=$(${pkgs.iproute2}/bin/ip -4 -br addr show dev "$i" 2>/dev/null | ${pkgs.gawk}/bin/awk '{print $3}' | ${pkgs.coreutils}/bin/cut -d/ -f1) 38 if [ -n "''${a:-}" ]; then printf '%s\n' "$a"; exit 0; fi 39 done 40 echo "htbip: no tun interface has an address — is the VPN up? (htbvpn status)" >&2 41 exit 1 42 ''; 43 44 # Root side, the fan-ec pattern (modules/hosts/laptop/fan-cli.nix): a 45 # fixed store script that validates its own arguments and builds the unit 46 # name itself. 47 # 48 # It replaces two `systemctl start|stop htbvpn@*` sudoers rules, which 49 # were a privilege escalation: sudoers matches the argument pattern with 50 # fnmatch and no FNM_PATHNAME, so `*` spans spaces and 51 # `start htbvpn@x canary.service` matched the rule — systemd then started 52 # both units. Any wheel user could start or stop anything, passwordless. 53 htbvpn-ctl = pkgs.writeShellScriptBin "htbvpn-ctl" '' 54 set -uo pipefail 55 [ "$(id -u)" = 0 ] || { echo "htbvpn-ctl: run as root (htbvpn does that)" >&2; exit 1; } 56 PATH=${lib.makeBinPath [ pkgs.systemd pkgs.coreutils ]}:$PATH 57 58 # Exactly two arguments, so nothing extra can be appended. 59 [ "$#" -eq 2 ] || { echo "usage: htbvpn-ctl start|stop <profile>" >&2; exit 2; } 60 action="$1" 61 profile="$2" 62 63 case "$action" in 64 start | stop) ;; 65 *) echo "htbvpn-ctl: action must be start or stop" >&2; exit 2 ;; 66 esac 67 case "$profile" in 68 "" | .* | *[!A-Za-z0-9_.-]*) 69 echo "htbvpn-ctl: bad profile name: $profile" >&2; exit 2 ;; 70 esac 71 72 exec systemctl "$action" "htbvpn@$profile.service" 73 ''; 74 75 # One command to get on the lab, because `htbvpn up <profile>` is three 76 # words too many when there is only ever one profile. 77 htbup = pkgs.writeShellScriptBin "htbup" '' 78 set -uo pipefail 79 PATH=/run/current-system/sw/bin:${lib.makeBinPath [ pkgs.coreutils pkgs.findutils pkgs.gnused ]}:$PATH 80 DIR=${lib.escapeShellArg vpnDir} 81 82 p="''${1:-}" 83 if [ -z "$p" ]; then 84 # No argument: if exactly one profile exists, that is the one meant. 85 n=$(find "$DIR" -maxdepth 1 -name '*.ovpn' 2>/dev/null | wc -l) 86 if [ "$n" -eq 1 ]; then 87 p=$(find "$DIR" -maxdepth 1 -name '*.ovpn' -printf '%f\n' | sed 's/\.ovpn$//') 88 elif [ "$n" -eq 0 ]; then 89 echo "htbup: no .ovpn profiles in $DIR" >&2 90 echo "htbup: download one from HTB (Access -> OpenVPN) and drop it there" >&2 91 exit 2 92 else 93 echo "htbup: several profiles — say which:" >&2 94 htbvpn list >&2 95 exit 2 96 fi 97 fi 98 99 htbvpn up "$p" || exit 1 100 echo "" 101 htbvpn status 102 if t=$(htbtarget 2>/dev/null | head -1); then echo " $t"; fi 103 echo " serve payloads with: payload-serve" 104 ''; 105 106 htbdown = pkgs.writeShellScriptBin "htbdown" '' 107 exec /run/current-system/sw/bin/htbvpn down 108 ''; 109 110 htbvpn = pkgs.writeShellScriptBin "htbvpn" '' 111 set -uo pipefail 112 PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.gnugrep pkgs.gnused pkgs.systemd pkgs.gawk ]}:$PATH 113 DIR=${lib.escapeShellArg vpnDir} 114 115 active() { systemctl list-units --type=service --state=active --no-legend 'htbvpn@*' 2>/dev/null \ 116 | awk '{print $1}' | sed -n 's/^htbvpn@\(.*\)\.service$/\1/p' | head -1; } 117 118 profiles() { [ -d "$DIR" ] && find "$DIR" -maxdepth 1 -name '*.ovpn' -printf '%f\n' 2>/dev/null | sed 's/\.ovpn$//' | sort; } 119 120 usage() { 121 echo "usage: htbvpn list | up <profile> | down | status" 122 echo " profiles live in $DIR (drop your .ovpn there)" 123 } 124 125 case "''${1:-status}" in 126 list) 127 cur=$(active) 128 if [ -z "$(profiles)" ]; then 129 echo "no .ovpn profiles in $DIR" 130 echo "download one from HTB (Access → OpenVPN) and put it there" 131 exit 0 132 fi 133 profiles | while read -r p; do 134 if [ "$p" = "$cur" ]; then echo "* $p (up)"; else echo " $p"; fi 135 done ;; 136 137 up) 138 p="''${2:-}" 139 [ -n "$p" ] || { echo "htbvpn: which profile?" >&2; usage >&2; exit 2; } 140 # The instance name becomes part of a path in the unit's ExecStart, 141 # so refuse anything that is not a plain file name. 142 case "$p" in 143 "" | .* | *[!A-Za-z0-9_.-]*) 144 echo "htbvpn: bad profile name: $p" >&2 145 echo " profile names are plain file names: letters, digits, _ . -" >&2 146 exit 2 ;; 147 esac 148 if [ ! -f "$DIR/$p.ovpn" ]; then 149 echo "htbvpn: no such profile: $DIR/$p.ovpn" >&2 150 echo "available:" >&2; profiles | sed 's/^/ /' >&2 151 exit 2 152 fi 153 cur=$(active) 154 if [ -n "$cur" ]; then 155 echo "stopping htbvpn@$cur first (one tunnel at a time)" 156 ${sudo} -n /run/current-system/sw/bin/htbvpn-ctl stop "$cur" || true 157 fi 158 ${sudo} -n /run/current-system/sw/bin/htbvpn-ctl start "$p" || { 159 echo "htbvpn: failed to start; journalctl -u htbvpn@$p" >&2; exit 1; } 160 for _ in $(seq 1 30); do 161 if a=$(${lib.getExe tunAddr} 2>/dev/null); then echo "htbvpn: $p up, tunnel $a"; exit 0; fi 162 sleep 1 163 done 164 echo "htbvpn: $p started but no tunnel address after 30s; journalctl -u htbvpn@$p" >&2 165 exit 1 ;; 166 167 down) 168 cur=$(active) 169 [ -n "$cur" ] || { echo "htbvpn: nothing is up"; exit 0; } 170 ${sudo} -n /run/current-system/sw/bin/htbvpn-ctl stop "$cur" 171 echo "htbvpn: $cur down" ;; 172 173 status) 174 cur=$(active) 175 if [ -z "$cur" ]; then echo "htbvpn: down"; else 176 echo "htbvpn: $cur up, tunnel $(${lib.getExe tunAddr} 2>/dev/null || echo '(no address yet)')" 177 fi ;; 178 179 -h|--help) usage ;; 180 *) usage >&2; exit 2 ;; 181 esac 182 ''; 183 in 184 { 185 config = lib.mkIf on { 186 environment.systemPackages = [ htbvpn htbvpn-ctl htbup htbdown tunAddr pkgs.openvpn ]; 187 188 # The directory only; the profiles in it are yours. The parents are 189 # listed explicitly: a tmpfiles `d` line does not reliably create a 190 # missing ~/.config on a fresh account, which is why the VM test's 191 # `test -d` failed. 192 systemd.tmpfiles.rules = [ 193 "d /home/${user}/.config 0755 ${user} users - -" 194 "d /home/${user}/.config/htb 0700 ${user} users - -" 195 "d ${vpnDir} 0700 ${user} users - -" 196 ]; 197 198 systemd.services."htbvpn@" = { 199 description = "HTB OpenVPN profile %i"; 200 after = [ "network-online.target" ]; 201 wants = [ "network-online.target" ]; 202 serviceConfig = { 203 Type = "simple"; 204 # --script-security 1 comes AFTER --config deliberately: openvpn 205 # applies options in order, so this overrides a `script-security 2` 206 # inside the profile. Without it, a profile in this user-owned 207 # 0700 directory could run `up /tmp/x.sh` as root — the unit runs 208 # as root, and the profiles are deliberately not Nix-managed. 209 # Level 1 still allows openvpn's own built-in ifconfig/route calls. 210 ExecStart = 211 "${pkgs.openvpn}/bin/openvpn --suppress-timestamps" 212 + " --config ${vpnDir}/%i.ovpn --script-security 1"; 213 # Profiles often reference certs by relative path. 214 WorkingDirectory = vpnDir; 215 Restart = "no"; 216 }; 217 }; 218 219 # One argument-free grant on a fixed script, exactly as fan-cli.nix 220 # does. No wildcard, so there is no argument pattern to widen. 221 security.sudo.extraRules = [ 222 { 223 groups = [ "wheel" ]; 224 commands = [ 225 { command = "${lib.getExe htbvpn-ctl}"; options = [ "NOPASSWD" ]; } 226 { command = "/run/current-system/sw/bin/htbvpn-ctl"; options = [ "NOPASSWD" ]; } 227 ]; 228 } 229 ]; 230 }; 231 }; 232 233 perSystem = 234 { pkgs, ... }: 235 { 236 # The deliverable is a running tunnel, so this boots a VM. OpenVPN in 237 # static-key point-to-point mode configures its tun device before any 238 # peer answers, which is what lets a single node assert a real address. 239 checks.pentest-vpn-vm = pkgs.testers.runNixOSTest { 240 name = "pentest-vpn"; 241 242 nodes.machine = { pkgs, ... }: { 243 imports = [ 244 self.nixosModules.pentest-options 245 self.nixosModules.pentest-vpn 246 ]; 247 daemon.pentest.enable = true; 248 _module.args.user = "daemonsec"; 249 users.users.daemonsec = { 250 isNormalUser = true; 251 extraGroups = [ "wheel" ]; 252 }; 253 254 # A unit the user must NOT be able to start through the VPN grant. 255 systemd.services.canary = { 256 description = "must not be startable by the htbvpn sudo rule"; 257 wantedBy = [ ]; 258 serviceConfig = { 259 Type = "oneshot"; 260 ExecStart = "${pkgs.coreutils}/bin/touch /tmp/canary-fired"; 261 }; 262 }; 263 }; 264 265 testScript = '' 266 machine.wait_for_unit("multi-user.target") 267 d = "/home/daemonsec/.config/htb/vpn" 268 269 machine.succeed(f"test -d {d}") 270 271 # No profiles yet: `list` must say so and not fail. 272 machine.succeed("su -l daemonsec -c 'htbvpn list' | grep -q 'no .ovpn profiles'") 273 274 # A missing profile must exit 2 and start nothing (plan Task 13 #4). 275 machine.fail("su -l daemonsec -c 'htbvpn up nosuchprofile'") 276 machine.fail("systemctl is-active 'htbvpn@nosuchprofile.service'") 277 278 # Two static-key p2p profiles, each on its own tun device. 279 machine.succeed(f"openvpn --genkey secret {d}/static.key") 280 for name, dev, local, peer in [ 281 ("profileA", "tun0", "10.8.0.1", "10.8.0.2"), 282 ("profileB", "tun1", "10.9.0.1", "10.9.0.2"), 283 ]: 284 machine.succeed( 285 f"printf '%s\\n' 'dev {dev}' 'dev-type tun' 'ifconfig {local} {peer}' " 286 f"'secret static.key' 'remote 192.0.2.1' 'proto udp' 'ping 10' " 287 f"'data-ciphers-fallback AES-256-CBC' 'verb 3' > {d}/{name}.ovpn" 288 ) 289 machine.succeed(f"chown -R daemonsec:users {d}") 290 291 # up: unit active and the tunnel really has an address. 292 machine.succeed("su -l daemonsec -c 'htbvpn up profileA'") 293 machine.wait_for_unit("htbvpn@profileA.service") 294 machine.succeed("ip -4 addr show tun0 | grep -q 10.8.0.1") 295 machine.succeed("su -l daemonsec -c htbip | grep -q 10.8.0.1") 296 machine.succeed("su -l daemonsec -c 'htbvpn list' | grep -q '\\* profileA (up)'") 297 298 # Review Focus #5: switching stops the first one. Never two tunnels. 299 machine.succeed("su -l daemonsec -c 'htbvpn up profileB'") 300 machine.wait_for_unit("htbvpn@profileB.service") 301 machine.fail("systemctl is-active 'htbvpn@profileA.service'") 302 machine.fail("ip link show tun0") 303 machine.succeed("ip -4 addr show tun1 | grep -q 10.9.0.1") 304 305 # The grant must be "start my VPN", not "start anything". 306 # 307 # sudoers matches the argument pattern with fnmatch and no 308 # FNM_PATHNAME, so `*` spans spaces: with a rule of 309 # `systemctl start htbvpn@*`, the arguments 310 # `start htbvpn@profileA canary.service` MATCH, and systemd starts 311 # both units. That turned a VPN grant into "start any unit". 312 machine.fail( 313 "su -l daemonsec -c 'sudo -n /run/current-system/sw/bin/systemctl " 314 "start htbvpn@profileB canary.service'" 315 ) 316 machine.fail("test -e /tmp/canary-fired") 317 machine.fail("systemctl is-active canary.service") 318 319 # ...and the plain form must be refused too. 320 machine.fail( 321 "su -l daemonsec -c 'sudo -n /run/current-system/sw/bin/systemctl " 322 "start canary.service'" 323 ) 324 machine.fail("test -e /tmp/canary-fired") 325 326 # down: nothing left holding a tunnel. 327 machine.succeed("su -l daemonsec -c 'htbvpn down'") 328 machine.fail("systemctl is-active 'htbvpn@profileB.service'") 329 machine.succeed("su -l daemonsec -c 'htbvpn status' | grep -q down") 330 machine.fail("su -l daemonsec -c htbip") 331 ''; 332 }; 333 }; 334 }