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
+49
View File
@@ -144,6 +144,55 @@ func (c *Client) FetchDesiredState(ctx context.Context) (*DesiredStateResponse,
return &out, nil
}
// WGRegisterResponse is the hub's answer to a WG pubkey registration (S3; hub S2
// handleRegisterHostWG). Existed=true = idempotent re-register (nothing moved hub-side).
type WGRegisterResponse struct {
Pubkey string `json:"pubkey"`
AssignedIP string `json:"assigned_ip"` // "10.77.0.2/32"
Existed bool `json:"existed"`
Generation int64 `json:"generation"`
Sync string `json:"sync"` // hub→endpoint push status: ok | deferred:… | disabled | unchanged
}
// RegisterWG registers this host's WG public key with the hub (S3 — doc 06 §3.3 step 2; POST
// /hosts/{host_id}/wg, per-host key, self-scoped server-side). The hub allocates/keeps the /32,
// bumps the desired generation on real change, and pushes the peer to the endpoint. Errors are
// typed (transport vs HTTP: 403 auth, 404 unknown host, 409 conflict/endpoint-unset) and never
// include the bearer token. Only the PUBLIC key ever travels.
func (c *Client) RegisterWG(ctx context.Context, pubkey string) (*WGRegisterResponse, error) {
if c.hostID == "" {
return nil, fmt.Errorf("hub: RegisterWG requires a configured host_id")
}
body, err := json.Marshal(map[string]string{"pubkey": pubkey})
if err != nil {
return nil, fmt.Errorf("hub: marshaling wg registration: %w", err)
}
url := c.baseURL + "/api/v1/hosts/" + c.hostID + "/wg"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("hub: building wg-register request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return nil, &TransportError{Err: err}
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, &HTTPError{StatusCode: resp.StatusCode, BodyTail: tail(raw, 256)}
}
var out WGRegisterResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("hub: decoding wg-register response: %w", err)
}
return &out, nil
}
// JobWire is one queued signed-op job as served by GET /hosts/{id}/jobs (slice 10A). The blob is
// OPAQUE to the hub — for slice 10B it is a base64 `SignedJobEnvelope` (op-blob + armored SSHSIG)
// the agent verifies before executing.
+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
@@ -0,0 +1,33 @@
{
"generation": 5,
"desired_state": {
"guests": [
{
"vmid": 100,
"run": "running",
"spec": { "cores": 2, "memory_bytes": 2147483648, "disk_bytes": 21474836480 },
"description": "felhom: acme prod"
},
{
"vmid": 200,
"decommission": true
}
],
"pbs_namespace": "felhom-cust-acme",
"restore_directive": {
"mode": "guest_loss",
"archive": "local:backup/vzdump-lxc-200-2026_06_09-11_00_00.tar.zst",
"vmid": 200
},
"wireguard": {
"endpoint": {
"dns_name": "ep0.felhom.eu",
"wg_port": 443,
"server_pubkey": "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=",
"pbs_tunnel_ip": "10.77.0.1"
},
"pubkey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=",
"assigned_ip": "10.77.0.2/32"
}
}
}
+75
View File
@@ -0,0 +1,75 @@
package hub
// S3 Group C — RegisterWG client (POST /hosts/{id}/wg, per-host key). Typed-error mapping and
// the no-token-in-errors invariant, same scaffolding as the desired-state client tests.
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
)
func TestRegisterWG_PathAuthBodyAndDecode(t *testing.T) {
var gotPath, gotAuth, gotMethod, gotBody string
c := testClient(func(r *http.Request) (*http.Response, error) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotMethod = r.Method
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
return httpResp(200, `{"pubkey":"PK","assigned_ip":"10.77.0.2/32","existed":false,"generation":3,"sync":"ok"}`), nil
})
resp, err := c.RegisterWG(context.Background(), "PK")
if err != nil {
t.Fatalf("RegisterWG: %v", err)
}
if gotMethod != http.MethodPost || gotPath != "/api/v1/hosts/demo-host-01/wg" {
t.Errorf("request = %s %s, want POST /api/v1/hosts/demo-host-01/wg", gotMethod, gotPath)
}
if gotAuth != "Bearer super-secret-bearer-key" {
t.Errorf("auth = %q", gotAuth)
}
if gotBody != `{"pubkey":"PK"}` {
t.Errorf("body = %s", gotBody)
}
if resp.AssignedIP != "10.77.0.2/32" || resp.Existed || resp.Generation != 3 || resp.Sync != "ok" {
t.Errorf("resp = %+v", resp)
}
}
func TestRegisterWG_TypedErrors(t *testing.T) {
for _, tc := range []struct {
status int
body string
}{
{403, "Forbidden: host_id mismatch"},
{404, "Unknown host_id"},
{409, "wg endpoint not configured"},
{409, "pubkey already registered elsewhere"},
} {
c := testClient(func(r *http.Request) (*http.Response, error) {
return httpResp(tc.status, tc.body), nil
})
_, err := c.RegisterWG(context.Background(), "PK")
var he *HTTPError
if !errors.As(err, &he) || he.StatusCode != tc.status {
t.Errorf("status %d: err = %v, want HTTPError %d", tc.status, err, tc.status)
}
if strings.Contains(err.Error(), "super-secret-bearer-key") {
t.Fatalf("bearer token leaked into error: %v", err)
}
}
// Transport failure → TransportError, token-free.
c := testClient(func(r *http.Request) (*http.Response, error) {
return nil, errors.New("dial tcp: connection refused")
})
_, err := c.RegisterWG(context.Background(), "PK")
var te *TransportError
if !errors.As(err, &te) {
t.Errorf("transport err = %v, want TransportError", err)
}
}
+56
View File
@@ -0,0 +1,56 @@
package hub
// S3 Group C — the wireguard desired-state block contract. testdata/desired-state-wireguard.
// golden.json MUST stay byte-identical with felhom.eu/hub's copy (the established cross-repo
// duplication rule); this test decodes it through the new WireWireguard struct field-exactly
// and key-set-compares to catch drift.
import (
"encoding/json"
"os"
"testing"
)
func TestDesiredStateWireguardGolden_DecodesFieldExact(t *testing.T) {
raw, err := os.ReadFile("testdata/desired-state-wireguard.golden.json")
if err != nil {
t.Fatal(err)
}
var resp DesiredStateResponse
if err := json.Unmarshal(raw, &resp); err != nil {
t.Fatalf("wireguard golden does not decode into DesiredStateResponse: %v", err)
}
wg := resp.DesiredState.Wireguard
if wg == nil {
t.Fatal("wireguard block missing after decode")
}
if wg.Pubkey != "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" {
t.Errorf("pubkey = %q", wg.Pubkey)
}
if wg.AssignedIP != "10.77.0.2/32" {
t.Errorf("assigned_ip = %q", wg.AssignedIP)
}
ep := wg.Endpoint
if ep.DNSName != "ep0.felhom.eu" || ep.WGPort != 443 ||
ep.ServerPubkey != "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=" || ep.PBSTunnelIP != "10.77.0.1" {
t.Errorf("endpoint = %+v", ep)
}
// The base (non-wireguard) content of the golden is the S2 superset of the original
// desired-state golden — the pre-existing fields must still decode.
if len(resp.DesiredState.Guests) != 2 || resp.DesiredState.PBSNamespace != "felhom-cust-acme" {
t.Errorf("base fields lost: guests=%d ns=%q", len(resp.DesiredState.Guests), resp.DesiredState.PBSNamespace)
}
// Key-set drift guard for the wireguard object + its endpoint.
var golden map[string]any
json.Unmarshal(raw, &golden)
b, _ := json.Marshal(resp)
var got map[string]any
json.Unmarshal(b, &got)
assertSameKeys(t, "desired_state.wireguard",
golden["desired_state"].(map[string]any)["wireguard"],
got["desired_state"].(map[string]any)["wireguard"])
assertSameKeys(t, "desired_state.wireguard.endpoint",
golden["desired_state"].(map[string]any)["wireguard"].(map[string]any)["endpoint"],
got["desired_state"].(map[string]any)["wireguard"].(map[string]any)["endpoint"])
}