Gunnar

nordisk/Skidbladnir

.nornir/pairing-design.md

# Parning: lådorna hittar varandra själva — design 2026-09-24

Ägarens beslut 2026-09-24: xtasken ska INTE para. Den startar maskinerna med tillräckligt
med information för att de ska bilda en tunnel (WireGuard som standard, Tailscale, Mullvad
eller ingen) och sedan para sig med varandra. Biblioteket ligger i edda och används av
både gunnar och holger. Testas under draupnir.

## 1. Vad som är fel idag (MÄTT i private-gunnar-ops, 2026-09-23/24)

Laptopens xtask är äktenskapsmäklaren. Tre lådor bootar utan att veta något om varandra;
xtasken gör varje presentation över ssh till fronten: bootstrap av primären med frontens
konsolnyckel, tre principaler, tvillingens BecomeTwin, certifikat, speglingslista. Varje
presentation var ett ställe där kedjan brast (apparat som inte lyssnade än, tvilling som
panikade som PID 1, dödad körning). Laptopen är en enda felkälla mitt i en installation
som borde vara lådornas egen sak.

## 2. Idén, i en mening

**Parning = gå med i tunneln.** Adresserna inne i tunneln är våra (fasta), inte molnets.
Bara EN låda behöver en känd adress: fronten (publik IP ur terraform, eller namnet
gunnar.rs). De andra ringer fronten; WireGuard lär sig deras endpoints ur handskakningen.
Inga förberedda IP:n, inga MAC-adresser.

| låda | overlay-adress (design, fast) | roll |
|---|---|---|
| front | 10.44.0.1 | rendezvous + join-dörr + konsol |
| main | 10.44.0.2 | primär |
| twin | 10.44.0.3 | tvilling |

Tunnel = `none`: samma design, adresserna är då UpClouds utility-IP:n som terraform vet
före installationen.

## 3. Flödet

1. **xtask (eller holgers motsvarighet) lägger maskinerna och skriver ett frö per låda**
   (virtio-fröet som korp-installer redan läser): tunneltyp, frontens tunnelpublika nyckel
   och endpoint, lådans overlay-adress och roll, frontens konsol-PUBLIKA nyckel
   (för-inskriven admin — bootstrapfönstret är stängt från första boot), och en
   engångs-jointoken (slumpad, 32 byte, skriven i fröet och i frontens lista).
2. **Första boot: lådan präglar sina egna nycklar** (tunnel + gunnar-identitet) och anropar
   frontens join-dörr: `{tunnelpubkey, overlay, roll, kapacitet}` signerat med token.
   Fronten verifierar token, lägger till peer, svarar med peer-listan och den beslutade
   rollen. Ingen privat nyckel lämnar någonsin sin låda.
3. **Inne i tunneln parar lådorna sig själva**: primären har admin (konsolnyckeln) från
   start; tvillingen kör BecomeTwin + certify mot 10.44.0.2 i en loop tills det går;
   följaren är loopen som redan finns. Kontrollplanet binds till overlay-adressen; mTLS som
   idag.
4. **Valet (svagast = tvilling) förblir "beslutat en gång"**: båda gunnar-lådorna skickar
   kapacitet vid join; fronten beslutar en gång och lämnar rollen i svaret. Lika = lägsta
   overlay-adress blir tvilling. Beslutet skrivs i frontens journal och ändras aldrig.
5. **Signalen**: varje låda rapporterar sitt parningsläge (tunnel: uppe/nere, join: gjord,
   roll: given, primär: nådd, följer: n av m) på sin statusdörr. xtasken LÄSER bara.

## 4. Biblioteket: `Skidbladnir/crates/installer/nordisk-pairing` (flyttad från edda 2026-09-26)

Ren Rust, inga shell-anrop (lagen). Moduler:

- `seed` — fröformatet (TOML på virtio-fröet): `tunnel = "wireguard"|"tailscale"|"mullvad"|"none"`,
  `front = { pubkey, endpoint }`, `me = { overlay, role_hint }`, `admin_pubkey`, `join_token`,
  `peers_expected = 3`. Läses av installern via korp-installer, skrivs av xtask/holger-ops.
