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 }