NixDaemon

NixOS pentest workstation as one flake — IceBreaker's successor
git clone https://git.daemon-sec.xyz/NixDaemon.git
Log | Files | Refs | README

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 }