slice 10B: operator-signed destructive completion (offline key + signing CLI) (v0.16.0)
A destructive op runs ONLY on a pinned-key-verified, nonce-fresh, in-window, host-bound, durable-id-bound operator signature. New cmd/felhom-opsign signs canonical OpBlobs offline via ssh-keygen -Y sign (hardware-ready); the signing key is never in the hub or agent. New internal/signedjobs runner verifies each queued blob through the gate and only on all-pass runs the WipeExecutor, which re-resolves the DURABLE device id + re-inspects (8C) before mkfs — closing the 8C data-bearing-wipe pending_signature gap. New storage durable-device resolution; authz.CanonicalBlob promoted to production. Real-crypto tests assert valid executes and forged/replay/expired/retarget/non-pinned are rejected (executor never called). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -144,6 +144,69 @@ func (c *Client) FetchDesiredState(ctx context.Context) (*DesiredStateResponse,
|
||||
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.
|
||||
type JobWire struct {
|
||||
JobID string `json:"job_id"`
|
||||
BlobB64 string `json:"blob_b64"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Jobs fetches this host's pending signed-op jobs (slice 10B). Self-scoped server-side (the
|
||||
// per-host key only reads its own host). The agent verifies each before executing.
|
||||
func (c *Client) Jobs(ctx context.Context) ([]JobWire, error) {
|
||||
if c.hostID == "" {
|
||||
return nil, fmt.Errorf("hub: Jobs requires a configured host_id")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/hosts/"+c.hostID+"/jobs", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
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, 4<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, &HTTPError{StatusCode: resp.StatusCode, BodyTail: tail(raw, 256)}
|
||||
}
|
||||
var out struct {
|
||||
Jobs []JobWire `json:"jobs"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("hub: decoding jobs: %w", err)
|
||||
}
|
||||
return out.Jobs, nil
|
||||
}
|
||||
|
||||
// CompleteJob clears a processed job from the host's queue (slice 10B): DELETE
|
||||
// /hosts/{id}/jobs/{job_id}, self-scoped. Called after a job is executed OR permanently rejected
|
||||
// (the nonce is already durably burned on a passing verify, so re-processing is replay-safe).
|
||||
func (c *Client) CompleteJob(ctx context.Context, jobID string) error {
|
||||
if c.hostID == "" || jobID == "" {
|
||||
return fmt.Errorf("hub: CompleteJob requires host_id + job_id")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL+"/api/v1/hosts/"+c.hostID+"/jobs/"+jobID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return &TransportError{Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return &HTTPError{StatusCode: resp.StatusCode, BodyTail: tail(raw, 256)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(b []byte, max int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > max {
|
||||
|
||||
@@ -29,6 +29,22 @@ type EnvelopeObserver interface {
|
||||
OnEnvelope(ctx context.Context, env *ControlEnvelope)
|
||||
}
|
||||
|
||||
// MultiObserver fans one envelope out to several observers in order (e.g. the desired-state
|
||||
// syncer + the signed-jobs runner). nil entries are skipped.
|
||||
func MultiObserver(observers ...EnvelopeObserver) EnvelopeObserver {
|
||||
return multiObserver(observers)
|
||||
}
|
||||
|
||||
type multiObserver []EnvelopeObserver
|
||||
|
||||
func (m multiObserver) OnEnvelope(ctx context.Context, env *ControlEnvelope) {
|
||||
for _, o := range m {
|
||||
if o != nil {
|
||||
o.OnEnvelope(ctx, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loop is the agent's first daemon run loop: collect a host-report, POST it, adopt
|
||||
// the hub's cadence, repeat. It is resilient — a collect or report error is logged
|
||||
// and the loop continues (the data plane is independent of the agent; a hub outage
|
||||
|
||||
Reference in New Issue
Block a user