Implements hq ADR 0105 on the controller side. Pairs with mesh-host feat/adopt-the-tunnel (#24). Revised after review (C1–C5, C10, rekey path).
What the controller stores: node.tunnel (what the node presented: interface, unit, config, port, address, range, public key), tunnel_peer (key + one host address per peer — peers of the tunnel, not nodes of the mesh; range-routed peers, a spoke's view of its hub, are skipped), node.tunnel_carried (the host's account: state not-taken / taken / down, note, kept original). Migration 0031-the-tunnel-a-node-found.sql.
What it computes: overlayRange reads the adopted tunnel's range first (adopted = the hub's overlay key is the tunnel's — a fact of the record, not of the node's mode, so converging the hub never renumbers the mesh), then MESH_OVERLAY_CIDR, then the default. The hub is placed at the tunnel's address on the tunnel's port (an endpoint on another port is refused). The hub's peer list carries every tunnel peer that has not enrolled (/32, or /128 for v6). The hub's overlay-up service says takes-over — declared to an adopted node only, and refused, not composed, when the hub's recorded address or endpoint port disagrees with the tunnel, naming both and the overlay place that fixes it. converge <hub> is refused while any carried peer has not enrolled, naming them.
Enrolment rule: a node whose overlay key matches a carried peer keeps that peer's address; a fresh node gets the lowest free address from the same range, never one the tunnel holds. A tunnel presented under a key that is not the node's overlay key is refused.
Rekey path (for the live hub, which enrolled with a generated key): Report.rekey {previous, overlay_key, tunnel, proof}, proof = identity-key signature over RekeyProof(node, previous, key, tunnel). The controller verifies against the node's live identity key, refuses a foreign signature, a proof over another tunnel, or a previous that is not the recorded key (replay), then records key + tunnel and moves a hub to the tunnel's address.
overlay show lists carried peers apart from nodes, saying which enrolled as what, and tells a hub that found no tunnel how to take one; node show says the tunnel found and its three-state account.
Lab bed + integration-test skeleton (not run) under lab/adopt-the-tunnel/ (R0, T1–T4, N).
Rollout sequence for the live hub (as the reviewer laid it out)
Merge both PRs. Nothing changes: the hub has tunnel is null, the range stays MESH_OVERLAY_CIDR, no takeover is composed. overlay show says the hub found no tunnel and names step 2.
On the hub machine: mesh-host overlay take --tunnel wg0. Rewrites overlay.key and identity.Overlay with wg0's key — node, sealing and serving keys untouched, so no store or broker credential is remade — and sends the signed rekey. node show <hub> on the controller must say tunnel found wg0 on port 51820, 10.10.0.1/24 in 10.10.0.0/24, 3 peer(s). Run again by mistake it is refused as stale and changes nothing.
overlay place <hub> --hub --endpoint <public-address>:51820 --site <site>. Refused if the port is not the tunnel's.
plan <hub> --json and check: Address = 10.10.0.1/32, ListenPort = 51820, three /32 carried peers under [Peer], takes-over naming wg-quick@wg0 and /etc/wireguard/wg0.conf, and no 10.42. anywhere. If the graph refuses instead, the refusal names the placement to fix; nothing was sent.
Start a curl loop on one predecessor peer against a service it reaches over the tunnel.
push <hub> --wait 2m. The host checks the declared interface against wg0 (port, address, key) before stopping anything; if wg-quick@mesh0 then fails to start it starts wg-quick@wg0 again and reports state: not-taken with the reason. Expect node show <hub> to say taken over and the curl loop to have missed at most a re-handshake.
Re-place / push every other node (their hub peer entry changes key, port and range).
Spokes enrol adopted only now (C1 fixed: their hub-routed peer is skipped). Each must show enrolled as <name> under the carried peers, keeping its address.
Never converge <hub> until every carried peer shows enrolled as; the flip is refused while one is missing (C2).
Manual rollback on the hub (any point after step 6): systemctl stop wg-quick@mesh0 && systemctl start wg-quick@wg0. wg0.conf is untouched on disk (its original is also kept under the host's state dir). To undo the rekey itself, the previous public key is in identity.json as overlay_before.
Do not merge without the host PR.
Implements hq ADR 0105 on the controller side. Pairs with mesh-host `feat/adopt-the-tunnel` (#24). Revised after review (C1–C5, C10, rekey path).
**What the controller stores**: `node.tunnel` (what the node presented: interface, unit, config, port, address, range, public key), `tunnel_peer` (key + one host address per peer — peers of the tunnel, not nodes of the mesh; range-routed peers, a spoke's view of its hub, are skipped), `node.tunnel_carried` (the host's account: state `not-taken` / `taken` / `down`, note, kept original). Migration `0031-the-tunnel-a-node-found.sql`.
**What it computes**: `overlayRange` reads the adopted tunnel's range first (adopted = the hub's overlay key is the tunnel's — **a fact of the record, not of the node's mode**, so converging the hub never renumbers the mesh), then `MESH_OVERLAY_CIDR`, then the default. The hub is placed at the tunnel's address on the tunnel's port (an endpoint on another port is refused). The hub's peer list carries every tunnel peer that has not enrolled (/32, or /128 for v6). The hub's `overlay-up` service says `takes-over` — declared to an adopted node only, and **refused, not composed, when the hub's recorded address or endpoint port disagrees with the tunnel**, naming both and the `overlay place` that fixes it. `converge <hub>` is refused while any carried peer has not enrolled, naming them.
**Enrolment rule**: a node whose overlay key matches a carried peer keeps that peer's address; a fresh node gets the lowest free address from the same range, never one the tunnel holds. A tunnel presented under a key that is not the node's overlay key is refused.
**Rekey path (for the live hub, which enrolled with a generated key)**: `Report.rekey {previous, overlay_key, tunnel, proof}`, proof = identity-key signature over `RekeyProof(node, previous, key, tunnel)`. The controller verifies against the node's live identity key, refuses a foreign signature, a proof over another tunnel, or a `previous` that is not the recorded key (replay), then records key + tunnel and moves a hub to the tunnel's address.
`overlay show` lists carried peers apart from nodes, saying which enrolled as what, and tells a hub that found no tunnel how to take one; `node show` says the tunnel found and its three-state account.
Lab bed + integration-test skeleton (not run) under `lab/adopt-the-tunnel/` (R0, T1–T4, N).
## Rollout sequence for the live hub (as the reviewer laid it out)
1. **Merge both PRs. Nothing changes**: the hub has `tunnel is null`, the range stays `MESH_OVERLAY_CIDR`, no takeover is composed. `overlay show` says the hub found no tunnel and names step 2.
2. On the hub machine: `mesh-host overlay take --tunnel wg0`. Rewrites `overlay.key` and `identity.Overlay` with wg0's key — node, sealing and serving keys untouched, so no store or broker credential is remade — and sends the signed rekey. `node show <hub>` on the controller must say `tunnel found wg0 on port 51820, 10.10.0.1/24 in 10.10.0.0/24, 3 peer(s)`. Run again by mistake it is refused as stale and changes nothing.
3. `overlay place <hub> --hub --endpoint <public-address>:51820 --site <site>`. Refused if the port is not the tunnel's.
4. `plan <hub> --json` and check: `Address = 10.10.0.1/32`, `ListenPort = 51820`, three `/32` carried peers under `[Peer]`, `takes-over` naming `wg-quick@wg0` and `/etc/wireguard/wg0.conf`, and **no `10.42.` anywhere**. If the graph refuses instead, the refusal names the placement to fix; nothing was sent.
5. Start a curl loop on one predecessor peer against a service it reaches over the tunnel.
6. `push <hub> --wait 2m`. The host checks the declared interface against wg0 (port, address, key) before stopping anything; if wg-quick@mesh0 then fails to start it starts wg-quick@wg0 again and reports `state: not-taken` with the reason. Expect `node show <hub>` to say `taken over` and the curl loop to have missed at most a re-handshake.
7. Re-place / push every other node (their hub peer entry changes key, port and range).
8. Spokes enrol adopted only now (C1 fixed: their hub-routed peer is skipped). Each must show `enrolled as <name>` under the carried peers, keeping its address.
9. **Never `converge <hub>`** until every carried peer shows `enrolled as`; the flip is refused while one is missing (C2).
**Manual rollback on the hub** (any point after step 6): `systemctl stop wg-quick@mesh0 && systemctl start wg-quick@wg0`. wg0.conf is untouched on disk (its original is also kept under the host's state dir). To undo the rekey itself, the previous public key is in `identity.json` as `overlay_before`.
Do not merge without the host PR.
On an adopted hub the private network takes over the tunnel it finds rather
than running beside it (hq ADR 0105): two tunnels leave the mesh's unreachable
through the provider's filter, so no machine can ever join.
The node presents the found tunnel when it enrols, under the key it took as
its own; the inventory records it (node.tunnel, tunnel_peer — migration 0031)
and the mesh composes from it: the overlay's range is the adopted tunnel's,
the hub is placed at the tunnel's address on the tunnel's port, and every
peer the tunnel had is carried in the hub's peer list as a peer of the
tunnel, not a node of the mesh, until a node enrols with that key — which
then keeps the address the tunnel had for it. A fresh node never gets an
address the tunnel holds. The hub's declaration tells the host which unit to
take over; the host's account of carrying it is recorded and shown.
Every reader of the range follows the setting; nothing stores it. A found
tunnel under another key is recorded and not adopted, so ADR 0100's
non-overlap rule keeps applying where a tunnel is left running beside the
mesh's. A lab bed and test skeleton for "How it is checked" are under lab/.
Review of the ADR 0105 build (hq ADR 0105). Four things it got wrong and one
path it lacked:
- A predecessor spoke's tunnel names one peer, the hub, routed the whole
range; recording refused it and the whole enrolment failed. Range-routed
peers are skipped now — only the hub's peers are ever carried.
- The range and the carried peers were conditions on the node being adopted,
so converging the hub would have renumbered the mesh and dropped the peers
still reaching it. They are facts of the tunnel record now, mode aside; the
takeover alone is declared to an adopted node. Converging the hub is refused
while a carried peer has not enrolled, naming it.
- A push composed a takeover for a hub whose address or endpoint disagreed
with the tunnel, which would have the host stop the found interface and
raise the mesh's where no peer listens. The graph refuses to compose it,
naming both and the placement that fixes it.
- The host's account said taken or not; "found down and the mesh's not up"
read as not taken. Three states now, and an account on every takeover.
- A hub that enrolled before this feature holds a key of its own, and
re-enrolling would rotate every key the mesh sealed credentials to. A node
now rekeys in a report, signed with its identity key over the key it
leaves, the key it takes and the tunnel; the mesh verifies against the live
key, refuses a stale or foreign proof, records key and tunnel, and moves a
hub to the tunnel's address. `overlay show` names the path for a hub that
found no tunnel.
Also: a carried IPv6 peer is routed /128, and identity.ForTest exists so the
link can be tested against a real identity store.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Implements hq ADR 0105 on the controller side. Pairs with mesh-host
feat/adopt-the-tunnel(#24). Revised after review (C1–C5, C10, rekey path).What the controller stores:
node.tunnel(what the node presented: interface, unit, config, port, address, range, public key),tunnel_peer(key + one host address per peer — peers of the tunnel, not nodes of the mesh; range-routed peers, a spoke's view of its hub, are skipped),node.tunnel_carried(the host's account: statenot-taken/taken/down, note, kept original). Migration0031-the-tunnel-a-node-found.sql.What it computes:
overlayRangereads the adopted tunnel's range first (adopted = the hub's overlay key is the tunnel's — a fact of the record, not of the node's mode, so converging the hub never renumbers the mesh), thenMESH_OVERLAY_CIDR, then the default. The hub is placed at the tunnel's address on the tunnel's port (an endpoint on another port is refused). The hub's peer list carries every tunnel peer that has not enrolled (/32, or /128 for v6). The hub'soverlay-upservice saystakes-over— declared to an adopted node only, and refused, not composed, when the hub's recorded address or endpoint port disagrees with the tunnel, naming both and theoverlay placethat fixes it.converge <hub>is refused while any carried peer has not enrolled, naming them.Enrolment rule: a node whose overlay key matches a carried peer keeps that peer's address; a fresh node gets the lowest free address from the same range, never one the tunnel holds. A tunnel presented under a key that is not the node's overlay key is refused.
Rekey path (for the live hub, which enrolled with a generated key):
Report.rekey {previous, overlay_key, tunnel, proof}, proof = identity-key signature overRekeyProof(node, previous, key, tunnel). The controller verifies against the node's live identity key, refuses a foreign signature, a proof over another tunnel, or apreviousthat is not the recorded key (replay), then records key + tunnel and moves a hub to the tunnel's address.overlay showlists carried peers apart from nodes, saying which enrolled as what, and tells a hub that found no tunnel how to take one;node showsays the tunnel found and its three-state account.Lab bed + integration-test skeleton (not run) under
lab/adopt-the-tunnel/(R0, T1–T4, N).Rollout sequence for the live hub (as the reviewer laid it out)
tunnel is null, the range staysMESH_OVERLAY_CIDR, no takeover is composed.overlay showsays the hub found no tunnel and names step 2.mesh-host overlay take --tunnel wg0. Rewritesoverlay.keyandidentity.Overlaywith wg0's key — node, sealing and serving keys untouched, so no store or broker credential is remade — and sends the signed rekey.node show <hub>on the controller must saytunnel found wg0 on port 51820, 10.10.0.1/24 in 10.10.0.0/24, 3 peer(s). Run again by mistake it is refused as stale and changes nothing.overlay place <hub> --hub --endpoint <public-address>:51820 --site <site>. Refused if the port is not the tunnel's.plan <hub> --jsonand check:Address = 10.10.0.1/32,ListenPort = 51820, three/32carried peers under[Peer],takes-overnamingwg-quick@wg0and/etc/wireguard/wg0.conf, and no10.42.anywhere. If the graph refuses instead, the refusal names the placement to fix; nothing was sent.push <hub> --wait 2m. The host checks the declared interface against wg0 (port, address, key) before stopping anything; if wg-quick@mesh0 then fails to start it starts wg-quick@wg0 again and reportsstate: not-takenwith the reason. Expectnode show <hub>to saytaken overand the curl loop to have missed at most a re-handshake.enrolled as <name>under the carried peers, keeping its address.converge <hub>until every carried peer showsenrolled as; the flip is refused while one is missing (C2).Manual rollback on the hub (any point after step 6):
systemctl stop wg-quick@mesh0 && systemctl start wg-quick@wg0. wg0.conf is untouched on disk (its original is also kept under the host's state dir). To undo the rekey itself, the previous public key is inidentity.jsonasoverlay_before.Do not merge without the host PR.