NixDaemon

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

time.nix (11753B)


      1 # modules/features/pentest/time.nix — clock skew, on purpose and reversibly.
      2 #
      3 # Kerberos rejects a ticket request when the clock is more than five minutes
      4 # off the KDC, so against an AD box the first fix is usually to match the DC's
      5 # clock. On NixOS that fights the system: systemd-timesyncd is enabled by
      6 # default and will quietly put the clock back.
      7 #
      8 # `htb-time` is the root half (this file); `htbtime` is the user-facing half in
      9 # modules/home/htb.nix, exactly as fan-ec/fan are split in
     10 # modules/hosts/laptop/fan-cli.nix and modules/home/fan.nix.
     11 #
     12 #   htb-time <host>   record what time sync looks like now, turn it off, and
     13 #                     step the clock to <host>
     14 #   htb-time off      put back precisely what was recorded, then resync
     15 #   htb-time status   current offset and whether we are holding a skew
     16 #
     17 # Two things it refuses to do quietly:
     18 #  * leave you wondering why TLS broke. A large skew makes certificates look
     19 #    not-yet-valid or expired, so `nix`, `git` and anything HTTPS start failing.
     20 #    It says so, every time.
     21 #  * leave the clock unmanaged. If the state file is missing or unreadable when
     22 #    `off` runs, it restores the NixOS default (NTP on) rather than doing
     23 #    nothing — and a reboot would do that anyway, so a forgotten `off` cannot
     24 #    strand the machine.
     25 { lib, self, ... }:
     26 {
     27   flake.nixosModules.pentest-time =
     28     { config, pkgs, lib, ... }:
     29     let
     30       on = config.daemon.pentest.enable;
     31 
     32       htb-time = pkgs.writeShellScriptBin "htb-time" ''
     33         set -uo pipefail
     34         [ "$(id -u)" = 0 ] || { echo "htb-time: run as root (htbtime does that for you)" >&2; exit 1; }
     35         PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.systemd pkgs.gnugrep pkgs.gawk pkgs.gnused ]}:$PATH
     36 
     37         NTPDATE=${pkgs.ntp}/bin/ntpdate
     38         SNTP=${pkgs.ntp}/bin/sntp
     39         CHRONYD=${pkgs.chrony}/bin/chronyd
     40         DIR=/var/lib/htb-time
     41         STATE=$DIR/state
     42 
     43         # Units that would fight a manual clock. Only ones that exist are touched.
     44         UNITS="systemd-timesyncd.service chronyd.service chrony.service ntpd.service"
     45 
     46         unit_exists() { systemctl cat "$1" >/dev/null 2>&1; }
     47         ntp_enabled() { timedatectl show -p NTP --value 2>/dev/null || echo unknown; }
     48 
     49         save_state() {
     50           mkdir -p "$DIR"
     51           {
     52             echo "ntp=$(ntp_enabled)"
     53             for u in $UNITS; do
     54               if unit_exists "$u"; then
     55                 echo "unit=$u:$(systemctl is-active "$u" 2>/dev/null || echo inactive)"
     56               fi
     57             done
     58           } > "$STATE"
     59         }
     60 
     61         stop_competitors() {
     62           for u in $UNITS; do
     63             if unit_exists "$u" && systemctl is-active --quiet "$u" 2>/dev/null; then
     64               echo "  stopping $u"
     65               systemctl stop "$u" || true
     66             fi
     67           done
     68           timedatectl set-ntp false 2>/dev/null || true
     69         }
     70 
     71         warn_tls() {
     72           cat >&2 <<'EOF'
     73 
     74         htb-time: the clock is now deliberately wrong for this machine.
     75           TLS certificate validation compares against it, so `nix`, `git`, curl
     76           and anything else over HTTPS may start failing while this is held.
     77           Put it back with:  htbtime off
     78         EOF
     79         }
     80 
     81         offset_against() { # best-effort, read-only
     82           $SNTP "$1" 2>/dev/null | ${pkgs.gnugrep}/bin/grep -oE '^[+-][0-9]+\.[0-9]+' | head -1
     83         }
     84 
     85         case "''${1:-status}" in
     86           off)
     87             if [ ! -r "$STATE" ]; then
     88               # Review Focus #3: never leave the clock unmanaged.
     89               echo "htb-time: no saved state at $STATE (nothing to restore, or it was lost)." >&2
     90               echo "htb-time: restoring the NixOS default instead: NTP on." >&2
     91               timedatectl set-ntp true 2>/dev/null || true
     92               unit_exists systemd-timesyncd.service && systemctl start systemd-timesyncd.service || true
     93               echo "htb-time: NTP enabled, clock is managed again."
     94               exit 0
     95             fi
     96 
     97             want_ntp=$(${pkgs.gnugrep}/bin/grep -m1 '^ntp=' "$STATE" | cut -d= -f2)
     98             if [ "$want_ntp" = "yes" ] || [ -z "''${want_ntp:-}" ] || [ "$want_ntp" = "unknown" ]; then
     99               timedatectl set-ntp true 2>/dev/null || true
    100             else
    101               timedatectl set-ntp false 2>/dev/null || true
    102             fi
    103 
    104             ${pkgs.gnugrep}/bin/grep '^unit=' "$STATE" 2>/dev/null | sed 's/^unit=//' | while IFS=: read -r u st; do
    105               if [ "$st" = "active" ]; then
    106                 unit_exists "$u" && systemctl start "$u" 2>/dev/null || true
    107               fi
    108             done
    109 
    110             rm -f "$STATE"
    111             echo "htb-time: restored (NTP=$(ntp_enabled)); the clock is managed again."
    112             exit 0
    113             ;;
    114 
    115           status)
    116             if [ -r "$STATE" ]; then
    117               echo "htb-time: HOLDING a manual skew (state $STATE)"
    118               sed 's/^/  /' "$STATE"
    119               echo "  release with: htbtime off"
    120             else
    121               echo "htb-time: not holding a skew"
    122             fi
    123             echo "  NTP=$(ntp_enabled)  now=$(date -Is)"
    124             # Explicit: a consumer like `htb-time status | grep -q x` exits as
    125             # soon as it matches, so the last echo takes EPIPE and would
    126             # otherwise become this script's exit status.
    127             exit 0
    128             ;;
    129 
    130           -h|--help)
    131             echo "usage: htb-time <host> | off | status"
    132             ;;
    133 
    134           *)
    135             target="$1"
    136             echo "htb-time: syncing this machine's clock to $target"
    137             [ -r "$STATE" ] || save_state
    138             stop_competitors
    139 
    140             if $NTPDATE -u "$target" 2>&1 | sed 's/^/  ntpdate: /'; then
    141               echo "htb-time: clock stepped to $target (now $(date -Is))"
    142               warn_tls
    143               exit 0
    144             fi
    145 
    146             echo "  ntpdate failed; trying chronyd -q" >&2
    147             if $CHRONYD -q "server $target iburst maxdelay 10" 2>&1 | sed 's/^/  chronyd: /'; then
    148               echo "htb-time: clock stepped to $target (now $(date -Is))"
    149               warn_tls
    150               exit 0
    151             fi
    152 
    153             # Deliberately leaves NTP off (spec C6: do not silently half-apply),
    154             # but says so loudly and names the way back.
    155             echo "htb-time: could not get the time from $target." >&2
    156             echo "htb-time: NTP is still DISABLED and the state is saved." >&2
    157             echo "htb-time: either retry, or run 'htbtime off' to put sync back." >&2
    158             exit 1
    159             ;;
    160         esac
    161       '';
    162     in
    163     {
    164       config = lib.mkIf on {
    165         environment.systemPackages = [ htb-time pkgs.ntp pkgs.chrony ];
    166         systemd.tmpfiles.rules = [ "d /var/lib/htb-time 0755 root root - -" ];
    167 
    168         # Same shape as fan-cli.nix: one fixed store script, wheel only.
    169         security.sudo.extraRules = [
    170           {
    171             groups = [ "wheel" ];
    172             commands = [
    173               { command = "/run/current-system/sw/bin/htb-time"; options = [ "NOPASSWD" ]; }
    174             ];
    175           }
    176         ];
    177       };
    178     };
    179 
    180   perSystem =
    181     { pkgs, ... }:
    182     {
    183       checks.pentest-time-vm = pkgs.testers.runNixOSTest {
    184         name = "pentest-time";
    185 
    186         nodes = {
    187           # Stands in for the DC whose clock we chase. chrony rather than
    188           # ntpd: ntpd with a local-clock fudge needs minutes before it will
    189           # answer as authoritative, so ntpdate finds "no server suitable" and
    190           # the test hangs. chrony's `local stratum 10` serves immediately.
    191           dc = {
    192             services.chrony = {
    193               enable = true;
    194               extraConfig = ''
    195                 # `local stratum 10` makes chronyd answer as synchronised even
    196                 # though this node has no upstream source, which is the whole
    197                 # point of a stand-in DC. Without it ntpdate reports "no server
    198                 # suitable for synchronization". NOT `orphan`: that only
    199                 # activates once every other source is unreachable, which left
    200                 # the server silent.
    201                 local stratum 10
    202                 allow all
    203               '';
    204             };
    205             networking.firewall.allowedUDPPorts = [ 123 ];
    206           };
    207 
    208           machine = { lib, ... }: {
    209             imports = [
    210               self.nixosModules.pentest-options
    211               self.nixosModules.pentest-time
    212             ];
    213             daemon.pentest.enable = true;
    214             # The test driver leaves time sync off, which would make the
    215             # baseline NTP=no and the round-trip assertion vacuous. The real
    216             # machine has timesyncd on, so say so explicitly: that is the
    217             # state htb-time has to record and put back.
    218             # mkForce: the NixOS test driver switches time sync OFF for its
    219             # nodes, which is exactly the state this test must not start from.
    220             services.timesyncd.enable = lib.mkForce true;
    221             _module.args.user = "daemonsec";
    222             users.users.daemonsec = {
    223               isNormalUser = true;
    224               extraGroups = [ "wheel" ];
    225             };
    226           };
    227         };
    228 
    229         testScript = ''
    230           start_all()
    231           dc.wait_for_unit("chronyd.service")
    232           machine.wait_for_unit("multi-user.target")
    233 
    234           # Do not proceed until the DC will actually answer, otherwise a
    235           # failure here is indistinguishable from a bug in htb-time.
    236           machine.wait_until_succeeds(
    237               "${pkgs.ntp}/bin/sntp -t 2 dc >/dev/null 2>&1 "
    238               "|| ${pkgs.ntp}/bin/ntpdate -q -t 2 dc >/dev/null 2>&1",
    239               timeout=120,
    240           )
    241 
    242           # Baseline: NixOS manages the clock.
    243           machine.wait_for_unit("systemd-timesyncd.service")
    244           before = machine.succeed("timedatectl show -p NTP --value").strip()
    245           assert before == "yes", f"expected NTP managed at boot, got {before!r}"
    246           machine.succeed("htb-time status | grep -q 'not holding a skew'")
    247 
    248           # Hold a skew against the DC.
    249           machine.succeed("htb-time dc")
    250           machine.succeed("test -r /var/lib/htb-time/state")
    251           held = machine.succeed("timedatectl show -p NTP --value").strip()
    252           assert held == "no", f"NTP should be off while holding a skew, got {held!r}"
    253           machine.fail("systemctl is-active systemd-timesyncd.service")
    254           machine.succeed("htb-time status | grep -q HOLDING")
    255 
    256           # Release: must match the baseline exactly, and clear the state.
    257           machine.succeed("htb-time off")
    258           after = machine.succeed("timedatectl show -p NTP --value").strip()
    259           assert after == before, f"off must restore NTP={before!r}, got {after!r}"
    260           machine.fail("test -e /var/lib/htb-time/state")
    261 
    262           # Review Focus #3: `off` with the state file gone must still leave the
    263           # clock managed, and must not fail.
    264           machine.succeed("htb-time dc")
    265           machine.succeed("rm -f /var/lib/htb-time/state")
    266           out = machine.succeed("htb-time off 2>&1")
    267           assert "NTP on" in out or "managed again" in out, out
    268           recovered = machine.succeed("timedatectl show -p NTP --value").strip()
    269           assert recovered == "yes", f"expected NTP restored to yes, got {recovered!r}"
    270 
    271           # An unreachable host fails loudly, keeps the state, and names the way back.
    272           machine.succeed("htb-time off || true")
    273           err = machine.fail("htb-time 192.0.2.123 2>&1")
    274           assert "htbtime off" in err, err
    275           machine.succeed("test -r /var/lib/htb-time/state")
    276           machine.succeed("htb-time off")
    277 
    278           # The user half needs no password.
    279           machine.succeed("su -l daemonsec -c 'sudo -n htb-time status'")
    280         '';
    281       };
    282     };
    283 }