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.