// Package agentapi is the in-guest controller's client for the host agent's per-guest local // API (doc 03 §6, slice 8A). It reaches the agent over the bridge, pinning the agent's // self-signed leaf by SHA-256 (the same pin convention the agent uses for the Proxmox/PBS host // certs), and authenticates with the per-guest bearer token. In 8A it exercises GET /storage // (connectivity + the controller learning its mounts); the full surface (the /backup/due // quiesce loop) lands in 8B. package agentapi import ( "bytes" "context" "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // Client talks to one agent local-API endpoint with a pinned leaf + bearer token. type Client struct { baseURL string token string hc *http.Client } // MountInfo mirrors the agent's GET /storage mount entry (doc 03 §6). type MountInfo struct { Key string `json:"key"` Storage string `json:"storage"` MountPoint string `json:"mount_point"` Class string `json:"class"` // fast | slow | "" Backup bool `json:"backup"` } // StorageResponse mirrors the agent's GET /storage data payload. type StorageResponse struct { VMID int `json:"vmid"` Mounts []MountInfo `json:"mounts"` } // apiResponse is the agent's {ok,data,error} envelope. type apiResponse struct { OK bool `json:"ok"` Data json.RawMessage `json:"data"` Error string `json:"error"` } // New builds a pinned client for endpoint ("host:port") with the given per-guest token and the // agent leaf-cert SHA-256 fingerprint (hex, ':'-separators tolerated). The pin is the trust // anchor — the agent serves a self-signed cert, so chain verification is replaced by an exact // leaf-DER SHA-256 match (fails closed on any mismatch). func New(endpoint, token, fingerprintHex string) (*Client, error) { endpoint = strings.TrimSpace(endpoint) if endpoint == "" { return nil, fmt.Errorf("agentapi: endpoint required") } if token == "" { return nil, fmt.Errorf("agentapi: token required") } want, err := normalizeFingerprint(fingerprintHex) if err != nil { return nil, err } tlsCfg := &tls.Config{ InsecureSkipVerify: true, // self-signed leaf — the pin below is the real check VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error { if len(rawCerts) == 0 { return fmt.Errorf("agentapi: TLS pin: peer presented no certificate") } got := sha256.Sum256(rawCerts[0]) // leaf DER if hex.EncodeToString(got[:]) != want { return fmt.Errorf("agentapi: TLS pin mismatch: agent leaf SHA-256 does not match the bootstrap fingerprint") } return nil }, MinVersion: tls.VersionTLS12, } return &Client{ baseURL: "https://" + endpoint, token: token, hc: &http.Client{ Timeout: 15 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsCfg}, }, }, nil } // Storage calls GET /storage and returns this guest's mounts (connectivity + placement view). func (c *Client) Storage(ctx context.Context) (StorageResponse, error) { var out StorageResponse body, err := c.get(ctx, "/storage") if err != nil { return out, err } if err := json.Unmarshal(body, &out); err != nil { return out, fmt.Errorf("agentapi: decode /storage: %w", err) } return out, nil } // ---- slice 8B: app-consistent backup (quiesce loop) ------------------------------------- // DueResponse mirrors the agent's GET /backup/due payload. type DueResponse struct { VMID int `json:"vmid"` Due bool `json:"due"` Reason string `json:"reason"` } // BackupResponse mirrors the agent's POST /backup payload. type BackupResponse struct { VMID int `json:"vmid"` JobID string `json:"job_id"` Phase string `json:"phase"` } // StatusResponse mirrors the agent's GET /backup/status payload. type StatusResponse struct { VMID int `json:"vmid"` Phase string `json:"phase"` // idle | running | done | failed JobID string `json:"job_id"` Error string `json:"error"` } // Backup status phases (mirror the agent's vocabulary). const ( PhaseIdle = "idle" PhaseRunning = "running" PhaseDone = "done" PhaseFailed = "failed" ) // BackupDue reports whether a policy-scheduled backup is due for this guest (the quiesce trigger). func (c *Client) BackupDue(ctx context.Context) (DueResponse, error) { var out DueResponse body, err := c.get(ctx, "/backup/due") if err != nil { return out, err } if err := json.Unmarshal(body, &out); err != nil { return out, fmt.Errorf("agentapi: decode /backup/due: %w", err) } return out, nil } // StartBackup enqueues a backup of this guest (the agent vzdump) and returns the job to poll. func (c *Client) StartBackup(ctx context.Context) (BackupResponse, error) { var out BackupResponse body, err := c.post(ctx, "/backup", struct{}{}) if err != nil { return out, err } if err := json.Unmarshal(body, &out); err != nil { return out, fmt.Errorf("agentapi: decode POST /backup: %w", err) } return out, nil } // BackupStatus reports the current/last backup job phase for this guest. func (c *Client) BackupStatus(ctx context.Context) (StatusResponse, error) { var out StatusResponse body, err := c.get(ctx, "/backup/status") if err != nil { return out, err } if err := json.Unmarshal(body, &out); err != nil { return out, fmt.Errorf("agentapi: decode /backup/status: %w", err) } return out, nil } // get issues an authenticated GET and unwraps the {ok,data,error} envelope. func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+c.token) resp, err := c.hc.Do(req) if err != nil { return nil, fmt.Errorf("agentapi: GET %s: %w", path, err) } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("agentapi: GET %s: HTTP %d", path, resp.StatusCode) } var env apiResponse if err := json.Unmarshal(raw, &env); err != nil { return nil, fmt.Errorf("agentapi: GET %s: bad envelope: %w", path, err) } if !env.OK { return nil, fmt.Errorf("agentapi: GET %s: %s", path, env.Error) } return env.Data, nil } // post issues an authenticated JSON POST and unwraps the {ok,data,error} envelope. The agent // returns 200 or 202 for accepted requests. func (c *Client) post(ctx context.Context, path string, body any) (json.RawMessage, error) { buf, err := json.Marshal(body) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(buf)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Content-Type", "application/json") resp, err := c.hc.Do(req) if err != nil { return nil, fmt.Errorf("agentapi: POST %s: %w", path, err) } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { return nil, fmt.Errorf("agentapi: POST %s: HTTP %d", path, resp.StatusCode) } var env apiResponse if err := json.Unmarshal(raw, &env); err != nil { return nil, fmt.Errorf("agentapi: POST %s: bad envelope: %w", path, err) } if !env.OK { return nil, fmt.Errorf("agentapi: POST %s: %s", path, env.Error) } return env.Data, nil } // normalizeFingerprint lowercases and strips ':'/' ' separators, requiring a 64-hex SHA-256. func normalizeFingerprint(fp string) (string, error) { s := strings.ToLower(strings.NewReplacer(":", "", " ", "", "\t", "").Replace(strings.TrimSpace(fp))) if len(s) != 64 { return "", fmt.Errorf("agentapi: fingerprint must be a SHA-256 (64 hex chars), got %d", len(s)) } if _, err := hex.DecodeString(s); err != nil { return "", fmt.Errorf("agentapi: fingerprint is not valid hex: %w", err) } return s, nil }