Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d4b1930df | |||
| b4721eee11 | |||
| 1109092def | |||
| b45c47be6c | |||
| b9b831fb90 | |||
| 39fb9cbf6c | |||
| ae69ad4512 | |||
| e803ad2b91 | |||
| 7651461be2 | |||
| a0c88e5183 | |||
| 2d5840ba11 |
+65
-1
@@ -1,4 +1,68 @@
|
|||||||
## Unreleased (2026-10-21) — every guest may swap as much as its RAM (R-1192, the 8 GB freeze) — COMMITTED, NOT RELEASED (the release is the operator's word)
|
## v0.163.0 — the tunnel heals itself after a clock that ran ahead (D30, R-1204); root SSH from the LAN by key only (D4, R-982) (2026-10-23) — RELEASED on the operator's yes (day brief); the vouch is the operator's
|
||||||
|
|
||||||
|
Binary sha256 `561b5316cd511de9fa9d8b8be4553da7add684f715d3bea533ca330ff7f3763c`; config bundle sha256 `3afb88d9fbbc4adc16083fa52a98cd4974583c6378a77a1a6e0ed8ae6d1b80c8`. Built from `1109092` (CI 2246 green). Both sections below are in it.
|
||||||
|
|
||||||
|
### The tunnel re-key (D30) and the D4 bundle drop-in
|
||||||
|
|
||||||
|
- **D30 (operator 2026-10-22: the agent makes a new key and registers it on the existing peer path).** When the latest
|
||||||
|
handshake stays stamped in the future for 15 minutes (the R-1204 sign: ep0 keeps the future timestamp and refuses
|
||||||
|
every newer handshake from that key), the agent makes a new WireGuard key pair, keeps the old private key as
|
||||||
|
`wg/private.key.prev` (0600 — the escrow may hold it), and registers the new public key through `POST /hosts/<id>/wg`.
|
||||||
|
The hub re-keys the peer IN PLACE (same /32, generation bump) and its full-list push to ep0 drops the old key and adds
|
||||||
|
the new one — nothing new runs on ep0. A new key is a new peer for ep0, with no stored timestamp, so the next
|
||||||
|
handshake is accepted. At most once an hour; not while the tunnel unit is down; never while a re-register is already
|
||||||
|
in flight. A failed registration is retried by the existing re-key-in-place path. Logs carry public keys only.
|
||||||
|
Tests `TestD30_*` (4), red-proved. A host-loss DR that installs the escrowed (older) key registers it again, and the
|
||||||
|
hub re-keys in place back to it — the same path.
|
||||||
|
- **Review of the night's own code (felhom.eu `REPORT-night-2026-10-23.md`, N6, N7).** The rotation never leaves the box
|
||||||
|
without a readable key: the new key is written and fsynced to a temp file, the current key gets its kept name by a
|
||||||
|
hard link, then the temp file is renamed OVER `private.key` and the directory fsynced (a power cut or a kill between
|
||||||
|
two renames used to leave no key, or an empty one). `private.key.prev` keeps the FIRST old key (the escrow may hold it);
|
||||||
|
later ones are `private.key.prev.<unix>`, the newest three kept. The once-an-hour bound is saved in `wg/last-rekey`, so
|
||||||
|
an agent restart does not reset it; a start or a last re-key dated AFTER now (a clock that jumped back) is dropped,
|
||||||
|
never a block until that date (the commit's own security review). Tests `TestD30_TwoRotationsKeepTheFirstOldKey`,
|
||||||
|
`TestD30_ALastReKeyFromTheFutureDoesNotBlock`,
|
||||||
|
`TestD30_TheHourBoundSurvivesARestart`, red-proved. **Live (felhom.eu `audits/night-2026-10-23/P2-D30/`):** the D30
|
||||||
|
re-key ran on a test box (before these two changes): clock corrected → 15 min → new key registered → the box switched
|
||||||
|
at its next desired-state fetch → a handshake with the new key.
|
||||||
|
- **D4 (operator ruling 2026-10-22, R-982) — root SSH from the LAN by key only, on `main` now (2026-10-23 day brief).** The
|
||||||
|
config bundle carries `/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf` (`PermitRootLogin prohibit-password`;
|
||||||
|
content check: exactly that line, `sshd -t -f`). After an install that wrote it: `sshd -t` (the whole config), then
|
||||||
|
`systemctl try-reload-or-restart ssh` (a stopped or socket-activated sshd no longer refuses the bundle), then `sshd -T`
|
||||||
|
is read back and a drop-in that is NOT in force (no Include, an earlier drop-in, a Match block) is said loudly and
|
||||||
|
reported as `sshd_dropin_not_in_force` — never a refusal; an undone bundle reloads sshd again. The console and the
|
||||||
|
web page keep the root password; felhom-sshd (8822) is not touched. Tests `RootKeyOnly` (6), red-proved; the
|
||||||
|
installer's uninstall removes the file (felhom.eu installer 1.36.0). Reviewed 2026-10-23 night (A1–A7).
|
||||||
|
- **Known gap, unchanged (filed):** after a reboot the kernel forgets the future stamp (latest handshake 0), so neither
|
||||||
|
this re-key nor the hub's alarm sees a box whose clock was corrected and which then restarted (R-1223).
|
||||||
|
|
||||||
|
### The future-stamped handshake is reported (R-1204) — first written on the 2026-10-22 night
|
||||||
|
|
||||||
|
- **R-982 / D4 — a ready patch on that night; on `main` since `1109092` and released in v0.163.0 (see above).** Built and proven the same night (felhom.eu
|
||||||
|
`audits/night-2026-10-22/P2-D4/`): a bundle drop-in `/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf`
|
||||||
|
(`PermitRootLogin prohibit-password`), a `sshd` content check (exactly `prohibit-password`; `sshd -t -f`), and after
|
||||||
|
an install that wrote it `sshd -t` + `systemctl reload ssh`; tests `RootKeyOnly` (3), red-proved. Live on a fresh
|
||||||
|
nested box from ISO 1.30.0: root password over SSH refused, root key works, the console and the web page still take
|
||||||
|
the password. It was committed (`a0c88e5`) and moved back out (`7651461`): the installer's uninstall must remove every bundle file (felhom.eu `scripts/test_hostinstall.py`), the bundle half reaches a box only with an agent release, and the installer half only with a new `installer-v…` tag (R-110 — a push to `main` alone publishes nothing) — both forbidden tonight. *(Corrected the same night: the first reason written here, „a push of `felhom-host-install.sh` publishes the installer", was wrong — the website serves `/scripts/` from the tag `installer-v1.35.0`, read live in `manifests/webpage.yaml` and the running Deployment.)* Both halves are patches: `felhom-agent-bundle-D4.patch` and `felhom.eu-installer-D4.patch` in that folder; apply them together with the agent release and the next installer tag.
|
||||||
|
- **R-1204 — `wireguard.handshake_future_s`.** A box that ran once with its clock ahead keeps a handshake stamped in the
|
||||||
|
future; ep0 refuses every newer one, and `last_handshake_age_s` (clamped at 0) read „just now" for ever. The heartbeat
|
||||||
|
now carries how many seconds the handshake lies ahead (> 60 s only); the age keeps its clamp. The hub alarms on it
|
||||||
|
(felhom.eu hub `wg_tunnel_stale`). Test `TestR1204_AFutureHandshakeIsReported`, red-proved.
|
||||||
|
|
||||||
|
## v0.162.0 — every guest may swap as much as its RAM (R-1192, the 8 GB freeze); the host's swap is reported and checked; no false „operator access degraded" on a fresh box (R-1194) (2026-10-22) — RELEASED on the operator's yes; the vouch is the operator's
|
||||||
|
|
||||||
|
Released 2026-10-22 07:40 UTC (operator yes, the morning answers) with `scripts/release-agent.sh` from a clean clone at
|
||||||
|
`b310773`: binary sha256 `5fd7a23bfe319cb521c4bc399612d5fb3413f1e695f79bc663c386f69c757928` (the same bytes as the
|
||||||
|
night's candidate), config bundle sha256 `ac772967fd4669045e837a70bf5acca239c58d55f7f00212e472a8facd60b5a5`, tag
|
||||||
|
`v0.162.0`; no `configs/` change since v0.161.0. Delivered by one signed `agent_update` job per box to demo-hp, Tester 1
|
||||||
|
and demo-felhom (07:49–07:58 UTC): each `signed op COMPLETED`, `capabilities self-check ok=76 total=76`, `update
|
||||||
|
committed`, then `guest-swap: raised` for its 9201 (512 MB → its RAM). Evidence felhom.eu
|
||||||
|
`documentation/audits/morning-2026-10-22/release/04-agent-delivery.txt`. **Found at once:** demo-hp (host swap 8191 MB,
|
||||||
|
guest swap 25898 MB) and demo-felhom (8191 MB vs 12288 MB) logged the host-swap WARN and the hub mailed
|
||||||
|
`host_swap_short` — the check compares the host's swap with the guests' whole swap allowance, which a guest larger
|
||||||
|
than the installer's 8 GB host swap always exceeds (felhom.eu register R-1216). Soak (night of 2026-10-21, 8 GB box):
|
||||||
|
11 h, no freeze, the host's swap peaked at 2.7 GB used.
|
||||||
|
|
||||||
|
|
||||||
The freeze of the rehearsal's first afternoon, reproduced and measured on demo-hp (felhom.eu
|
The freeze of the rehearsal's first afternoon, reproduced and measured on demo-hp (felhom.eu
|
||||||
`audits/fixes-2026-10-21/P4-freeze/`): Proxmox gives an LXC a 512 MB swap cap when nothing sets `swap`, and the agent
|
`audits/fixes-2026-10-21/P4-freeze/`): Proxmox gives an LXC a 512 MB swap cap when nothing sets `swap`, and the agent
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# CONTEXT — felhom-agent working state
|
# CONTEXT — felhom-agent working state
|
||||||
|
|
||||||
|
> **2026-10-22/23 night — D30 on `main` (`ae69ad4`), unreleased; the D4 patches v2 ready.** Operator ruling D30
|
||||||
|
> (2026-10-22): a handshake stamped in the future for 15 min → a new WG key pair (`wgtunnel/rekey.go`; old key kept as
|
||||||
|
> `wg/private.key.prev`), registered on `POST /hosts/<id>/wg` (the hub re-keys in place; its full-list push swaps the
|
||||||
|
> key on ep0). Live on a test box (felhom.eu `audits/night-2026-10-23/P2-D30/`): with ep0 free to start a handshake the
|
||||||
|
> tunnel heals by itself (no re-key, correct); with ep0's starts dropped it stays dead and the agent re-keyed at 15 min.
|
||||||
|
> The interface switches at the next desired-state fetch. Gap: a reboot forgets the future stamp (R-1223). The D4 bundle
|
||||||
|
> patch v2 (`P3-review/felhom-agent-bundle-D4-v2.patch`): `try-reload-or-restart`, an `sshd -T` read-back reported as
|
||||||
|
> `sshd_dropin_not_in_force`, a reload in undo. Next agent release = D30 + D4 v2 + R-1204's field, the operator vouches.
|
||||||
> **2026-10-20 — v0.161.0 RELEASED (operator yes), NOT VOUCHED; on demo-hp, Tester 1, demo-felhom** (signed jobs, all
|
> **2026-10-20 — v0.161.0 RELEASED (operator yes), NOT VOUCHED; on demo-hp, Tester 1, demo-felhom** (signed jobs, all
|
||||||
> committed, 76/76). R-921 `host_busy` (proven live on demo-hp during a press), R-1070 the tunnel `absent` state (9202's
|
> committed, 76/76). R-921 `host_busy` (proven live on demo-hp during a press), R-1070 the tunnel `absent` state (9202's
|
||||||
> real Docker 29 answer is the fixture row), R-1010 the janitor's one `force=1`. Vouch: version 0.161.0, sha256
|
> real Docker 29 answer is the fixture row), R-1010 the janitor's one `force=1`. Vouch: version 0.161.0, sha256
|
||||||
|
|||||||
@@ -1,18 +1,36 @@
|
|||||||
# REPORT — agent, the night of 2026-10-21: the host's swap is reported; no false emergency-door alarm on a new box — COMMITTED, NOT RELEASED
|
# REPORT — day 2026-10-23: agent 0.163.0 released, delivered to the three test boxes, vouched by the operator
|
||||||
|
|
||||||
**Brief:** the unattended night brief of 2026-10-21 (Parts 1, 2, 5). Architecture: felhom.eu `03-host-agent.md` §9 (the
|
**What:** the day brief's Part 2. Design read: `03-host-agent.md` (the tunnel, the bundle), `11` §5.4.2 (the step
|
||||||
new swap paragraph). Both join the swap fix (`286a7e3`) in the coming 0.162.0 — the operator's release and vouch (D26 = A).
|
bundle, R-880).
|
||||||
|
|
||||||
- **Host swap (R-1192 follow-up), `0da792e`:** the host report carries `host.host_swap_mb` (Proxmox node status = the
|
## Release
|
||||||
kernel's SwapTotal) and `host.guest_swap_mb` (the guests' `swap` sum; each guest spec `swap_bytes`); one WARN when the
|
|
||||||
first is below the second, one INFO when covered again; `GET /guest/memory` carries both for the controller's install
|
- Code: `1109092` (D4 v2: the sshd drop-in `/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf`,
|
||||||
page. The agent never creates host swap (register R-1207). Proven live on the soak box: the hub stored
|
`PermitRootLogin prohibit-password`, `try-reload-or-restart`, an `sshd -T` read-back; RootKeyOnly 6 tests). CI 2246
|
||||||
`host_swap_mb 7935, guest_swap_mb 4096`.
|
success. CHANGELOG `b4721ee` (v0.163.0 header, both sha256), CI 2249 success.
|
||||||
- **R-1194, `0da792e`:** no OOB stanza until the felhom-sshd loop's first pass has run — the first report on a fresh box
|
- `scripts/release-agent.sh 0.163.0`: binary sha256 `561b5316cd511de9fa9d8b8be4553da7add684f715d3bea533ca330ff7f3763c`,
|
||||||
read a config not yet rendered and the hub mailed `oob_degraded` (reproduced 2026-10-21 20:34 CEST on a fresh install).
|
config bundle sha256 `3afb88d9fbbc4adc16083fa52a98cd4974583c6378a77a1a6e0ed8ae6d1b80c8`; tag `v0.163.0`; verified by
|
||||||
- **The swap fix itself** ran as a hand-placed fixture on the 8 GB soak box: guest swap 512 → 4096 MB one minute after
|
an independent download.
|
||||||
start; the 2-hour household load answered every probe (felhom.eu `audits/night-2026-10-21/P1-soak/`).
|
- **Vouched by the operator** 07:58:46 UTC — read back from the hub DB (with its WAL) and from the running hub's
|
||||||
- Release candidate built from a clean clone at `0da792e` with the release flags, version `0.162.0`:
|
`/configuration` page: agent 0.163.0, both sha256 as above.
|
||||||
sha256 `5fd7a23bfe319cb521c4bc399612d5fb3413f1e695f79bc663c386f69c757928` (not published).
|
|
||||||
- Tests (red-proved): `TestR1192_HostReportCarriesSwapAndWarnsOnce`, `TestR1192_GuestMemoryCarriesHostAndGuestSwap`,
|
## Delivery — three signed jobs per box (felhom-opsign, key felhom-op-1)
|
||||||
`TestR1194_NoOOBStanzaBeforeTheFirstPass`. CI job 2176 green.
|
|
||||||
|
The D4 drop-in is a NEW bundle path, and the boxes ran bundle 0.160.0 (sha256 `6b0609…`), so: `agent_update 0.163.0`
|
||||||
|
→ `agent_config_update 0.163.0-step1` (built with `scripts/build-step-bundle.py` from 0.160.0: only
|
||||||
|
`felhom-os-apply` replaced; sha256 `6cbc0669…`, uploaded and read back) → `agent_config_update 0.163.0`.
|
||||||
|
|
||||||
|
| Box | agent | bundle | self-check | swap = RAM (pool guest 9201) | drop-in, `sshd -T` | sshd reloaded | key login | felhom-sshd |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tester 1 | 0.163.0 | 0.163.0 | ok | 4096 / 4096 | in place, `without-password` | 09:27:38 SIGHUP | ok | active, listening, `sshd -t` ok |
|
||||||
|
| demo-hp | 0.163.0 | 0.163.0 | ok | 25898 / 25898 | in place, `without-password` | 09:19:44 SIGHUP | ok | active, listening, `sshd -t` ok |
|
||||||
|
| demo-felhom | 0.163.0 | 0.163.0 | ok | 12288 / 12288 | in place, `without-password` | 09:24:42 SIGHUP | ok | active, listening, `sshd -t` ok |
|
||||||
|
|
||||||
|
**Not proven, said plainly:** the real root password over SSH was not tried (CC does not hold it); a dummy password is
|
||||||
|
refused, but OpenSSH logs that only as „Failed password", so the policy proof is `sshd -T` + the reload. felhom-sshd
|
||||||
|
(:8822) admits only the operator peer over the tunnel, so a connection from DooPlex times out by design. The console
|
||||||
|
login is not touched by the drop-in (`passwd -S root` = P on all three); not tried at a physical console.
|
||||||
|
|
||||||
|
Installer 1.36.0 (the uninstall removes the drop-in) was published the same day (felhom.eu). R-982 and R-1204 closed.
|
||||||
|
Evidence: felhom.eu `documentation/audits/day-2026-10-23/07-agent-0163-boxes.txt`, `10-vouch-readback.txt`,
|
||||||
|
`11-installer-1360.txt`.
|
||||||
|
|||||||
+41
-3
@@ -165,7 +165,8 @@ KERNEL_ACTIVE = ("staged", "oneshot", "judging", "reverting")
|
|||||||
# dest: (source file under configs/, mode, check, policy)
|
# dest: (source file under configs/, mode, check, policy)
|
||||||
# check: the content check BEFORE anything is written (R18): sudoers → visudo -cf; sh / bash → -n; python → compile;
|
# check: the content check BEFORE anything is written (R18): sudoers → visudo -cf; sh / bash → -n; python → compile;
|
||||||
# unit → a [Unit]/[Service]/[Timer] section; unit-nort → that, and never RuntimeDirectory= (the G1 incident);
|
# unit → a [Unit]/[Service]/[Timer] section; unit-nort → that, and never RuntimeDirectory= (the G1 incident);
|
||||||
# agent-unit → User=felhom-agent; dropin → a [Unit] section (SF-3); nft → nft -c -f; plain → none.
|
# agent-unit → User=felhom-agent; dropin → a [Unit] section (SF-3); nft → nft -c -f; plain → none;
|
||||||
|
# sshd → `sshd -t -f` on the snippet alone (R-982).
|
||||||
# policy: replace → always written when it differs; if-absent → only when the box has none (a setting the operator may
|
# policy: replace → always written when it differs; if-absent → only when the box has none (a setting the operator may
|
||||||
# have tuned); oob → only on a box with the OOB belt (/etc/felhom-sshd exists — the installer's --no-oob leaves none).
|
# have tuned); oob → only on a box with the OOB belt (/etc/felhom-sshd exists — the installer's --no-oob leaves none).
|
||||||
# Order matters: the sudoers files come LAST, so a referenced wrapper is in place before the line that allows it.
|
# Order matters: the sudoers files come LAST, so a referenced wrapper is in place before the line that allows it.
|
||||||
@@ -184,6 +185,9 @@ BUNDLE_KEEP = 3
|
|||||||
BUNDLE_MAX_BYTES = 4 * 1024 * 1024
|
BUNDLE_MAX_BYTES = 4 * 1024 * 1024
|
||||||
BUNDLE_RE = re.compile(r"^bundle-[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?\.json$")
|
BUNDLE_RE = re.compile(r"^bundle-[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?\.json$")
|
||||||
OOB_DIR = "/etc/felhom-sshd"
|
OOB_DIR = "/etc/felhom-sshd"
|
||||||
|
# R-982: the host's own sshd drop-in (after_install reloads sshd for it; undo reloads after putting it back).
|
||||||
|
SSHD_DROPIN = "/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf"
|
||||||
|
|
||||||
BUNDLE_FILES = [
|
BUNDLE_FILES = [
|
||||||
("/usr/local/sbin/felhom-mkfs-guarded", "felhom-mkfs-guarded.sh", 0o755, "bash", "replace"),
|
("/usr/local/sbin/felhom-mkfs-guarded", "felhom-mkfs-guarded.sh", 0o755, "bash", "replace"),
|
||||||
("/usr/local/sbin/felhom-selfupdate-guarded", "felhom-selfupdate-guarded", 0o755, "sh", "replace"),
|
("/usr/local/sbin/felhom-selfupdate-guarded", "felhom-selfupdate-guarded", 0o755, "sh", "replace"),
|
||||||
@@ -221,6 +225,9 @@ BUNDLE_FILES = [
|
|||||||
("/etc/systemd/system/felhom-sshd.service", "felhom-sshd.service", 0o644, "unit-nort", "oob"),
|
("/etc/systemd/system/felhom-sshd.service", "felhom-sshd.service", 0o644, "unit-nort", "oob"),
|
||||||
("/etc/felhom-oob.nft", "felhom-oob.nft", 0o644, "nft", "oob"),
|
("/etc/felhom-oob.nft", "felhom-oob.nft", 0o644, "nft", "oob"),
|
||||||
("/etc/systemd/system/felhom-oob-nft.service", "felhom-oob-nft.service", 0o644, "unit", "oob"),
|
("/etc/systemd/system/felhom-oob-nft.service", "felhom-oob-nft.service", 0o644, "unit", "oob"),
|
||||||
|
# R-982, operator ruling D4 (2026-10-22): the host's own sshd (port 22, the LAN): root by key only. The console
|
||||||
|
# password stays; felhom-sshd (the operator's door) reads nothing from sshd_config.d. Reloaded in after_install.
|
||||||
|
("/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf", "felhom-root-ssh-key-only.conf", 0o644, "sshd", "replace"),
|
||||||
("/etc/sudoers.d/felhom-op", "felhom-op.sudoers", 0o440, "sudoers", "oob"),
|
("/etc/sudoers.d/felhom-op", "felhom-op.sudoers", 0o440, "sudoers", "oob"),
|
||||||
("/etc/sudoers.d/felhom-agent", "felhom-agent.sudoers", 0o440, "sudoers", "replace"),
|
("/etc/sudoers.d/felhom-agent", "felhom-agent.sudoers", 0o440, "sudoers", "replace"),
|
||||||
]
|
]
|
||||||
@@ -434,7 +441,8 @@ class Runner:
|
|||||||
def check_content(self, kind, data):
|
def check_content(self, kind, data):
|
||||||
"""Run an external syntax check on a root-only temp copy. Returns (rc, message)."""
|
"""Run an external syntax check on a root-only temp copy. Returns (rc, message)."""
|
||||||
import tempfile
|
import tempfile
|
||||||
cmd = {"sudoers": ["visudo", "-cf"], "sh": ["sh", "-n"], "bash": ["bash", "-n"], "nft": ["nft", "-c", "-f"]}[kind]
|
cmd = {"sudoers": ["visudo", "-cf"], "sh": ["sh", "-n"], "bash": ["bash", "-n"], "nft": ["nft", "-c", "-f"],
|
||||||
|
"sshd": ["/usr/sbin/sshd", "-t", "-f"]}[kind]
|
||||||
with tempfile.TemporaryDirectory(prefix="felhom-bundle-") as d:
|
with tempfile.TemporaryDirectory(prefix="felhom-bundle-") as d:
|
||||||
p = os.path.join(d, "f")
|
p = os.path.join(d, "f")
|
||||||
with open(p, "wb") as f:
|
with open(p, "wb") as f:
|
||||||
@@ -1864,6 +1872,7 @@ class Bundle:
|
|||||||
def __init__(self, apply):
|
def __init__(self, apply):
|
||||||
self.a, self.r = apply, apply.r
|
self.a, self.r = apply, apply.r
|
||||||
self.report = apply.report
|
self.report = apply.report
|
||||||
|
self.sshd_dropin_not_in_force = False
|
||||||
|
|
||||||
# ---------- reading and checking ----------
|
# ---------- reading and checking ----------
|
||||||
def load_file(self, path):
|
def load_file(self, path):
|
||||||
@@ -1932,10 +1941,17 @@ class Bundle:
|
|||||||
text = content.decode("utf-8")
|
text = content.decode("utf-8")
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
raise Refused("R18", f"{dest} is not UTF-8 text")
|
raise Refused("R18", f"{dest} is not UTF-8 text")
|
||||||
if check in ("sudoers", "sh", "bash", "nft"):
|
if check in ("sudoers", "sh", "bash", "nft", "sshd"):
|
||||||
rc, msg = self.r.check_content(check, content)
|
rc, msg = self.r.check_content(check, content)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
raise Refused("R18", f"{dest} fails its {check} check: {msg}")
|
raise Refused("R18", f"{dest} fails its {check} check: {msg}")
|
||||||
|
if check == "sshd":
|
||||||
|
# R-982: the drop-in's PermitRootLogin must be exactly `prohibit-password`. `no` would also shut the root key
|
||||||
|
# out (Proxmox's own node-to-node SSH and the LAN key route), `yes` would undo the ruling — both refused.
|
||||||
|
# Only that line is pinned: the file is signed like every bundle file (review A7).
|
||||||
|
vals = re.findall(r"^\s*PermitRootLogin\s+(\S+)\s*$", text, re.M | re.I)
|
||||||
|
if vals != ["prohibit-password"]:
|
||||||
|
raise Refused("R18", f"{dest} must set exactly `PermitRootLogin prohibit-password` (found {vals})")
|
||||||
elif check == "python":
|
elif check == "python":
|
||||||
try:
|
try:
|
||||||
compile(text, dest, "exec")
|
compile(text, dest, "exec")
|
||||||
@@ -2044,6 +2060,8 @@ class Bundle:
|
|||||||
self.report["failed"] = {"rc": 3, "step": "bundle", "reason": reason[:300]}
|
self.report["failed"] = {"rc": 3, "step": "bundle", "reason": reason[:300]}
|
||||||
return 3
|
return 3
|
||||||
rep["signers_created"] = False
|
rep["signers_created"] = False
|
||||||
|
if self.sshd_dropin_not_in_force:
|
||||||
|
rep["sshd_dropin_not_in_force"] = True # R-982 A2: written and reloaded, but sshd -T shows root passwords allowed
|
||||||
if bootstrap and not self.r.lexists(TRUST_SIGNERS):
|
if bootstrap and not self.r.lexists(TRUST_SIGNERS):
|
||||||
self.r.put_file(TRUST_SIGNERS, pinned_signers_line().encode(), 0o644)
|
self.r.put_file(TRUST_SIGNERS, pinned_signers_line().encode(), 0o644)
|
||||||
rep["signers_created"] = True
|
rep["signers_created"] = True
|
||||||
@@ -2073,6 +2091,23 @@ class Bundle:
|
|||||||
"enable the crash guard")
|
"enable the crash guard")
|
||||||
if "/etc/systemd/system/felhom-mgmt-watchdog.timer" in dests:
|
if "/etc/systemd/system/felhom-mgmt-watchdog.timer" in dests:
|
||||||
self.must(["systemctl", "enable", "--now", "felhom-mgmt-watchdog.timer"], "enable the mgmt watchdog timer")
|
self.must(["systemctl", "enable", "--now", "felhom-mgmt-watchdog.timer"], "enable the mgmt watchdog timer")
|
||||||
|
# R-982: the host's sshd takes the root-key-only drop-in. The WHOLE config is tested first (a failure is a
|
||||||
|
# refusal, and the bundle undo puts the old files back); `try-reload-or-restart` keeps every open session and
|
||||||
|
# does nothing on a box whose ssh.service is not running (a socket-activated or stopped sshd reads the file
|
||||||
|
# at its next start) — a failure there must not freeze the bundle route (review 2026-10-22 night, A1).
|
||||||
|
# felhom-sshd is not touched. Then the POSITIVE check: sshd's own effective view for root. A box whose
|
||||||
|
# sshd_config has no Include of sshd_config.d, an earlier drop-in or a Match block can keep the password route
|
||||||
|
# — that is said loudly in the log and the report, never silently recorded as done (A2).
|
||||||
|
if SSHD_DROPIN in written:
|
||||||
|
self.must(["/usr/sbin/sshd", "-t"], "sshd -t (the host's whole sshd config)")
|
||||||
|
self.must(["systemctl", "try-reload-or-restart", "ssh"], "systemctl try-reload-or-restart ssh")
|
||||||
|
rc, out, err = self.r.host(["/usr/sbin/sshd", "-T", "-C", "user=root,host=felhom-check,addr=192.0.2.1"], 60)
|
||||||
|
eff = re.findall(r"^permitrootlogin\s+(\S+)", out, re.M | re.I)
|
||||||
|
if rc != 0 or eff != ["without-password"]:
|
||||||
|
self.r.log(f"os-apply: BUNDLE WARNING the root-key-only drop-in is NOT in force: sshd -T says "
|
||||||
|
f"permitrootlogin={eff or '?'} (rc={rc}) — check sshd_config's Include, an earlier drop-in "
|
||||||
|
f"or a Match block (R-982)")
|
||||||
|
self.sshd_dropin_not_in_force = True
|
||||||
|
|
||||||
def must(self, argv, what):
|
def must(self, argv, what):
|
||||||
rc, out, err = self.r.host(argv, 120)
|
rc, out, err = self.r.host(argv, 120)
|
||||||
@@ -2133,6 +2168,9 @@ class Bundle:
|
|||||||
self.r.log(f"os-apply: BUNDLE UNDO could not restore {dest}: {e}")
|
self.r.log(f"os-apply: BUNDLE UNDO could not restore {dest}: {e}")
|
||||||
if any(d.startswith("/etc/systemd/system/") for d in back):
|
if any(d.startswith("/etc/systemd/system/") for d in back):
|
||||||
self.r.host(["systemctl", "daemon-reload"], 120)
|
self.r.host(["systemctl", "daemon-reload"], 120)
|
||||||
|
if SSHD_DROPIN in back:
|
||||||
|
# A3: after_install may already have reloaded sshd with the drop-in; the restored state takes effect now.
|
||||||
|
self.r.host(["systemctl", "try-reload-or-restart", "ssh"], 120)
|
||||||
rc, _, _ = self.r.host(["visudo", "-c"], 60)
|
rc, _, _ = self.r.host(["visudo", "-c"], 60)
|
||||||
self.r.log(f"os-apply: BUNDLE UNDO restored={len(back)} visudo-after={'ok' if rc == 0 else 'FAILED'}")
|
self.r.log(f"os-apply: BUNDLE UNDO restored={len(back)} visudo-after={'ok' if rc == 0 else 'FAILED'}")
|
||||||
return back
|
return back
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# MANAGED BY FELHOM (the agent's config bundle) — R-982, operator ruling D4 (2026-10-22).
|
||||||
|
# The Proxmox host's OWN sshd — the listener the household network reaches on port 22: root logs in with a KEY only.
|
||||||
|
# The root password still works on the console (the break-glass password: felhom.eu runbooks/break-glass.md). The
|
||||||
|
# operator's door (felhom-sshd, port 8822, /etc/felhom-sshd/sshd_config) is a separate daemon whose config includes
|
||||||
|
# nothing from here, so it is not changed. Debian's sshd_config reads this folder BEFORE its own settings, and for
|
||||||
|
# sshd the first value wins; the bundle checks the effective value with `sshd -T` after the reload.
|
||||||
|
PermitRootLogin prohibit-password
|
||||||
@@ -429,6 +429,90 @@ class SelfCheckUndo(unittest.TestCase):
|
|||||||
self.assert_restored(box, before, rep)
|
self.assert_restored(box, before, rep)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class RootKeyOnly(unittest.TestCase):
|
||||||
|
"""R-982, operator ruling D4 (2026-10-22): the bundle gives the host's own sshd a root-key-only drop-in, tests the
|
||||||
|
whole config and reloads sshd — only when the file was written; a drop-in that says anything but
|
||||||
|
`prohibit-password` is refused before anything is written.
|
||||||
|
RED-PROOF: with the after_install block removed, test_fresh_box_takes_the_dropin_and_reloads fails (no reload);
|
||||||
|
with the content rule removed, test_a_dropin_that_says_yes_or_no_is_refused fails — seen failing 2026-10-22."""
|
||||||
|
DEST = "/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf"
|
||||||
|
|
||||||
|
def test_fresh_box_takes_the_dropin_and_reloads(self):
|
||||||
|
data, sha = real_bundle()
|
||||||
|
box = Box(data, signed_job(sha))
|
||||||
|
rc, rep = run(box)
|
||||||
|
self.assertEqual(rc, 0, rep)
|
||||||
|
self.assertIn(b"PermitRootLogin prohibit-password", box.files[self.DEST])
|
||||||
|
self.assertEqual(box.modes[self.DEST], 0o644)
|
||||||
|
self.assertIn(["/usr/sbin/sshd", "-t"], box.calls)
|
||||||
|
self.assertIn(["systemctl", "try-reload-or-restart", "ssh"], box.calls)
|
||||||
|
self.assertLess(box.calls.index(["/usr/sbin/sshd", "-t"]), box.calls.index(["systemctl", "try-reload-or-restart", "ssh"]))
|
||||||
|
self.assertFalse(any("felhom-sshd" in " ".join(c) for c in box.calls), "the operator's door must not be touched")
|
||||||
|
|
||||||
|
def test_an_unchanged_dropin_is_not_reloaded(self):
|
||||||
|
data, sha = real_bundle()
|
||||||
|
box = Box(data, signed_job(sha))
|
||||||
|
box.put(self.DEST, (REPO / "configs" / "felhom-root-ssh-key-only.conf").read_bytes(), 0o644)
|
||||||
|
rc, rep = run(box)
|
||||||
|
self.assertEqual(rc, 0, rep)
|
||||||
|
self.assertNotIn(["systemctl", "try-reload-or-restart", "ssh"], box.calls)
|
||||||
|
|
||||||
|
def test_a_dropin_that_says_yes_or_no_is_refused(self):
|
||||||
|
for bad in (b"PermitRootLogin yes\n", b"PermitRootLogin no\n", b"# nothing\n",
|
||||||
|
b"PermitRootLogin prohibit-password\nPermitRootLogin yes\n"):
|
||||||
|
def edit(files, bad=bad):
|
||||||
|
for e in files:
|
||||||
|
if e["path"] == self.DEST:
|
||||||
|
e["content_b64"] = base64.b64encode(bad).decode()
|
||||||
|
data, sha = edited_bundle(edit)
|
||||||
|
box = Box(data, signed_job(sha))
|
||||||
|
rc, rep = run(box)
|
||||||
|
self.assertNotEqual(rc, 0, bad)
|
||||||
|
self.assertNotIn(self.DEST, box.files, bad)
|
||||||
|
|
||||||
|
# Review 2026-10-22 night. A2: the effective value is read back with `sshd -T`; a drop-in that is not in force is
|
||||||
|
# said in the log and the report (not refused — a refusal would freeze the bundle route). A3: a bundle undone
|
||||||
|
# after the reload reloads sshd again with the old state. RED-PROOF: with the sshd -T check removed,
|
||||||
|
# test_a_dropin_not_in_force_is_reported fails; with the undo reload removed, test_an_undone_bundle_reloads_sshd
|
||||||
|
# fails — seen failing 2026-10-22 night.
|
||||||
|
def _sshd_T(self, box, value):
|
||||||
|
orig = box.host
|
||||||
|
def host(argv, timeout=600, stdin=None):
|
||||||
|
if argv[:2] == ["/usr/sbin/sshd", "-T"]:
|
||||||
|
box.calls.append(argv)
|
||||||
|
return 0, f"port 22\npermitrootlogin {value}\npasswordauthentication yes\n", ""
|
||||||
|
return orig(argv, timeout, stdin)
|
||||||
|
box.host = host
|
||||||
|
|
||||||
|
def test_the_effective_value_is_checked(self):
|
||||||
|
data, sha = real_bundle()
|
||||||
|
box = Box(data, signed_job(sha))
|
||||||
|
self._sshd_T(box, "without-password")
|
||||||
|
rc, rep = run(box)
|
||||||
|
self.assertEqual(rc, 0, rep)
|
||||||
|
self.assertTrue(any(c[:2] == ["/usr/sbin/sshd", "-T"] for c in box.calls))
|
||||||
|
self.assertNotIn("sshd_dropin_not_in_force", rep["bundle"])
|
||||||
|
|
||||||
|
def test_a_dropin_not_in_force_is_reported(self):
|
||||||
|
data, sha = real_bundle()
|
||||||
|
box = Box(data, signed_job(sha))
|
||||||
|
self._sshd_T(box, "yes") # e.g. no Include of sshd_config.d
|
||||||
|
rc, rep = run(box)
|
||||||
|
self.assertEqual(rc, 0, rep)
|
||||||
|
self.assertTrue(rep["bundle"].get("sshd_dropin_not_in_force"), rep)
|
||||||
|
self.assertTrue(any("NOT in force" in l for l in box.logs), box.logs)
|
||||||
|
|
||||||
|
def test_an_undone_bundle_reloads_sshd(self):
|
||||||
|
data, sha = real_bundle()
|
||||||
|
box = Box(data, signed_job(sha))
|
||||||
|
box.visudo_fail = True # the self-check fails AFTER the install → undo
|
||||||
|
rc, rep = run(box)
|
||||||
|
self.assertEqual(rc, 3, rep)
|
||||||
|
self.assertNotIn(self.DEST, box.files)
|
||||||
|
reloads = [i for i, c in enumerate(box.calls) if c == ["systemctl", "try-reload-or-restart", "ssh"]]
|
||||||
|
self.assertEqual(len(reloads), 2, box.calls) # after_install, then the undo
|
||||||
|
|
||||||
class TrustBootstrap(unittest.TestCase):
|
class TrustBootstrap(unittest.TestCase):
|
||||||
def test_missing_signers_verifies_against_the_pinned_key_and_creates_it(self):
|
def test_missing_signers_verifies_against_the_pinned_key_and_creates_it(self):
|
||||||
data, sha = real_bundle()
|
data, sha = real_bundle()
|
||||||
|
|||||||
@@ -292,7 +292,11 @@ type WireguardStatus struct {
|
|||||||
Registered bool `json:"registered"` // the registration marker exists
|
Registered bool `json:"registered"` // the registration marker exists
|
||||||
Active bool `json:"active"` // wg-quick@wg-felhom is-active
|
Active bool `json:"active"` // wg-quick@wg-felhom is-active
|
||||||
LastHandshakeAgeS *int64 `json:"last_handshake_age_s,omitempty"`
|
LastHandshakeAgeS *int64 `json:"last_handshake_age_s,omitempty"`
|
||||||
AssignedIP string `json:"assigned_ip,omitempty"` // from the marker, e.g. "10.77.0.2/32"
|
// HandshakeFutureS (R-1204): seconds the latest handshake lies in the FUTURE by this box's clock — set (> 0) only
|
||||||
|
// then. A box that ran once with its clock ahead keeps a handshake stamped in the future; WireGuard on ep0 then
|
||||||
|
// refuses every newer handshake, and LastHandshakeAgeS (clamped at 0) reads „just now" for ever. The hub alarms on it.
|
||||||
|
HandshakeFutureS *int64 `json:"handshake_future_s,omitempty"`
|
||||||
|
AssignedIP string `json:"assigned_ip,omitempty"` // from the marker, e.g. "10.77.0.2/32"
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemInfo is the `system` stanza (see HostReport.System).
|
// SystemInfo is the `system` stanza (see HostReport.System).
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ type Manager struct {
|
|||||||
resolver Resolver
|
resolver Resolver
|
||||||
|
|
||||||
staleAfter time.Duration // handshake-age threshold for the re-resolve watchdog (doc 06 §4.2)
|
staleAfter time.Duration // handshake-age threshold for the re-resolve watchdog (doc 06 §4.2)
|
||||||
|
rekeyAfter time.Duration // D30: how long a future-stamped handshake stays before the re-key (rekey.go)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
nextRegAt time.Time
|
nextRegAt time.Time
|
||||||
@@ -111,6 +112,10 @@ type Manager struct {
|
|||||||
resolveFailLogged bool // DNS-failure log throttle (reset on the next success)
|
resolveFailLogged bool // DNS-failure log throttle (reset on the next success)
|
||||||
staleSameIPLogged bool // "stale but IP unchanged" log throttle (endpoint-down, not re-IP)
|
staleSameIPLogged bool // "stale but IP unchanged" log throttle (endpoint-down, not re-IP)
|
||||||
|
|
||||||
|
// D30 (rekey.go): when the latest handshake was first seen stamped in the future, and the last re-key.
|
||||||
|
futureSince time.Time
|
||||||
|
lastRekey time.Time
|
||||||
|
|
||||||
// conv is the CACHED tunnel-convergence snapshot (v0.90.0, R-28 fast-tick source), refreshed at
|
// conv is the CACHED tunnel-convergence snapshot (v0.90.0, R-28 fast-tick source), refreshed at
|
||||||
// the end of every Apply (the tunnel's own cadence) so the fast-tick reads it WITHOUT execing
|
// the end of every Apply (the tunnel's own cadence) so the fast-tick reads it WITHOUT execing
|
||||||
// `wg`/`systemctl` per tick. wgBlockDesired = a wireguard block for THIS key is desired;
|
// `wg`/`systemctl` per tick. wgBlockDesired = a wireguard block for THIS key is desired;
|
||||||
@@ -156,6 +161,7 @@ func NewManager(runner proxmox.Runner, registrar Registrar, stateDir string, log
|
|||||||
now: time.Now,
|
now: time.Now,
|
||||||
resolver: netResolver{},
|
resolver: netResolver{},
|
||||||
staleAfter: 180 * time.Second,
|
staleAfter: 180 * time.Second,
|
||||||
|
rekeyAfter: rekeyAfterDefault,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,6 +606,10 @@ func (m *Manager) Watchdog(ctx context.Context, fetched bool, block *hub.WireWir
|
|||||||
if !fetched || block == nil || !m.lastResolvedIP.IsValid() {
|
if !fetched || block == nil || !m.lastResolvedIP.IsValid() {
|
||||||
return // nothing applied to watch, or no desired block
|
return // nothing applied to watch, or no desired block
|
||||||
}
|
}
|
||||||
|
// D30: a handshake stamped in the future that stays → a new key on the existing peer path (rekey.go).
|
||||||
|
if m.clockAheadRekeyLocked(ctx, block) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if !m.handshakeStaleLocked(ctx) {
|
if !m.handshakeStaleLocked(ctx) {
|
||||||
return // healthy → no DNS, no action (the load-bearing negative)
|
return // healthy → no DNS, no action (the load-bearing negative)
|
||||||
}
|
}
|
||||||
@@ -661,6 +671,9 @@ func (m *Manager) Status(ctx context.Context) *hub.WireguardStatus {
|
|||||||
if age, ok := parseHandshakeAge(string(out), m.now()); ok {
|
if age, ok := parseHandshakeAge(string(out), m.now()); ok {
|
||||||
st.LastHandshakeAgeS = &age
|
st.LastHandshakeAgeS = &age
|
||||||
}
|
}
|
||||||
|
if fut, ok := handshakeFuture(string(out), m.now()); ok {
|
||||||
|
st.HandshakeFutureS = &fut
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if st.Pubkey == "" && !st.Registered && !st.Active {
|
if st.Pubkey == "" && !st.Registered && !st.Active {
|
||||||
@@ -669,6 +682,28 @@ func (m *Manager) Status(ctx context.Context) *hub.WireguardStatus {
|
|||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handshakeFuture (R-1204) reports how many seconds the latest handshake lies in the future, ok only when it does
|
||||||
|
// (by more than a minute — clock jitter is not the case). parseHandshakeAge clamps that case to 0 („just now"), which
|
||||||
|
// is what made a box whose clock had run ahead look healthy while ep0 refused its tunnel. Pinned by
|
||||||
|
// TestR1204_AFutureHandshakeIsReported.
|
||||||
|
func handshakeFuture(out string, now time.Time) (int64, bool) {
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
if len(f) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
epoch, err := strconv.ParseInt(f[1], 10, 64)
|
||||||
|
if err != nil || epoch <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fut := epoch - now.Unix(); fut > 60 {
|
||||||
|
return fut, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
// parseHandshakeAge parses `wg show <if> latest-handshakes` output ("<peer-pub>\t<epoch>").
|
// parseHandshakeAge parses `wg show <if> latest-handshakes` output ("<peer-pub>\t<epoch>").
|
||||||
// epoch 0 = no handshake yet → not ok (nil in the report; nil ≠ 0).
|
// epoch 0 = no handshake yet → not ok (nil in the report; nil ≠ 0).
|
||||||
func parseHandshakeAge(out string, now time.Time) (int64, bool) {
|
func parseHandshakeAge(out string, now time.Time) (int64, bool) {
|
||||||
|
|||||||
@@ -696,3 +696,31 @@ func TestInitialResolveFailureNoTeardown(t *testing.T) {
|
|||||||
t.Errorf("did not recover after DNS returned: %v", rr.calls)
|
t.Errorf("did not recover after DNS returned: %v", rr.calls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// R-1204: a box that ran once with its clock ahead keeps a handshake stamped in the FUTURE — ep0 then refuses every
|
||||||
|
// newer one. The age stays clamped at 0 (unchanged contract), and the new field says how far ahead it lies; a
|
||||||
|
// handshake in the past (or within a minute of now) never sets it.
|
||||||
|
// RED-PROOF: with Status not setting HandshakeFutureS the first assertion fails — seen failing 2026-10-22.
|
||||||
|
func TestR1204_AFutureHandshakeIsReported(t *testing.T) {
|
||||||
|
fh := &fakeHub{}
|
||||||
|
m, _, active, _ := testManager(t, fh)
|
||||||
|
ctx := context.Background()
|
||||||
|
m.Apply(ctx, false, nil)
|
||||||
|
*active = true
|
||||||
|
m.now = func() time.Time { return time.Unix(1783107115-2*24*3600, 0) } // the clock came back two days earlier
|
||||||
|
st := m.Status(ctx)
|
||||||
|
if st.HandshakeFutureS == nil || *st.HandshakeFutureS != 2*24*3600 {
|
||||||
|
t.Fatalf("handshake_future_s = %v, want %d", st.HandshakeFutureS, 2*24*3600)
|
||||||
|
}
|
||||||
|
if st.LastHandshakeAgeS == nil || *st.LastHandshakeAgeS != 0 {
|
||||||
|
t.Fatalf("the age stays clamped at 0: %v", st.LastHandshakeAgeS)
|
||||||
|
}
|
||||||
|
m.now = func() time.Time { return time.Unix(1783107115+100, 0) }
|
||||||
|
if st = m.Status(ctx); st.HandshakeFutureS != nil {
|
||||||
|
t.Fatalf("a past handshake must not set handshake_future_s: %v", *st.HandshakeFutureS)
|
||||||
|
}
|
||||||
|
m.now = func() time.Time { return time.Unix(1783107115-30, 0) } // 30 s of jitter is not the case
|
||||||
|
if st = m.Status(ctx); st.HandshakeFutureS != nil {
|
||||||
|
t.Fatalf("clock jitter must not set handshake_future_s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package wgtunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
// D30 (operator 2026-10-22, R-1204): the tunnel heals itself after the box ran with its clock ahead.
|
||||||
|
//
|
||||||
|
// WireGuard refuses a handshake whose timestamp is not newer than the last one it accepted from that key (replay
|
||||||
|
// protection). A box whose clock ran ahead handshook with a future timestamp; ep0 keeps it, and once the clock is
|
||||||
|
// right again ep0 refuses every new handshake from that key — until the box's clock passes the stored stamp. The
|
||||||
|
// sign on the box: its own latest handshake stays stamped in the future (R-1204's handshake_future_s).
|
||||||
|
//
|
||||||
|
// The repair: when that sign stays for rekeyAfter, the agent makes a NEW key pair and registers the new public key on
|
||||||
|
// the existing path (POST /hosts/<id>/wg → the hub re-keys the peer IN PLACE: same /32, new key, generation bump,
|
||||||
|
// and the wgsync push of the FULL peer list to ep0 drops the old key and adds the new one). A new key is a new peer
|
||||||
|
// for ep0, with no stored timestamp — the next handshake is accepted. Nothing new runs on ep0.
|
||||||
|
//
|
||||||
|
// The previous private key is kept as private.key.prev (0600): the escrow ceremony may hold it (a host-loss DR that
|
||||||
|
// installs it registers it again, and the hub re-keys in place back to it — the same path).
|
||||||
|
// Pinned by TestD30_* (rekey_test.go).
|
||||||
|
|
||||||
|
const (
|
||||||
|
// rekeyAfter: how long the latest handshake must stay in the future before the re-key. A working tunnel
|
||||||
|
// handshakes every two minutes, which ends the sign within minutes of the clock being right.
|
||||||
|
rekeyAfterDefault = 15 * time.Minute
|
||||||
|
// rekeyMinGap bounds the re-key to once an hour, whatever the clock does.
|
||||||
|
rekeyMinGap = time.Hour
|
||||||
|
prevKeyName = "private.key.prev"
|
||||||
|
)
|
||||||
|
|
||||||
|
// clockAheadRekeyLocked watches for a handshake stamped in the future and re-keys when it stays. It reports whether
|
||||||
|
// it re-keyed (the caller then skips the rest of its tick). Caller holds m.mu.
|
||||||
|
func (m *Manager) clockAheadRekeyLocked(ctx context.Context, block *hub.WireWireguard) bool {
|
||||||
|
if m.keyBroken || !m.isActive(ctx) {
|
||||||
|
m.futureSince = time.Time{}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
out, _, err := m.runner.Run(ctx, "wg", "show", Iface, "latest-handshakes")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := m.now()
|
||||||
|
fut, ok := handshakeFuture(string(out), now)
|
||||||
|
if !ok {
|
||||||
|
if !m.futureSince.IsZero() {
|
||||||
|
m.logger.Info("wgtunnel: the handshake is no longer stamped in the future — no re-key needed")
|
||||||
|
}
|
||||||
|
m.futureSince = time.Time{}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if m.futureSince.IsZero() {
|
||||||
|
m.futureSince = now
|
||||||
|
m.logger.Warn("wgtunnel: the latest handshake is stamped in the future — this box's clock ran ahead, and the endpoint refuses its newer handshakes; re-keying if it stays",
|
||||||
|
"future_s", fut, "rekey_after", m.rekeyAfter.String())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// A clock that jumps backwards must not stretch either wait (commit security review 2026-10-23 night): a start or a
|
||||||
|
// last re-key stamped AFTER now is from a wrong clock and is dropped — this whole feature exists because clocks jump.
|
||||||
|
if now.Before(m.futureSince) {
|
||||||
|
m.futureSince = now
|
||||||
|
}
|
||||||
|
if now.Sub(m.futureSince) < m.rekeyAfter {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if m.lastRekey.IsZero() {
|
||||||
|
m.lastRekey = m.loadLastRekey()
|
||||||
|
}
|
||||||
|
if now.Before(m.lastRekey) {
|
||||||
|
m.lastRekey = time.Time{}
|
||||||
|
}
|
||||||
|
if !m.lastRekey.IsZero() && now.Sub(m.lastRekey) < rekeyMinGap {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pub, _, err := EnsureKey(m.stateDir)
|
||||||
|
if err != nil || block.Pubkey != pub {
|
||||||
|
return false // a re-register is already in flight, or the key is not readable — never re-key over that
|
||||||
|
}
|
||||||
|
newPub, err := rotateKey(m.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("wgtunnel: re-key after a future handshake FAILED — the old key stays", "err", err)
|
||||||
|
m.lastRekey = now // not again before rekeyMinGap
|
||||||
|
m.saveLastRekey(now)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m.lastRekey, m.futureSince = now, time.Time{}
|
||||||
|
m.saveLastRekey(now)
|
||||||
|
m.logger.Warn("wgtunnel: RE-KEYED after a handshake stamped in the future (D30) — registering the new key; the hub re-keys the peer in place and the endpoint takes the new key",
|
||||||
|
"old_pubkey", pub, "new_pubkey", newPub, "future_s", fut)
|
||||||
|
m.nextRegAt = time.Time{} // register now, not after an older backoff
|
||||||
|
m.registerLocked(ctx, newPub)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotateKey makes a new key pair, never leaving the box without a readable key (review 2026-10-23 night, N6): the new
|
||||||
|
// key is written and fsynced to a temp file; the current key gets a second name by a HARD LINK (the escrow may hold
|
||||||
|
// it — `private.key.prev` keeps the first such key, later ones `private.key.prev.<unix>`, the newest three kept, N7);
|
||||||
|
// then the temp file is renamed OVER the current name (atomic) and the directory fsynced. At every instant
|
||||||
|
// `private.key` is a complete key. Returns the new PUBLIC key only.
|
||||||
|
func rotateKey(stateDir string) (string, error) {
|
||||||
|
dir := filepath.Join(stateDir, "wg")
|
||||||
|
cur := filepath.Join(dir, keyFileName)
|
||||||
|
if _, err := os.Stat(cur); err != nil {
|
||||||
|
return "", fmt.Errorf("no current key: %w", err)
|
||||||
|
}
|
||||||
|
priv := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(priv); err != nil {
|
||||||
|
return "", fmt.Errorf("entropy: %w", err)
|
||||||
|
}
|
||||||
|
clampPrivateKey(priv)
|
||||||
|
pub, err := derivePublic(priv)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tmp := filepath.Join(dir, keyFileName+".new")
|
||||||
|
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("writing the new key: %w", err)
|
||||||
|
}
|
||||||
|
_, werr := f.Write([]byte(base64.StdEncoding.EncodeToString(priv) + "\n"))
|
||||||
|
serr := f.Sync()
|
||||||
|
cerr := f.Close()
|
||||||
|
if werr != nil || serr != nil || cerr != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return "", fmt.Errorf("writing the new key: %v %v %v", werr, serr, cerr)
|
||||||
|
}
|
||||||
|
prev := filepath.Join(dir, prevKeyName)
|
||||||
|
if _, err := os.Lstat(prev); err == nil {
|
||||||
|
prev = filepath.Join(dir, fmt.Sprintf("%s.%d", prevKeyName, rekeyNow().Unix()))
|
||||||
|
}
|
||||||
|
if err := os.Link(cur, prev); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return "", fmt.Errorf("keeping the old key: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, cur); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return "", fmt.Errorf("installing the new key: %w", err)
|
||||||
|
}
|
||||||
|
if d, err := os.Open(dir); err == nil {
|
||||||
|
d.Sync()
|
||||||
|
d.Close()
|
||||||
|
}
|
||||||
|
prunePrevKeys(dir, 3)
|
||||||
|
return pub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rekeyNow is the clock for the kept-key names (a seam).
|
||||||
|
var rekeyNow = time.Now
|
||||||
|
|
||||||
|
// prunePrevKeys keeps the newest `keep` of the `private.key.prev.<unix>` files (`private.key.prev` itself always stays).
|
||||||
|
func prunePrevKeys(dir string, keep int) {
|
||||||
|
m, _ := filepath.Glob(filepath.Join(dir, prevKeyName+".*"))
|
||||||
|
if len(m) <= keep {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Strings(m) // the same digit count until 2286: lexical = time order
|
||||||
|
for _, p := range m[:len(m)-keep] {
|
||||||
|
os.Remove(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastRekeyFile records the last re-key's time across agent restarts (N7: the once-an-hour bound was in memory only).
|
||||||
|
const lastRekeyFile = "last-rekey"
|
||||||
|
|
||||||
|
func (m *Manager) loadLastRekey() time.Time {
|
||||||
|
b, err := os.ReadFile(filepath.Join(m.wgDir(), lastRekeyFile))
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
var u int64
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(string(b)), "%d", &u); err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return time.Unix(u, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) saveLastRekey(t time.Time) {
|
||||||
|
_ = os.WriteFile(filepath.Join(m.wgDir(), lastRekeyFile), []byte(fmt.Sprintf("%d\n", t.Unix())), 0o600)
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package wgtunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// D30 (R-1204): a handshake stamped in the future that STAYS for rekeyAfter → a new key pair, registered on the
|
||||||
|
// existing peer path (the hub re-keys in place; its full-list push to ep0 swaps the key). The fake `wg` reports a
|
||||||
|
// handshake at 1783107115; a clock three hours behind that sees it three hours in the future.
|
||||||
|
// RED-PROOF (2026-10-22 night): with the clockAheadRekeyLocked call removed from Watchdog,
|
||||||
|
// TestD30_AFutureHandshakeThatStaysReKeys and …AFailedRegistration… fail (no new key, no registration); with the
|
||||||
|
// rekeyAfter wait removed, …ThatStaysReKeys fails (a re-key at 10 min); with the isActive check removed,
|
||||||
|
// TestD30_NoReKeyWhileTheTunnelIsDown fails.
|
||||||
|
|
||||||
|
const hs = 1783107115
|
||||||
|
|
||||||
|
func at(m *Manager, unix int64) { m.now = func() time.Time { return time.Unix(unix, 0) } }
|
||||||
|
|
||||||
|
func readKey(t *testing.T, m *Manager, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(filepath.Join(m.stateDir, "wg", name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestD30_AFutureHandshakeThatStaysReKeys(t *testing.T) {
|
||||||
|
fh := &fakeHub{}
|
||||||
|
m, _, active, logs := testManager(t, fh)
|
||||||
|
oldPub := establishWG(t, m, active)
|
||||||
|
oldPriv := readKey(t, m, keyFileName)
|
||||||
|
ctx := context.Background()
|
||||||
|
calls := fh.count()
|
||||||
|
|
||||||
|
t0 := int64(hs - 3*3600) // the clock is right again; the last handshake (made while it ran ahead) is 3 h ahead
|
||||||
|
at(m, t0)
|
||||||
|
m.Watchdog(ctx, true, testBlock(oldPub))
|
||||||
|
at(m, t0+10*60)
|
||||||
|
m.Watchdog(ctx, true, testBlock(oldPub))
|
||||||
|
if fh.count() != calls || localPub(t, m) != oldPub {
|
||||||
|
t.Fatal("no re-key before the future stamp has stayed rekeyAfter")
|
||||||
|
}
|
||||||
|
at(m, t0+16*60)
|
||||||
|
m.Watchdog(ctx, true, testBlock(oldPub))
|
||||||
|
newPub := localPub(t, m)
|
||||||
|
if newPub == oldPub {
|
||||||
|
t.Fatal("a future stamp that stayed 16 min must give a new key")
|
||||||
|
}
|
||||||
|
if fh.count() != calls+1 {
|
||||||
|
t.Fatalf("the new key must be registered on the existing peer path (RegisterWG calls %d → %d)", calls, fh.count())
|
||||||
|
}
|
||||||
|
if mk := m.loadMarker(); mk == nil || mk.Pubkey != newPub {
|
||||||
|
t.Fatalf("the registration marker must carry the new key: %+v", mk)
|
||||||
|
}
|
||||||
|
if readKey(t, m, prevKeyName) != oldPriv {
|
||||||
|
t.Fatal("the old private key must be kept as private.key.prev (the escrow may hold it)")
|
||||||
|
}
|
||||||
|
if strings.Contains(logs.String(), oldPriv) || strings.Contains(logs.String(), readKey(t, m, keyFileName)) {
|
||||||
|
t.Fatal("a private key reached the log")
|
||||||
|
}
|
||||||
|
// The served block still names the OLD key until the next fetch: no second re-key over that.
|
||||||
|
at(m, t0+17*60)
|
||||||
|
m.Watchdog(ctx, true, testBlock(oldPub))
|
||||||
|
if localPub(t, m) != newPub {
|
||||||
|
t.Fatal("a second re-key ran before the hub served the new key")
|
||||||
|
}
|
||||||
|
// The hub serves the new key → the tunnel is applied with the NEW private key.
|
||||||
|
m.Apply(ctx, true, testBlock(newPub))
|
||||||
|
conf, err := os.ReadFile(m.stagedConfPath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(conf), readKey(t, m, keyFileName)) || strings.Contains(string(conf), oldPriv) {
|
||||||
|
t.Fatal("the applied tunnel must use the new private key, not the old one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestD30_AShortFutureSpellDoesNotReKey(t *testing.T) {
|
||||||
|
fh := &fakeHub{}
|
||||||
|
m, _, active, _ := testManager(t, fh)
|
||||||
|
pub := establishWG(t, m, active)
|
||||||
|
ctx := context.Background()
|
||||||
|
t0 := int64(hs - 3*3600)
|
||||||
|
at(m, t0)
|
||||||
|
m.Watchdog(ctx, true, testBlock(pub))
|
||||||
|
at(m, hs+100) // a handshake now lies in the past: the sign ended (a working tunnel)
|
||||||
|
m.Watchdog(ctx, true, testBlock(pub))
|
||||||
|
at(m, t0+20*60) // in the future again: a NEW window starts, it does not continue the old one
|
||||||
|
m.Watchdog(ctx, true, testBlock(pub))
|
||||||
|
if localPub(t, m) != pub {
|
||||||
|
t.Fatal("a future stamp that did not STAY for rekeyAfter must not re-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestD30_NoReKeyWhileTheTunnelIsDown(t *testing.T) {
|
||||||
|
fh := &fakeHub{}
|
||||||
|
m, _, active, _ := testManager(t, fh)
|
||||||
|
pub := establishWG(t, m, active)
|
||||||
|
*active = false
|
||||||
|
ctx := context.Background()
|
||||||
|
t0 := int64(hs - 3*3600)
|
||||||
|
for _, d := range []int64{0, 20 * 60, 40 * 60} {
|
||||||
|
at(m, t0+d)
|
||||||
|
m.Watchdog(ctx, true, testBlock(pub))
|
||||||
|
}
|
||||||
|
if localPub(t, m) != pub {
|
||||||
|
t.Fatal("no re-key while the tunnel unit is not active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestD30_AFailedRegistrationIsRetriedByTheExistingPath(t *testing.T) {
|
||||||
|
fh := &fakeHub{}
|
||||||
|
m, _, active, _ := testManager(t, fh)
|
||||||
|
oldPub := establishWG(t, m, active)
|
||||||
|
ctx := context.Background()
|
||||||
|
t0 := int64(hs - 3*3600)
|
||||||
|
at(m, t0)
|
||||||
|
m.Watchdog(ctx, true, testBlock(oldPub))
|
||||||
|
fh.mu.Lock()
|
||||||
|
fh.err = errors.New("hub down")
|
||||||
|
fh.mu.Unlock()
|
||||||
|
at(m, t0+16*60)
|
||||||
|
m.Watchdog(ctx, true, testBlock(oldPub))
|
||||||
|
newPub := localPub(t, m)
|
||||||
|
if newPub == oldPub {
|
||||||
|
t.Fatal("the key rotates before the registration")
|
||||||
|
}
|
||||||
|
if mk := m.loadMarker(); mk == nil || mk.Pubkey != oldPub {
|
||||||
|
t.Fatal("a failed registration leaves the marker on the old key")
|
||||||
|
}
|
||||||
|
fh.mu.Lock()
|
||||||
|
fh.err = nil
|
||||||
|
fh.mu.Unlock()
|
||||||
|
at(m, t0+30*60) // past the backoff: Apply sees the served (old) key ≠ the local key → re-key-in-place registers
|
||||||
|
m.Apply(ctx, true, testBlock(oldPub))
|
||||||
|
if mk := m.loadMarker(); mk == nil || mk.Pubkey != newPub {
|
||||||
|
t.Fatalf("the existing re-key-in-place path must register the new key: %+v", mk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review 2026-10-23 night. N7: a second rotation keeps the FIRST old key as `private.key.prev` (the escrow may hold it)
|
||||||
|
// and the later one beside it; N6: the current key is replaced by a rename OVER it, so `private.key` is never missing.
|
||||||
|
// RED-PROOF: with the „prev exists → a dated name" branch removed, TwoRotations fails (the first key overwritten) —
|
||||||
|
// seen failing 2026-10-23 night.
|
||||||
|
func TestD30_TwoRotationsKeepTheFirstOldKey(t *testing.T) {
|
||||||
|
fh := &fakeHub{}
|
||||||
|
m, _, active, _ := testManager(t, fh)
|
||||||
|
establishWG(t, m, active)
|
||||||
|
first := readKey(t, m, keyFileName)
|
||||||
|
old := rekeyNow
|
||||||
|
defer func() { rekeyNow = old }()
|
||||||
|
rekeyNow = func() time.Time { return time.Unix(hs, 0) }
|
||||||
|
if _, err := rotateKey(m.stateDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second := readKey(t, m, keyFileName)
|
||||||
|
rekeyNow = func() time.Time { return time.Unix(hs+7200, 0) }
|
||||||
|
if _, err := rotateKey(m.stateDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if readKey(t, m, prevKeyName) != first {
|
||||||
|
t.Fatal("private.key.prev must keep the FIRST old key")
|
||||||
|
}
|
||||||
|
if readKey(t, m, fmt.Sprintf("%s.%d", prevKeyName, hs+7200)) != second {
|
||||||
|
t.Fatal("the second old key must be kept beside it")
|
||||||
|
}
|
||||||
|
if cur := readKey(t, m, keyFileName); cur == first || cur == second {
|
||||||
|
t.Fatal("the current key must be new")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// N7: the once-an-hour bound survives an agent restart (it was in memory only). RED-PROOF: with loadLastRekey removed
|
||||||
|
// from clockAheadRekeyLocked, this fails — seen failing 2026-10-23 night.
|
||||||
|
func TestD30_TheHourBoundSurvivesARestart(t *testing.T) {
|
||||||
|
fh := &fakeHub{}
|
||||||
|
m, rr, active, _ := testManager(t, fh)
|
||||||
|
pub := establishWG(t, m, active)
|
||||||
|
ctx := context.Background()
|
||||||
|
t0 := int64(hs - 3*3600)
|
||||||
|
at(m, t0)
|
||||||
|
m.Watchdog(ctx, true, testBlock(pub))
|
||||||
|
at(m, t0+16*60)
|
||||||
|
m.Watchdog(ctx, true, testBlock(pub))
|
||||||
|
newPub := localPub(t, m)
|
||||||
|
if newPub == pub {
|
||||||
|
t.Fatal("fixture: the first re-key must happen")
|
||||||
|
}
|
||||||
|
// The agent restarts: a NEW manager on the same state; the served block now names the new key; the sign is back.
|
||||||
|
m2 := NewManager(rr, fh, m.stateDir, m.logger)
|
||||||
|
m2.isActive, m2.wgPresent, m2.resolver = m.isActive, m.wgPresent, m.resolver
|
||||||
|
m2.Apply(ctx, true, testBlock(newPub))
|
||||||
|
at(m2, t0+20*60)
|
||||||
|
m2.Watchdog(ctx, true, testBlock(newPub))
|
||||||
|
at(m2, t0+40*60)
|
||||||
|
m2.Watchdog(ctx, true, testBlock(newPub))
|
||||||
|
if localPub(t, m2) != newPub {
|
||||||
|
t.Fatal("a restart must not reset the once-an-hour bound")
|
||||||
|
}
|
||||||
|
_ = active
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit security review 2026-10-23 night: a last re-key saved while the clock ran ahead (a later date than now) must
|
||||||
|
// not block the next re-key until that date. RED-PROOF: with the „lastRekey after now → dropped" guard removed, this
|
||||||
|
// fails — seen failing 2026-10-23 night.
|
||||||
|
func TestD30_ALastReKeyFromTheFutureDoesNotBlock(t *testing.T) {
|
||||||
|
fh := &fakeHub{}
|
||||||
|
m, _, active, _ := testManager(t, fh)
|
||||||
|
pub := establishWG(t, m, active)
|
||||||
|
m.saveLastRekey(time.Unix(hs+2*365*86400, 0)) // written while the clock was two years ahead
|
||||||
|
ctx := context.Background()
|
||||||
|
t0 := int64(hs - 3*3600)
|
||||||
|
at(m, t0)
|
||||||
|
m.Watchdog(ctx, true, testBlock(pub))
|
||||||
|
at(m, t0+16*60)
|
||||||
|
m.Watchdog(ctx, true, testBlock(pub))
|
||||||
|
if localPub(t, m) == pub {
|
||||||
|
t.Fatal("a last re-key dated in the future must not block the re-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user