412 lines
15 KiB
Go
412 lines
15 KiB
Go
package main
|
|
|
|
// Move plans: the ONE write lane on a machine store, and it
|
|
// is archive-only. The host reads the local agent's catalog, proposes a plan of
|
|
// `move` operations inside the granted root (a new structure; "remove" means
|
|
// move into an archive folder), the USER approves it with their step-up code,
|
|
// the host signs it, and this box executes it:
|
|
//
|
|
// dry_run → every move checked against the sandbox (nothing touched)
|
|
// apply → the same check, then renames in order — each one re-checked the
|
|
// instant before it runs (target still absent, parent still inside
|
|
// the root); a failure rolls the completed renames back; then a
|
|
// verify pass (every target present, every source gone, sizes as
|
|
// declared) and a report.
|
|
//
|
|
// What cannot happen here, by construction:
|
|
// - no delete: there is no remove call on any user path in this file. The
|
|
// only filesystem mutations are os.Rename inside the root and MkdirAll for
|
|
// a target's parent inside the root.
|
|
// - no overwrite: a target that exists is a problem, never replaced — checked
|
|
// on normalized, case-folded paths at plan time AND immediately before each
|
|
// rename (rename(2) replaces silently; this lane never lets it).
|
|
// - no escape: from/to both resolve through the same sandbox as every read
|
|
// (cleanRel + symlink wall), and excludes still apply. The report file is
|
|
// written only if its own path resolves inside the root.
|
|
// - no unsigned, no eternal plan: verified against the pinned entity key,
|
|
// must carry an expiry, must name this store's root and this box, and the
|
|
// store grant must still be held here. An applied plan is not re-applied.
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
const dcnPlanMaxMoves = 5000
|
|
|
|
type dcnMove struct {
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
SizeBytes *int64 `json:"size_bytes,omitempty"` // declared by the host from the catalog; checked before AND after the move
|
|
}
|
|
|
|
type dcnPlanTerms struct {
|
|
Kind string `json:"kind"`
|
|
StoreID int64 `json:"store_id"`
|
|
PlanID int64 `json:"plan_id"`
|
|
Server string `json:"server"`
|
|
Root string `json:"root"`
|
|
Archive string `json:"archive"`
|
|
Moves []dcnMove `json:"moves"`
|
|
Expires string `json:"expires"`
|
|
// Approval this box issued after seeing the user's code (stepup.go). Required
|
|
// to APPLY when a second factor is enrolled here; ignored otherwise.
|
|
Approval string `json:"approval,omitempty"`
|
|
}
|
|
|
|
type dcnPlanProblem struct {
|
|
Index int `json:"index"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Why string `json:"why"`
|
|
}
|
|
|
|
// dcnVerifyPlan checks the plan's signature against the pinned key and parses
|
|
// its terms from the EXACT signed bytes — the same discipline as dcnVerifyRec.
|
|
func dcnVerifyPlan(pinnedKeyB64, termsB64, sigB64 string) (*dcnPlanTerms, error) {
|
|
rec := dcnGrantRec{TermsCanonical: termsB64, Signature: sigB64}
|
|
if _, err := verifySignedUnexpired(pinnedKeyB64, rec); err != nil {
|
|
// Signature + a stated, future expiry — the wall every signed record
|
|
// shares (service_reach.go). Its KIND is checked just below; dcnVerifyRec
|
|
// is not used here, because it now refuses anything but a store grant.
|
|
return nil, err
|
|
}
|
|
canonical, _ := base64.StdEncoding.DecodeString(termsB64)
|
|
var p dcnPlanTerms
|
|
if err := json.Unmarshal(canonical, &p); err != nil {
|
|
return nil, fmt.Errorf("signed plan unparsable")
|
|
}
|
|
if p.Kind != "dcn_move_plan" {
|
|
return nil, fmt.Errorf("not a move plan")
|
|
}
|
|
if strings.TrimSpace(p.Expires) == "" {
|
|
return nil, fmt.Errorf("plan carries no expiry (a plan is executed, never held)")
|
|
}
|
|
if len(p.Moves) == 0 || len(p.Moves) > dcnPlanMaxMoves {
|
|
return nil, fmt.Errorf("plan must carry 1..%d moves", dcnPlanMaxMoves)
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
// pathKey is the identity two plan paths are compared by: the cleaned absolute
|
|
// path, case-folded — so "Archive/x", "./Archive//x" and "archive/X" collide
|
|
// (case-insensitive filesystems make the last two the same file).
|
|
func pathKey(abs string) string {
|
|
return strings.ToLower(filepath.Clean(abs))
|
|
}
|
|
|
|
// dcnCheckPlan is the dry run: every move is validated against the sandbox and
|
|
// the current filesystem, nothing is touched. Returns the problems found.
|
|
func dcnCheckPlan(root string, excludes []string, moves []dcnMove) []dcnPlanProblem {
|
|
var problems []dcnPlanProblem
|
|
bad := func(i int, m dcnMove, why string) {
|
|
problems = append(problems, dcnPlanProblem{Index: i, From: m.From, To: m.To, Why: why})
|
|
}
|
|
seenTo := map[string]int{}
|
|
seenFrom := map[string]int{}
|
|
for i, m := range moves {
|
|
if strings.TrimSpace(m.From) == "" || strings.TrimSpace(m.To) == "" {
|
|
bad(i, m, "from and to are required")
|
|
continue
|
|
}
|
|
src, ok := dcnResolvePath(root, m.From)
|
|
if !ok {
|
|
bad(i, m, "source escapes the store root")
|
|
continue
|
|
}
|
|
dst, ok := cleanRel(root, m.To)
|
|
if !ok {
|
|
bad(i, m, "target escapes the store root")
|
|
continue
|
|
}
|
|
if pathKey(src) == pathKey(dst) {
|
|
bad(i, m, "from and to are the same path")
|
|
continue
|
|
}
|
|
if j, dup := seenTo[pathKey(dst)]; dup {
|
|
bad(i, m, fmt.Sprintf("target already used by move %d", j))
|
|
continue
|
|
}
|
|
if j, dup := seenFrom[pathKey(src)]; dup {
|
|
bad(i, m, fmt.Sprintf("source already moved by move %d", j))
|
|
continue
|
|
}
|
|
seenTo[pathKey(dst)], seenFrom[pathKey(src)] = i, i
|
|
if dcnExcludedAsSent(excludes, root, m.From) {
|
|
bad(i, m, "source is excluded by the grant")
|
|
continue
|
|
}
|
|
info, err := os.Lstat(src)
|
|
if err != nil {
|
|
bad(i, m, "source does not exist")
|
|
continue
|
|
}
|
|
if m.SizeBytes != nil && !info.IsDir() && info.Size() != *m.SizeBytes {
|
|
bad(i, m, fmt.Sprintf("size is %d, the plan says %d (the file changed since it was catalogued)", info.Size(), *m.SizeBytes))
|
|
continue
|
|
}
|
|
if dcnExcludedAsSent(excludes, root, m.To) {
|
|
bad(i, m, "target is excluded by the grant")
|
|
continue
|
|
}
|
|
// The target's deepest existing ancestor must resolve inside the root
|
|
// (a symlinked parent pointing outside would carry the file out).
|
|
if _, ok := dcnResolvePath(root, m.To); !ok {
|
|
bad(i, m, "target's parent escapes the store root")
|
|
continue
|
|
}
|
|
if _, err := os.Lstat(dst); err == nil {
|
|
bad(i, m, "target already exists (a plan never overwrites)")
|
|
continue
|
|
}
|
|
// Moving a directory into itself.
|
|
if strings.HasPrefix(pathKey(dst), pathKey(src)+string(os.PathSeparator)) {
|
|
bad(i, m, "target is inside the source")
|
|
continue
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// dcnReportPath is where the box keeps a plan's report — <root>/.agent/plans/<id>.json,
|
|
// the machine's own record next to the local agent's outputs — but only if that
|
|
// path resolves inside the root (a symlinked .agent could otherwise carry the
|
|
// write out). "" = do not write; the report still goes back over the lane.
|
|
func dcnReportPath(root string, planID int64) string {
|
|
rel := filepath.Join(".agent", "plans", strconv.FormatInt(planID, 10)+".json")
|
|
p, ok := dcnResolvePath(root, rel)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return p
|
|
}
|
|
|
|
// dcnApplyPlan runs the plan: check, rename in order (each re-checked just
|
|
// before it runs; rolling back on the first failure), verify, report. Every
|
|
// path it touches has passed dcnCheckPlan.
|
|
func dcnApplyPlan(root string, excludes []string, plan *dcnPlanTerms) map[string]any {
|
|
problems := dcnCheckPlan(root, excludes, plan.Moves)
|
|
if len(problems) > 0 {
|
|
return map[string]any{"state": "refused", "mode": "apply", "plan_id": plan.PlanID,
|
|
"reason": fmt.Sprintf("%d move(s) failed the pre-check; nothing was changed", len(problems)),
|
|
"problems": problems}
|
|
}
|
|
type done struct{ src, dst, from string }
|
|
var applied []done
|
|
// rollback undoes the completed renames in reverse; returns what is back
|
|
// and what is NOT (so the user knows exactly what is still displaced).
|
|
rollback := func() (back, stuck []string) {
|
|
for i := len(applied) - 1; i >= 0; i-- {
|
|
if _, err := os.Lstat(applied[i].src); err == nil {
|
|
stuck = append(stuck, applied[i].from) // something now sits at the source; never replace it
|
|
continue
|
|
}
|
|
if err := os.Rename(applied[i].dst, applied[i].src); err == nil {
|
|
back = append(back, applied[i].from)
|
|
} else {
|
|
stuck = append(stuck, applied[i].from)
|
|
}
|
|
}
|
|
return back, stuck
|
|
}
|
|
fail := func(i int, m dcnMove, why string) map[string]any {
|
|
back, stuck := rollback()
|
|
rep := map[string]any{"state": "failed", "mode": "apply", "plan_id": plan.PlanID,
|
|
"failed_at": i, "from": m.From, "to": m.To, "reason": why,
|
|
"applied": len(applied), "rolled_back": len(back)}
|
|
if len(stuck) > 0 {
|
|
rep["not_rolled_back"] = stuck
|
|
}
|
|
return rep
|
|
}
|
|
for i, m := range plan.Moves {
|
|
src, _ := dcnResolvePath(root, m.From)
|
|
dst, _ := cleanRel(root, m.To)
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
return fail(i, m, "could not create the target folder: "+err.Error())
|
|
}
|
|
// Re-check the instant before the rename: the world may have changed since
|
|
// the pre-check (a file appeared at the target; a parent became a symlink).
|
|
// rename(2) would replace silently — this lane never lets it.
|
|
if _, ok := dcnResolvePath(root, m.To); !ok {
|
|
return fail(i, m, "target's parent no longer resolves inside the store root")
|
|
}
|
|
if _, err := os.Lstat(dst); err == nil {
|
|
return fail(i, m, "target appeared since the check (a plan never overwrites)")
|
|
}
|
|
if err := os.Rename(src, dst); err != nil {
|
|
why := err.Error()
|
|
var le *os.LinkError
|
|
if errors.As(err, &le) && errors.Is(le.Err, syscall.EXDEV) {
|
|
why = "target is on a different filesystem than the source (a plan only renames; it never copies and removes)"
|
|
}
|
|
return fail(i, m, why)
|
|
}
|
|
applied = append(applied, done{src, dst, m.From})
|
|
}
|
|
// Verify pass: every target present, every source gone, declared sizes hold.
|
|
var mismatches []dcnPlanProblem
|
|
for i, m := range plan.Moves {
|
|
src, _ := dcnResolvePath(root, m.From)
|
|
dst, _ := cleanRel(root, m.To)
|
|
info, err := os.Lstat(dst)
|
|
if err != nil {
|
|
mismatches = append(mismatches, dcnPlanProblem{i, m.From, m.To, "target missing after move"})
|
|
continue
|
|
}
|
|
if _, err := os.Lstat(src); err == nil {
|
|
mismatches = append(mismatches, dcnPlanProblem{i, m.From, m.To, "source still present after move"})
|
|
}
|
|
if m.SizeBytes != nil && !info.IsDir() && info.Size() != *m.SizeBytes {
|
|
mismatches = append(mismatches, dcnPlanProblem{i, m.From, m.To,
|
|
fmt.Sprintf("size %d, plan declared %d", info.Size(), *m.SizeBytes)})
|
|
}
|
|
}
|
|
rep := map[string]any{"state": "ok", "mode": "apply", "plan_id": plan.PlanID,
|
|
"applied": len(applied), "verified": len(applied) - len(mismatches),
|
|
"archive": plan.Archive, "at": time.Now().UTC().Format(time.RFC3339)}
|
|
if len(mismatches) > 0 {
|
|
rep["state"] = "failed"
|
|
rep["reason"] = fmt.Sprintf("%d move(s) did not verify", len(mismatches))
|
|
rep["mismatches"] = mismatches
|
|
}
|
|
return rep
|
|
}
|
|
|
|
// handleDcnPlanApply — op dcn_plan_apply <store_id> <payload_b64>, payload =
|
|
// {"terms_canonical","signature","mode":"dry_run"|"apply"}.
|
|
func handleDcnPlanApply(ws *websocket.Conn, dir string, args []string) {
|
|
if len(args) < 2 {
|
|
sendMgmt(ws, 'R', []byte("dcn_plan_apply: need <store_id> <payload_b64>\n"))
|
|
sendMgmtExit(ws, 1)
|
|
return
|
|
}
|
|
storeID, err := strconv.ParseInt(strings.TrimSpace(args[0]), 10, 64)
|
|
if err != nil {
|
|
sendMgmt(ws, 'R', []byte("dcn_plan_apply: bad store_id\n"))
|
|
sendMgmtExit(ws, 1)
|
|
return
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(args[1]))
|
|
if err != nil {
|
|
sendMgmt(ws, 'R', []byte("bad base64\n"))
|
|
sendMgmtExit(ws, 1)
|
|
return
|
|
}
|
|
var payload struct {
|
|
TermsCanonical string `json:"terms_canonical"`
|
|
Signature string `json:"signature"`
|
|
Mode string `json:"mode"`
|
|
}
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
sendMgmt(ws, 'R', []byte("bad payload json\n"))
|
|
sendMgmtExit(ws, 1)
|
|
return
|
|
}
|
|
mode := strings.TrimSpace(payload.Mode)
|
|
if mode != "dry_run" && mode != "apply" {
|
|
sendMgmt(ws, 'R', []byte("mode must be dry_run or apply\n"))
|
|
sendMgmtExit(ws, 1)
|
|
return
|
|
}
|
|
op := "plan_" + mode
|
|
// The store grant must be held here (any mask — the plan is its own signed,
|
|
// user-approved authorization; the grant supplies root + excludes).
|
|
grantsMu.Lock()
|
|
f, err := dcnLoadFile(dir)
|
|
grantsMu.Unlock()
|
|
if err != nil || f.VerifyPubkey == "" {
|
|
dcnRefuse(ws, dir, op, storeID, "", "no grant synced to this box (default-deny)")
|
|
return
|
|
}
|
|
rec, ok := f.Stores[strconv.FormatInt(storeID, 10)]
|
|
if !ok {
|
|
dcnRefuse(ws, dir, op, storeID, "", "no grant for this store on this box (default-deny)")
|
|
return
|
|
}
|
|
terms, err := dcnVerifyRec(f.VerifyPubkey, rec)
|
|
if err != nil {
|
|
dcnRefuse(ws, dir, op, storeID, "", "grant refused: "+err.Error())
|
|
return
|
|
}
|
|
if terms.Class != "machine" || terms.Root == "" {
|
|
dcnRefuse(ws, dir, op, storeID, "", "grant is not a machine store")
|
|
return
|
|
}
|
|
plan, err := dcnVerifyPlan(f.VerifyPubkey, payload.TermsCanonical, payload.Signature)
|
|
if err != nil {
|
|
dcnRefuse(ws, dir, op, storeID, "", "plan refused: "+err.Error())
|
|
return
|
|
}
|
|
if plan.StoreID != storeID || plan.Root != terms.Root {
|
|
dcnRefuse(ws, dir, op, storeID, "", "plan is for a different store or root")
|
|
return
|
|
}
|
|
if hn, _ := os.Hostname(); hn != "" && plan.Server != "" && plan.Server != hn {
|
|
dcnRefuse(ws, dir, op, storeID, "", fmt.Sprintf("plan is for server '%s', this box is '%s'", plan.Server, hn))
|
|
return
|
|
}
|
|
planKey := strconv.FormatInt(plan.PlanID, 10)
|
|
if mode == "dry_run" {
|
|
problems := dcnCheckPlan(terms.Root, terms.Excludes, plan.Moves)
|
|
res := map[string]any{"state": "ok", "mode": "dry_run", "plan_id": plan.PlanID,
|
|
"checked": len(plan.Moves), "ready": len(problems) == 0}
|
|
if len(problems) > 0 {
|
|
res["problems"] = problems
|
|
}
|
|
dcnResult(ws, dir, op, storeID, planKey, res)
|
|
return
|
|
}
|
|
// apply — once. A report on disk from a successful run means the files have
|
|
// already moved; running the same plan again could only fail or surprise, so
|
|
// a retry (a lost reply, a host re-run) gets the earlier report back BEFORE
|
|
// any gate can turn it into a refusal. (The signed expiry is the guard that
|
|
// does not depend on a file the user can remove.)
|
|
repPath := dcnReportPath(terms.Root, plan.PlanID)
|
|
if repPath != "" {
|
|
if prev, err := os.ReadFile(repPath); err == nil {
|
|
var old map[string]any
|
|
if json.Unmarshal(prev, &old) == nil && old["state"] == "ok" {
|
|
old["note"] = "already applied on this machine; this is the earlier report"
|
|
dcnResult(ws, dir, op, storeID, planKey, old)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
// Second factor on THIS box (stepup.go): applying a plan changes files here,
|
|
// so when a factor is enrolled here the plan must carry an approval this
|
|
// machine issued after the user confirmed exactly these moves HERE.
|
|
// Checked, not burned, until success.
|
|
if approvalRequired(dir) {
|
|
purpose, perr := purposeForPlan(plan)
|
|
if perr != nil {
|
|
dcnRefuse(ws, dir, op, storeID, planKey, "plan refused: "+perr.Error())
|
|
return
|
|
}
|
|
if err := boxApproval(dir, plan.Approval, purpose); err != nil {
|
|
dcnRefuse(ws, dir, op, storeID, planKey, err.Error())
|
|
return
|
|
}
|
|
}
|
|
rep := dcnApplyPlan(terms.Root, terms.Excludes, plan)
|
|
if rep["state"] == "ok" && plan.Approval != "" {
|
|
markApprovalUsed(dir, plan.Approval) // single use: the files have moved
|
|
}
|
|
if repPath != "" {
|
|
if raw, err := json.MarshalIndent(rep, "", " "); err == nil {
|
|
_ = atomicWrite(repPath, raw) // the machine's own record; best-effort
|
|
}
|
|
}
|
|
dcnResult(ws, dir, op, storeID, planKey, rep)
|
|
}
|