Files
felhom-agent/cmd/felhom-opsign/main.go
T
admin 8033a522cd feat: D1 Part 2 — agent self-update Go plumbing (op class, opsign, executor, commit, report)
- reconcile: ClassAgentUpdate op class; always Destructive (no provenance
  blesses replacing the root-adjacent binary). classify test + companion
  (TestClassify_AgentUpdateAlwaysDestructive).
- opsign: `-op agent_update` with -agent-version + -sha256 (isHex64-validated);
  params {version,sha256}. isHex64 test (Group D).
- config: SelfUpdateConfig{URLTemplate,Username,Token,StateDir,DwellSeconds}
  + WithDefaults + Token redaction.
- internal/selfupdate: Executor (download → verify vs the SIGNED sha → sudo -n
  wrapper `apply`; sha is the only integrity root — mismatch refuses + removes,
  agent untouched); Manager (startup dwell → `commit`; version-mismatch → no
  commit + loud WARN + marker left for report visibility; shutdown-before-dwell
  leaves pending). WrapperRunner seam → tests never shell out.
- hub report: additive selfupdate_pending(+version) via SetSelfUpdateReporter
  seam; both omitempty (Wireguard precedent) so the cross-repo golden contract
  stays byte-stable — no hub change.
- capability manifest: 3 non-critical FELHOM_SELFUPDATE probes.
- main.go: updateExec appended to the executor chain; commit-manager wired to
  the report seam + MaybeCommit goroutine after core init.

Tests: Group A (executor happy/sha-mismatch+companion/bad-params/wrapper-fail),
B (agent_update rides the real gate: pinned-key executes, non-pinned +
retarget rejected), C (commit/version-mismatch/no-pending/shutdown), D (opsign).
C2 companion red-proof verified (neutered Go verify → bad binary reaches apply
→ test fails), reverted. Full go test ./... green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-05 15:32:15 +02:00

223 lines
8.5 KiB
Go

