bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user