- `tunnel` — `trait Tunnel { fn up(&self, me: &Keys, peers: &[Peer]) -> Result<Iface>; fn add_peer(..); fn status(&self) -> TunnelStatus; }`
  Drivrutiner: `wireguard` (kernelmodul via netlink — `wireguard-control`/`boringtun` i user
  space som reserv), `tailscale` (auth key i fröet, tailscaled-API), `mullvad` (kontonummer i
  fröet, WireGuard-konfig ur deras API), `none` (utility-IP:n rakt av). Valet är en
  build-thing-inställning: mediet komposeras med rätt del (kernelmodul/drivrutin).
- `join` — dörren på fronten (HTTPS på publik IP + inne i tunneln): `POST /join` med
  token-signatur; svar = peer-lista + roll. Klienten på lådorna: loop med backoff tills
  svar. Engångstoken: förbrukad efter första lyckade join, per låda.
- `pair` — tillståndsmaskinen på lådan: `Booted → TunnelUp → Joined → RoleKnown → Paired(primary|twin)`.
  Primär: admin för-inskriven, väntar på tvillingen. Tvilling: BecomeTwin(10.44.0.2) →
  certify → följ. Varje steg idempotent och omkörbart.
- `signal` — läget som en rad + som strukturerat svar (samma sak `estate pairing` visar idag,
  men rapporterat av lådan själv).
- `elect` — svagast-är-tvilling, beslutat på fronten av två kapacitetsrapporter.

gunnar länkar allt; holger länkar `seed`, `tunnel`, `join`, `signal` (holger har ingen tvilling
idag men samma front/apparat-par).

## 5. Vad som blir kvar i xtask/ops

`lay` (terraform), frö per låda, media/installation, DNS-växling, `down` sist, och att
LÄSA signalen tills alla tre säger Paired. Stegen elect/pair/twin försvinner.

## 6. Tester under draupnir

`draupnir::boot_fleet_and_await_parallel` med tre gäster på ett virtuellt nät (`CloudInit`/
`seed.rs` för fröna). Testet skriver tre frön med samma bibliotek, bootar, och läser
signalen: PASS = tunneln uppe hos alla tre, join gjord, roller givna, tvillingen följer.
Samma test för `tunnel = none` (utility-adresser) och för varje drivrutin som finns lokalt
(WireGuard i kärnan finns på t14s; Tailscale/Mullvad-testerna kräver konto och märks så).
Detta ersätter huvuddelen av mock-stegen i private-gunnar-ops.

## 7. Ordning

1. gunnar.rs upp med dagens kedja (pågår 2026-09-24).
2. `nordisk-pairing` i edda: seed + tunnel(none, wireguard) + join + pair + signal, med
   draupnir-test.
3. gunnar: installer läser fröet, PID-1-tjänsten kör `pair`; xtask krymper till §5.
4. holger: samma.
5. Tailscale- och Mullvad-drivrutiner.
6. Riv och bygg om (regeln: nya maskiner först, DNS, gamla sist).

## 8. Nästa version: installerarens tillfälliga mTLS (ägarens idé 2026-09-24)

Idén: installeraren har ett tillfälligt mTLS-certifikat som de installerade maskinerna känner,
så att de kan kontakta installeraren, som matar dem med det de behöver.

**Vad det löser**
- Dagens join är ensidig: dörren prövar lådan (HMAC med estate-token), men lådan frågar över
  vanlig HTTP och tror på svaret. Med en tillfällig CA pinnar lådan dörrens certifikat — båda
  sidor är bevisade.
- Allt estate-specifikt flyttar från mediet till dörrens svar: monetize-nyckeln, klocknyckeln,
  konsolens admin-nyckel, overlay-adress, roll, peers, tunnelkonfig. Ett medium tjänar då varje
  estate och komponeras en gång; felklassen `monetize-pubkey-mismatch` försvinner och ISO-cachen
  träffar alltid.

**Formen**
1. xtask präglar per ombyggnad en installer-CA (ed25519), giltig i timmar. CA:ns privata nyckel
   ligger på fronten och lämnar den aldrig.