// Command felhom-opsign is the OPERATOR's offline signing CLI for destructive ops (slice 10B,
// decision (a): offline operator key + signing CLI, hardware-key-ready).
//
// It constructs the canonical OpBlob bytes by REUSING internal/authz.CanonicalBlob — the exact
// production path the agent's verifier authenticates over — so signer and verifier can never drift.
// It signs that canonical message with the operator's key via `ssh-keygen -Y sign`, which makes it
// hardware-ready: an `sk-`/YubiKey key works through ssh-keygen unchanged. The output is a signed-op
// envelope { op_blob_b64, sig_armored } to hand to the hub's jobs queue (optionally uploaded with
// --upload). This CLI touches ONLY the operator's signing key — never the hub's or agent's keys.
//
// Example — sign a data-bearing wipe (closing the 8C pending_signature gap):
//
// felhom-opsign -op storage_wipe -host demo-felhom-01 \
// -durable-id byid:wwn-0x5000c500abcd1234 -fstype ext4 \
// -key-id ops-key-1 -key ~/.ssh/felhom_op_ed25519 -ttl 30m
package main
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/authz"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "felhom-opsign:", err)
os.Exit(1)
}
}
func run() error {
var (
op = flag.String("op", "", "op class to sign, e.g. storage_wipe | guest_destroy | decommission | agent_update")
host = flag.String("host", "", "target host_id (anti-retarget — the op runs ONLY on this host)")
guest = flag.String("guest", "", "target guest_id (\"\" = host-scoped op)")
keyID = flag.String("key-id", "", "key id of the signing key (must match a pinned agent signer)")
paramsRaw = flag.String("params", "", "op params as JSON (overrides -durable-id/-fstype)")
durableID = flag.String("durable-id", "", "storage_wipe: the DURABLE device id (byid:…|byuuid:…); decommission: the drive's STORAGE durable-id (e.g. uuid:<fs-uuid>)")
fstype = flag.String("fstype", "ext4", "for storage_wipe: the filesystem to mkfs after wipe")
agentVer = flag.String("agent-version", "", "for agent_update: the target agent version (e.g. 0.70.1)")
sha256Hex = flag.String("sha256", "", "for agent_update: the pinned lowercase-hex sha256 of the target binary")
keyFile = flag.String("key", "", "operator signing key (ssh private key / sk- key handle) for ssh-keygen -Y sign")
ttl = flag.Duration("ttl", 30*time.Minute, "validity window from now (issued_at..expires_at)")
nonce = flag.String("nonce", "", "explicit nonce (default: a fresh 128-bit random nonce)")
uploadURL = flag.String("upload", "", "optional hub base URL to POST the signed op to the jobs queue")
hubKey = flag.String("hub-key", "", "hub bearer key for --upload (operator's hub key — NOT a signing key)")
out = flag.String("o", "", "write the envelope JSON to this file (default: stdout)")
)
flag.Parse()
if *op == "" || *host == "" || *keyID == "" || *keyFile == "" {
return fmt.Errorf("-op, -host, -key-id and -key are required")
}
// Params: explicit JSON, or built from the convenience flags.
params := strings.TrimSpace(*paramsRaw)
if params == "" {
switch *op {
case "storage_wipe":
if *durableID == "" {
return fmt.Errorf("storage_wipe needs -durable-id (byid:…|byuuid:…) — a path-only binding is refused by the agent")
}
pj, _ := json.Marshal(map[string]string{"durable_id": *durableID, "fstype": *fstype})
params = string(pj)
case "decommission":
// Decommission binds to the drive's STORAGE durable-id (the watchdog's key, e.g.
// "uuid:<fs-uuid>"), NOT the device-level byid:/byuuid: scheme. The agent records this
// id into the intent map, so it must match what the storage observer reports.
if *durableID == "" {
return fmt.Errorf("decommission needs -durable-id (the drive's storage durable-id, e.g. uuid:<fs-uuid>)")
}
pj, _ := json.Marshal(map[string]string{"durable_id": *durableID})
params = string(pj)
case "agent_update":
// The agent downloads the binary for -agent-version and verifies it against -sha256.
// The sha is the ONLY integrity root, so both are mandatory and the sha is strict-validated.
if *agentVer == "" || *sha256Hex == "" {
return fmt.Errorf("agent_update needs -agent-version and -sha256 (the pinned binary hash)")
}
if !isHex64(*sha256Hex) {
return fmt.Errorf("agent_update -sha256 must be 64 lowercase hex chars (got %d)", len(*sha256Hex))
}
pj, _ := json.Marshal(map[string]string{"version": *agentVer, "sha256": *sha256Hex})
params = string(pj)
default:
params = "{}"
}
}
n := *nonce
if n == "" {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Errorf("generating nonce: %w", err)
}
n = hex.EncodeToString(b[:])
}
now := time.Now().UTC()
issued := now
expires := now.Add(*ttl)
// Canonical OpBlob bytes — the EXACT bytes the agent verifier authenticates over.
blob, err := authz.CanonicalBlob(*op, *host, *guest, *keyID, n, params, issued, expires)
if err != nil {
return err
}
sigArmored, err := signWithSSHKeygen(blob, *keyFile)
if err != nil {
return err
}
env := map[string]string{
"op_blob_b64": base64.StdEncoding.EncodeToString(blob),
"sig_armored": sigArmored,
}
envJSON, _ := json.Marshal(env)
// Emit the envelope (stdout or file). Also print the human summary to stderr (never the key).
fmt.Fprintf(os.Stderr, "signed: op=%s host=%s guest=%q key_id=%s nonce=%s expires=%s\n",
*op, *host, *guest, *keyID, n, expires.Format(time.RFC3339))
if *out != "" {
if err := os.WriteFile(*out, envJSON, 0o600); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "wrote envelope to", *out)
} else {
fmt.Println(string(envJSON))
}
if *uploadURL != "" {
if *hubKey == "" {
return fmt.Errorf("--upload needs --hub-key (the operator's hub bearer key)")
}
if err := upload(*uploadURL, *hubKey, *host, envJSON); err != nil {
return fmt.Errorf("upload to hub: %w", err)
}
fmt.Fprintln(os.Stderr, "uploaded signed op to the hub jobs queue")
}
return nil
}
// isHex64 reports whether s is exactly 64 lowercase hex chars (a sha256 hex digest).
func isHex64(s string) bool {
if len(s) != 64 {
return false
}
for _, c := range s {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
// signWithSSHKeygen signs `message` with `ssh-keygen -Y sign -n <namespace>`, the hardware-ready
// path (sk-/YubiKey keys work unchanged). It writes the message to a temp file, runs ssh-keygen,
// and reads the armored SSHSIG it produces. The namespace is the agent's FIXED domain separator.
func signWithSSHKeygen(message []byte, keyFile string) (string, error) {
dir, err := os.MkdirTemp("", "felhom-opsign-")
if err != nil {
return "", err
}
defer os.RemoveAll(dir)
msgPath := filepath.Join(dir, "op.blob")
if err := os.WriteFile(msgPath, message, 0o600); err != nil {
return "", err
}
cmd := exec.Command("ssh-keygen", "-Y", "sign", "-n", authz.Namespace, "-f", keyFile, msgPath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("ssh-keygen -Y sign: %w: %s", err, strings.TrimSpace(stderr.String()))
}
sig, err := os.ReadFile(msgPath + ".sig")
if err != nil {
return "", fmt.Errorf("reading produced signature: %w", err)
}
return string(sig), nil
}
// upload POSTs the signed-op envelope to the hub's jobs queue (POST /api/v1/admin/hosts/{id}/jobs).
// The queued blob is base64(envelope JSON); the hub stores it opaquely (it cannot forge or open it).
func upload(baseURL, hubKey, hostID string, envJSON []byte) error {
body, _ := json.Marshal(map[string]string{
"blob_b64": base64.StdEncoding.EncodeToString(envJSON),
})
// 10A's enqueue lives under /admin/hosts/{id}/jobs (operator/global key). The queued blob is
// base64(envelope JSON); the hub stores it opaquely.
url := strings.TrimRight(baseURL, "/") + "/api/v1/admin/hosts/" + hostID + "/jobs"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+hubKey)
req.Header.Set("Content-Type", "application/json")
hc := &http.Client{Timeout: 15 * time.Second}
resp, err := hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
return nil
}