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:
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
Reference in New Issue
Block a user