2. Mediet bär CA-certifikatet (publikt) och ett bootstrap-klientcert + nyckel med kort livstid och
   ett enda syfte: anropa join-dörren.
3. Vid första join skickar lådan en CSR för en nyckel den själv präglat; dörren signerar ett
   riktigt certifikat per låda (samma utfärdare som redan signerar tvillingens cert). Därefter
   vägras bootstrap-certet.
4. Slot-per-adress gäller fortfarande: ett kopierat medium på en annan adress vägras.

**Förbehållet:** en nyckel på mediet är bara så hemlig som mediet — ISO:n laddas upp till
UpCloud-lagringen och kan läsas av den som har kontoåtkomst. Därför kort livstid, ett syfte,
bunden till adress, och utbytt direkt vid första join. Aldrig en långlivad nyckel på mediet.

**Var installeraren kör:** på fronten, som dörren. Maskinerna når inte laptopen, men de når
fronten över utility-nätet.

**BYGGT 2026-09-24** (edda `nordisk-pairing`, `mtls.rs`/`join.rs`/`door.rs`): installer-CA, dörrens cert för sin utility-adress, bootstrap-cert på mediet, lådans eget cert från CSR vid första join, bootstrap `spent` för joinad slot och `withdrawn` när alla joinat; dörren matar estate-värden (`feed`). Fleet-testet (draupnir, tre containrar, WireGuard) grönt på mTLS på 33 s.

**Ägarens precisering 2026-09-24:** installerarens mTLS gäller ENDAST under bootstrap. När
lådan har joinat och fått sitt eget certifikat är installerarens cert dött för den lådan, och
när estatet är komplett (alla slots joinade) dras installer-CA:n tillbaka helt.

## 9. Tunneldrivrutinerna bakom trait `Tunnel` (BYGGT 2026-09-24)

Ägarens ord: "it must provide for more than wireguard; if possible use a trait, for mullvad, tailscale".

- **Traiten är sömmen.** `trait Tunnel { kind, public_key (default tom), up, add_peer, status }`.
  `public_key` är lådans mesh-nyckel, känd FÖRE `up` (lådan joinar först). `driver(kind)` utan
  konfiguration, `driver_for(seed, feed)` med fröets publika val och dörrens matning. En typ
  som mediet inte byggts med vägras `[tunnel-not-built]`.
- **Fröet** har valfria publika sektioner `[mullvad] { location, port }` och
  `[tailscale] { control_url }`. Hemligheterna (kontonummer, auth key) står ALDRIG på mediet —
  dörren matar dem över mTLS vid join: `feed.mullvad_account`, `feed.tailscale_authkey`.
- **Mullvad** (feature `mullvad`, fungerar): Mullvad är en EXIT-VPN, reläerna bär inte trafik
  mellan klienter — den kan inte vara meshen. Därför: meshen förblir WireGuard via fronten
  (`wg0`), och varje låda är dessutom Mullvad-klient på `wg1` — egen nyckel registrerad på
  kontot (`tunnr-mullvad` former + ureq-klient), relä efter `location`, `tunnel_for_relay` →
  samma dataplan (wg-appliance-core). Luckor: standardrutten skrivs inte (ingen
  `RTM_NEWROUTE`-skrivare i sviten, F15), ingen PQ-PSK, registreringen är oprövad live (inget
  konto) — relälistan är provad live genom vår klient.
- **Tailscale** (feature `tailscale`): byggd men vägrar `[tunnel-tailscale-unimplemented]` —
  det saknas en ren-Rust-koordinationsklient (ts2021 Noise-IK, MapRequest-long-poll, DERP);
  ingen finns i sviten eller i den lokala crates-spegeln, och tailscaled kan inte köras på en
  OS-lös låda. Testerna nålar vägran.
- **Dörrens livslängder:** `Door::with_box_cert_valid` (standard 24 h, var fast 6 h) och
  `Door::forget(name)` för en ominstallerad låda (domen behålls, bootstrap öppnas för den
  sloten). Installer-CA:ns livslängd (anroparens) måste överleva lådornas certifikat.

