wgtunnel: S3 Part 1 — pure-Go keygen + hub wire (WireWireguard, report stanza, RegisterWG)

key.go: create-once 0600/0700, corrupt-refusal (never overwrite — may be escrowed
identity), clamp for CANONICAL STORED form (x/crypto X25519 clamps derivation
internally — discovered during red-proof (c); the stored-clamped test is the
real anchor). Fixed vectors generated with real wg pubkey (provenance in test).
hub: WireDesiredState.Wireguard + WireguardStatus report stanza + RegisterWG
client (typed errors, token-free). S2 golden copied BYTE-IDENTICAL + field-exact
decode test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-04 07:00:19 +02:00
parent 4ba1b144d6
commit 0daae92c4f
7 changed files with 520 additions and 1 deletions
+38 -1
View File
@@ -47,6 +47,22 @@ type HostReport struct {
// (SPIKE-dr-recipe-2026-06-16). Derived from the facts above; carries ONLY identifiers/intents/
// sizes/coordinates, never a secret. The hub assembles it with the controller's app half.
DRRecipe *DRRecipeHostHalf `json:"dr_recipe"`
// Wireguard is the offsite-tunnel status stanza (S3, doc 06 §4.6). Present only when the
// wg_tunnel feature is enabled. The report is stored opaquely hub-side, so no hub change is
// needed; the pubkey here is the operator's revocation-recovery handle (re-add the peer with
// it). Carries NO secret — the pubkey is public by definition.
Wireguard *WireguardStatus `json:"wireguard,omitempty"`
}
// WireguardStatus is the per-heartbeat offsite-tunnel status (S3). LastHandshakeAgeS is nil when
// the handshake age is unreadable (service down, capability degraded) — nil ≠ 0.
type WireguardStatus struct {
Pubkey string `json:"pubkey"`
Registered bool `json:"registered"` // the registration marker exists
Active bool `json:"active"` // wg-quick@wg-felhom is-active
LastHandshakeAgeS *int64 `json:"last_handshake_age_s,omitempty"`
AssignedIP string `json:"assigned_ip,omitempty"` // from the marker, e.g. "10.77.0.2/32"
}
// HostMetrics is the host block, sourced from proxmox NodeStatus.
@@ -285,7 +301,9 @@ type DesiredStateResponse struct {
// parts it can today (guests: benign deltas reconciled, an explicit decommission gated
// pending_signature); the rest are FORWARD-COMPAT — carried + cached, NOT acted on in 10A. The
// restore_directive is consumed in 10D (host/guest-loss DR); storage_manifest / backup_policy /
// pbs_namespace are placeholders kept opaque so the wire is stable as those land.
// pbs_namespace are placeholders kept opaque so the wire is stable as those land. The wireguard
// block (S3) is HUB-OWNED state merged into the served document at read time (hub S2) — consumed
// by internal/wgtunnel via the desired.Syncer raw-consumer seam.
type WireDesiredState struct {
Guests []WireDesiredGuest `json:"guests"`
@@ -293,6 +311,25 @@ type WireDesiredState struct {
BackupPolicy json.RawMessage `json:"backup_policy,omitempty"`
PBSNamespace string `json:"pbs_namespace,omitempty"`
RestoreDirective *WireRestoreDirective `json:"restore_directive,omitempty"` // slice 10D (forward-compat)
Wireguard *WireWireguard `json:"wireguard,omitempty"` // S3 (doc 06 §3.2; golden-pinned)
}
// WireWireguard is the hub-owned offsite-tunnel assignment (S3) — field-exact with the S2 golden
// (testdata/desired-state-wireguard.golden.json, byte-identical hub copy). Client-side
// AllowedIPs, PersistentKeepalive=25 and MTU 1420 are deliberately NOT wire fields — wgtunnel
// constants derived from endpoint.pbs_tunnel_ip + doc 06 §4.
type WireWireguard struct {
Endpoint WireWireguardEndpoint `json:"endpoint"`
Pubkey string `json:"pubkey"` // the box's registered pubkey
AssignedIP string `json:"assigned_ip"` // e.g. "10.77.0.2/32"
}
// WireWireguardEndpoint is the endpoint half of the wireguard block.
type WireWireguardEndpoint struct {
DNSName string `json:"dns_name"`
WGPort int `json:"wg_port"`
ServerPubkey string `json:"server_pubkey"`
PBSTunnelIP string `json:"pbs_tunnel_ip"`
}
// WireDesiredGuest is one guest's target (slice 10A). Every field is optional ("unmanaged"); the