## 10. Det konstanta mediet (ägaren 2026-09-24 kväll, BYGGT i 0.2.0)

Ägarens order: ISO:n ska vara DENSAMMA mellan installationer. Oförändrad gunnar-commit ⇒ samma
sha ⇒ mediet tas över, ingen komponering, ingen uppladdning, ingen grind. Inget UpCloud-metadata
eller user_data: det ENDA som matar en låda som startar är installerarens mTLS-dörr.

- **Fröet bär bara det som når dörren:** estate, tunneltyp, dörrens adress, installer-CA:ns
  certifikat, bootstrap-certet + nyckel, `peers_expected` (+ de publika mullvad/tailscale-valen).
  `Front.tunnel_pubkey` och `Front.endpoint` är BORTA ur fröet (ett 0.1-frö med dem läses
  fortfarande; fälten ignoreras).
- **Frontens tunnel matas vid join:** `Door::front` (`FrontTunnel { tunnel_pubkey, endpoint }`),
  skrivet av laptopen i dörrens ledger när installeraren förbereds, listas först i varje svars
  `peers` som peern `front` med roll front och endpoint (`JoinReply::front()`). Relänyckeln
  präglas per uppläggning, på fronten; den står aldrig på mediet.
- **Installer-CA:n är per MEDIUM, inte per uppläggning:** präglas av laptopen, sparas bredvid
  mediets kvitto (0600) och skjuts till varje ny fronts dörr. Lång livstid (anroparens val;
  private-gunnar-ops väljer 180 dagar, ny CA — och därmed nytt medium — när under 30 dagar
  återstår). "Giltig endast under bootstrap" upprätthålls av DÖRREN (`spent` per joinad slot,
  `withdrawn` när ledgern är komplett), inte av ett kort utgångsdatum. `mtls::cert_left` läser
  vad ett certifikat har kvar.
- **Dörrens certifikat bakåtdateras ett dygn** (`DOOR_BACKDATE`): lådan prövar det med en klocka
  som ingen ställt än (klockan matas av dörren EFTER join). Dörrens cert lever lika länge som CA:n.
- **Fleet-testet** (draupnir, tre containrar, WireGuard via reläet) grönt med frontnyckeln tagen
  ur svaret i stället för fröet.

Kvar hos gunnar/ops (samma dag): gunnar skriver `wg0.conf` ur den matade fronten; mediet bär
`monetize_pubkey=fed` och ingen klocka; dörrens adress deklareras i `estate.toml [installer]
door_ip` så att mediet kan komponeras innan fronten finns, och alla tre maskiner startas samtidigt.
Öppen ägarfråga: om UpCloud alls lägger en server på en VALD utility-adress, och att den förra
fronten håller sin adress tills `down` (riv sist) — se ops `ANALYS-INSTALL-XTASK-2026-09-24.md`.

## 11. Erbjudandet: det lådan ger installeraren (0.3.1, 2026-09-25)

Installeraren matar lådorna (`POST /install/feed`); 0.3.1 lägger till den andra riktningen.
`GET /install/offer` svarar med det lådan ERBJUDER — publika värden som bara den kan prägla:
primärens ticket-nyckel (som operatörens verktyg för vidare till monetize), tvillingens
replikeringsnyckel och signeringsbegäran, certifikatet primären signerade åt tvillingen.

- `Vm::offer()` (standard: tomt) på lådan; `Client::offer()` hos installeraren (en låda utan
  vägen, 404, är ett tomt erbjudande).
- Installeraren lägger varje erbjudet värde i dörrens matning som `offer.<vm>.<nyckel>`
  (`bootstrap::fed_offer_key`, tillbaka med `bootstrap::offers_in`) — nästa svar till varje låda
  skiljer sig då och matas igen — och säger det till `Sink::offer`. `FolderSink` skriver
  `offers.toml` (`installer::read_offers`).
- Så byter två lådor som ännu inte når varandra det bara de kan prägla, med installeraren, som
  redan är betrodd åt båda hållen, som bärare. Aldrig en privat nyckel: erbjudandet loggas.