Files
privatae-bastion/client-go/dcn.go
T
Privatae 1176f7fd0b
ci / personal-data (push) Successful in 8s
ci / test (push) Successful in 7m17s
ci / fuzz (push) Successful in 6m33s
bastion 0.61.0
2026-11-02 06:20:42 +00:00

961 lines
35 KiB
Go

package main
// DCN machine-store handlers — the box-side wall for the Distributed Context
// Network. Dedicated Go handlers, NO exec, NO shell — like the context-store
// handlers, these never touch p8AllowedOps. Wire framing reuses sendMgmt /
// sendMgmtExit: O = stdout (a JSON result object), R = stderr, X = exit code.
//
// THE TRUST MODEL, honestly: the pod gate already default-denies un-granted DCN ops,
// but for DCN the box must be its OWN wall — a Go handler on hardware you own
// refuses — so the signed grant is synced HERE (dcn_grant_sync), the
// entity's verify key is PINNED on first sync (TOFU over the mTLS+bearer lane the
// pairing already trusts; a later key change is refused), and every op re-verifies
// the stored grant's signature + expiry before touching a byte. A compromised pod
// can ask; an unsigned/expired/out-of-mask/out-of-root ask is refused on the box.
//
// The op vocabulary is read-only (list / stat / read_range). There is NO delete
// handler — the operation does not exist on this surface, by design.
//
// Exit convention (the mapping the host applies to a machine op's result):
// 0 — handled; O carries a JSON object whose "state" is ok|gone
// 126 — refused (no grant / bad signature / mask / excludes / path escape)
// 1 — handler error (bad input, IO failure)
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
)
const dcnMaxRangeBytes = 512 * 1024
const dcnMaxListEntries = 1000
// dcnGrantFile is <pairing dcn dir>/grants.json — the box's copy of the user's signed
// store grants, plus the pinned entity verify key. 0600; the box's private state.
// The dir is PER PAIRING (Config.dcnDir): the pin is per-entity, and multi-homing
// (0.10.0) means several entities share this daemon — a shared pin would let one
// entity's key verify another's grants.
type dcnGrantFile struct {
// b64 raw ed25519. PINNED by whichever writer reaches it first — a DCN store
// sync was the only one until 0.24.3, when session.go added the session
// frame's own key so a Terminal needs no folder grant. Both write this one
// field, and a key that disagrees with the pin is REFUSED, never overwritten.
VerifyPubkey string `json:"verify_pubkey"`
Stores map[string]dcnGrantRec `json:"stores"`
// Reach grants (service_reach.go): which host may reach which server in
// this machine's service slot. Same signed-record shape and the same pinned
// key as a store grant; verified by reachVerifyRec, which checks its kind.
Reach map[string]dcnGrantRec `json:"reach,omitempty"`
// ReachDropped: reach grants this box dropped because their SERVER was
// stopped (reachDropForSlot), reported in the enforcing report so the host
// revokes its row instead of re-pushing a grant for a server that is gone.
// Newest last, capped at reachDroppedMax.
ReachDropped []int64 `json:"reach_dropped,omitempty"`
// Coder grants (coder_grant.go): the standing grant that lets this host
// start coder runs here without a per-run approval. Same signed-record
// shape and pinned key; verified by coderVerifyRec, which checks its kind.
Coder map[string]dcnGrantRec `json:"coder,omitempty"`
}
type dcnGrantRec struct {
TermsCanonical string `json:"terms_canonical"` // b64 of the EXACT signed bytes
Signature string `json:"signature"` // b64 ed25519 over those bytes
// The user's registered backup key as an age recipient — carried OUTSIDE
// the signed bytes (a public key; the terms stay exactly what was
// signed). The managed local agent seals the store's catalog to it.
SealRecipient string `json:"seal_recipient,omitempty"`
// RootReal is where the granted folder led when this box installed the
// grant: its real path, pinned here (dcn_store.go), outside the signed
// bytes. An op whose root leads anywhere else is refused.
RootReal string `json:"root_real,omitempty"`
}
// dcnTerms is parsed FROM the verified canonical bytes — never from anything else.
type dcnTerms struct {
Kind string `json:"kind"`
Class string `json:"class"`
Server string `json:"server"`
Root string `json:"root"`
Mask []string `json:"mask"`
Excludes []string `json:"excludes"`
Expires string `json:"expires"`
// The local agent's cadence + the sensitive flag — part of the approval purpose,
// so a pod cannot change them under an approval the user gave.
Sweep string `json:"sweep,omitempty"`
Sensitive bool `json:"sensitive,omitempty"`
TTLDays int `json:"ttl_days,omitempty"` // shown to the user; the signed expiry may not exceed it
// Approval this box issued after the user confirmed the request HERE
// (stepup.go). Required at sync when a second factor is enrolled on this
// machine; ignored otherwise.
Approval string `json:"approval,omitempty"`
}
func dcnLoadFile(dir string) (*dcnGrantFile, error) {
raw, err := os.ReadFile(filepath.Join(dir, "grants.json"))
if os.IsNotExist(err) {
return &dcnGrantFile{Stores: map[string]dcnGrantRec{}}, nil
}
if err != nil {
return nil, err
}
var f dcnGrantFile
if err := json.Unmarshal(raw, &f); err != nil {
return nil, err
}
if f.Stores == nil {
f.Stores = map[string]dcnGrantRec{}
}
return &f, nil
}
// pinVerifyKey decides which entity_sign key this pairing's grants are
// verified under, and pins it into f when it was not yet. Returns (pinned,
// refusal): pinned means f changed and the caller saves it.
//
// LOCAL box: TOFU from the first offer over the mTLS+bearer lane the pairing
// already trusts; an offer that disagrees with the pin is refused (the rule
// since the DCN lane existed).
//
// ANCHORED box: the key pinned AT ANCHORING (anchor.go pinnedKeyFor) — the
// anchor host's from claim-anchor, a member's from the anchor-signed pairing
// grant. A key the ANCHOR host offers on this lane is checked against its pin
// and never pinned from: a host that could pin its own key on first use could
// then sign its own grants, and the anchor host is the one that writes here.
//
// A MEMBER whose pairing grant carries no verify_key keeps first-use pinning
// for now: the anchor host does not hold another host's signing key today
// (a host signs only its own pairing grants), so refusing
// would strand every member pairing on the fleet. The grant field is read as
// soon as a host fills it, and the member's offer is then checked against it.
func pinVerifyKey(f *dcnGrantFile, pairing, offered string) (bool, string) {
offered = strings.TrimSpace(offered)
if offered != "" {
if raw, err := base64.StdEncoding.DecodeString(offered); err != nil || len(raw) != ed25519.PublicKeySize {
return false, "host offered a malformed verify key"
}
}
if currentAnchor() != nil {
want := pinnedKeyFor(pairing)
switch {
case want == "" && f.VerifyPubkey == "":
if offered == "" {
return false, "no entity key pinned on this box yet (default-deny)"
}
f.VerifyPubkey = offered // a member's first use, until its grant carries verify_key
return true, ""
case want != "" && f.VerifyPubkey == "":
f.VerifyPubkey = want
if offered != "" && offered != want {
return false, "verify key mismatch — the host offered a key that is not the one pinned at anchoring"
}
return true, ""
case want != "" && f.VerifyPubkey != want:
return false, "verify key mismatch — the key pinned here is not the one pinned at anchoring (re-enrol this box to rotate keys)"
}
if offered != "" && offered != f.VerifyPubkey {
return false, "verify key mismatch — refusing re-pin (re-enrol this box to rotate keys)"
}
return false, ""
}
if f.VerifyPubkey == "" && offered != "" {
f.VerifyPubkey = offered // TOFU pin, first use only
return true, ""
}
if offered != "" && f.VerifyPubkey != offered {
return false, "verify key mismatch — refusing re-pin (re-enrol this box to rotate keys)"
}
return false, ""
}
func dcnSaveFile(dir string, f *dcnGrantFile) error {
raw, err := json.MarshalIndent(f, "", " ")
if err != nil {
return err
}
return atomicWrite(filepath.Join(dir, "grants.json"), raw)
}
// dcnAudit appends one JSONL record to <pairing dcn dir>/audit.jsonl — the box-side
// accounting ("every read is logged for you"). Best-effort: auditing never blocks
// a result, but a refusal is still a refusal whether or not the line lands.
func dcnAudit(dir, op string, storeID int64, path, decision string) {
dcnAuditWith(dir, op, storeID, path, decision, nil)
}
// dcnAuditWith is dcnAudit with more fields on the row (an ask's asker and
// target, askparties.go). They never replace the five every reader expects.
func dcnAuditWith(dir, op string, storeID int64, path, decision string, extra map[string]any) {
row := map[string]any{}
for k, v := range extra {
row[k] = v
}
row["ts"], row["op"] = time.Now().UTC().Format(time.RFC3339), op
row["store_id"], row["path"], row["decision"] = storeID, path, decision
rec, _ := json.Marshal(row)
auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), rec)
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"),
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return
}
defer fh.Close()
_, _ = fh.Write(append(rec, '\n'))
}
// dcnVerifyRec checks one stored grant record against the pinned key and returns its
// parsed terms. Re-verified on EVERY op — a tampered grants.json authorizes nothing.
func dcnVerifyRec(pinnedKeyB64 string, rec dcnGrantRec) (*dcnTerms, error) {
canonical, err := verifySignedRecord(pinnedKeyB64, rec)
if err != nil {
return nil, err
}
var terms dcnTerms
if err := json.Unmarshal(canonical, &terms); err != nil {
return nil, fmt.Errorf("signed terms unparsable")
}
// ITS KIND, CHECKED. The entity key signs several kinds of record (store
// grants, reach grants, …) and this verifier used to parse `kind` without
// comparing it, so any record the key had signed could be offered as a
// store grant. The host has signed "dcn_store_grant" on every store grant
// since store grants existed, so none is refused by this.
if terms.Kind != "dcn_store_grant" {
return nil, fmt.Errorf("signed record is a %q, not a dcn_store_grant", terms.Kind)
}
// NO OPEN-ENDED TERMS. A grant with no expiry used to verify forever; the
// host has always signed one (365 days by default), so refusing its absence
// costs nothing real and closes a grant that could never lapse.
exp, err := time.Parse(time.RFC3339, terms.Expires)
if err != nil {
return nil, fmt.Errorf("grant states no expiry (open-ended terms are refused)")
}
if !exp.After(time.Now()) {
return nil, fmt.Errorf("grant expired")
}
return &terms, nil
}
// dcnResolvePath sandbox-checks rpath against the granted root: cleanRel (traversal
// wall) THEN symlink resolution — the final resolved target must stay under the
// resolved root (a symlink inside the store pointing outside it is an escape).
func dcnResolvePath(root, rpath string) (string, bool) {
joined, ok := cleanRel(root, rpath)
if !ok {
return "", false
}
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return "", false
}
// Resolve the deepest existing ancestor of joined (the file itself may not exist
// yet for stat-of-missing; a missing path can't be a symlink escape).
probe := joined
for {
r, err := filepath.EvalSymlinks(probe)
if err == nil {
if r != resolvedRoot && !strings.HasPrefix(r, resolvedRoot+string(os.PathSeparator)) {
return "", false
}
break
}
// Missing is fine, and so is a broken link as the last part of the path:
// a stat of it reads as gone and a move renames the link itself. A path
// that goes THROUGH a link that leads nowhere (or loops) is refused:
// nothing can be reached through it, and where it would lead cannot be
// shown to be inside the root.
if _, lerr := os.Lstat(probe); lerr == nil && probe != joined {
return "", false
}
parent := filepath.Dir(probe)
if parent == probe {
return "", false
}
probe = parent
}
return joined, true
}
// dcnExcluded reports whether rpath trips any exclude pattern, or is inside a
// local search agent's folder (dcnInAgentDirOn), which no grant reaches. A
// pattern of one folder name matches any segment of the path (".ssh", "*.key",
// ".env*"); a pattern with a "/" (".config/gcloud") matches that run of
// consecutive segments wherever it sits in the path, each one a glob. Names
// are compared the way this platform's file system compares them (dcnNameKey).
// rpath is a real relative path; a path a host sent goes through dcnExcludedAsSent.
func dcnExcluded(excludes []string, rpath string) bool {
return dcnExcludedOn(runtime.GOOS, excludes, rpath)
}
func dcnExcludedOn(goos string, excludes []string, rpath string) bool {
if dcnInAgentDirOn(goos, rpath) {
return true
}
segs := strings.Split(filepath.ToSlash(rpath), "/")
for i, seg := range segs {
segs[i] = dcnNameKey(goos, seg)
}
for _, pat := range excludes {
pat = strings.TrimSpace(pat)
if pat == "" {
continue
}
var run []string
for _, p := range strings.Split(pat, "/") {
if p != "" {
run = append(run, dcnNameKey(goos, p))
}
}
if dcnRunMatches(run, segs) {
return true
}
}
return false
}
// dcnRunMatches says whether the globs in run match len(run) consecutive
// segments of segs, starting anywhere. An empty run matches nothing.
func dcnRunMatches(run, segs []string) bool {
if len(run) == 0 {
return false
}
for i := 0; i+len(run) <= len(segs); i++ {
all := true
for j, p := range run {
if ok, _ := filepath.Match(p, segs[i+j]); !ok {
all = false
break
}
}
if all {
return true
}
}
return false
}
// dcnAgentDir is the folder the local search agent keeps everything it makes
// in, beside the files it reads: the plaintext of every file in scope, the
// sanitized text, vectors, the catalog and sealed bundles. The bastion puts
// it at <root>/.agent wherever it may write into the folder (freeagent_managed.go;
// the agent's own default too), and a folder inside a store can hold another
// store's. The agent's walker never reads one, at any depth and in any case;
// the file ops never serve one either.
const dcnAgentDir = ".agent"
// dcnInAgentDirOn reports whether any segment of rpath names an agent's
// folder, as the file system on goos names it, and in any case on every OS
// (as the agent's own walker judges it: a ".Agent" on a volume that ignores
// case is the agent's folder).
func dcnInAgentDirOn(goos, rpath string) bool {
for _, seg := range strings.Split(filepath.ToSlash(rpath), "/") {
if strings.EqualFold(dcnNameKey(goos, seg), dcnAgentDir) {
return true
}
}
return false
}
// dcnNameKey is a name as the file system on goos tells names apart. Windows
// and macOS ignore case by default, so ".SSH" opens ".ssh" there. Windows also
// drops trailing dots and spaces from a name (".ssh." opens ".ssh") and reads
// "name:stream" as a stream of the file "name" (server.key::$DATA is server.key).
func dcnNameKey(goos, name string) string {
switch goos {
case "windows":
if i := strings.IndexByte(name, ':'); i >= 0 {
name = name[:i]
}
return strings.ToLower(strings.TrimRight(name, " ."))
case "darwin":
return strings.ToLower(name)
}
return name
}
// dcnExcludedAsSent is dcnExcluded for a path a host sent, and it judges what
// the path OPENS rather than its text, both ways it can differ:
// - the spelling: cleanRel trims and cleans before it opens, so " .ssh/id_rsa"
// or "a.key " would get past a check of the text as sent;
// - the links: a link inside the store to an excluded folder ("keys" -> ".ssh")
// makes "keys/id_rsa" open .ssh/id_rsa. So the path is also judged where it
// leads, relative to the resolved root.
//
// A path cleanRel refuses is judged as sent (the sandbox refuses it anyway).
func dcnExcludedAsSent(excludes []string, root, rpath string) bool {
return dcnJudgedAsSent(root, rpath, func(rel string) bool { return dcnExcluded(excludes, rel) })
}
// dcnAgentAsSent is whether a path a host sent opens an agent's folder (by its
// spelling or through a link), judged as dcnExcludedAsSent judges excludes.
// dcnExcluded refuses the same paths; this one exists so the refusal can say
// why.
func dcnAgentAsSent(root, rpath string) bool {
return dcnJudgedAsSent(root, rpath, func(rel string) bool { return dcnInAgentDirOn(runtime.GOOS, rel) })
}
// dcnJudgedAsSent applies judge to what a host's path opens: the cleaned path
// relative to the root, then where it leads relative to the resolved root.
func dcnJudgedAsSent(root, rpath string, judge func(rel string) bool) bool {
joined, ok := cleanRel(root, rpath)
if !ok {
return judge(rpath)
}
if rel, err := filepath.Rel(filepath.Clean(root), joined); err != nil || judge(rel) {
return true
}
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return true
}
rel, err := filepath.Rel(resolvedRoot, dcnWhereItLeads(joined))
if err != nil {
return true
}
return judge(rel)
}
// dcnWhereItLeads is p with its deepest existing part's links resolved and the
// rest (not there yet) appended: where a read of p opens, or a move to p lands.
func dcnWhereItLeads(p string) string {
rest := ""
for probe := p; ; {
if r, err := filepath.EvalSymlinks(probe); err == nil {
return filepath.Join(r, rest)
}
parent := filepath.Dir(probe)
if parent == probe {
return p
}
rest = filepath.Join(filepath.Base(probe), rest)
probe = parent
}
}
func dcnHasMask(terms *dcnTerms, op string) bool {
for _, m := range terms.Mask {
if m == op {
return true
}
}
return false
}
func dcnRefuse(ws *websocket.Conn, dir, op string, storeID int64, path, why string) {
dcnAudit(dir, op, storeID, path, "refused: "+why)
sendMgmt(ws, 'R', []byte(why+"\n"))
sendMgmtExit(ws, 126)
}
func dcnResult(ws *websocket.Conn, dir, op string, storeID int64, path string, obj map[string]any) {
state, _ := obj["state"].(string)
dcnAudit(dir, op, storeID, path, state)
raw, err := json.Marshal(obj)
if err != nil {
sendMgmt(ws, 'R', []byte("result marshal failed\n"))
sendMgmtExit(ws, 1)
return
}
sendMgmt(ws, 'O', raw)
sendMgmtExit(ws, 0)
}
// dcnLoadGrant fetches + re-verifies the grant for storeID and gates (op, rpath)
// against the 'read' mask — list/stat/read_range. dcnLoadGrantMask is the same
// wall with the mask named by the caller (search needs 'search').
func dcnLoadGrant(ws *websocket.Conn, dir, op string, storeID int64, rpath string) *dcnTerms {
return dcnLoadGrantMask(ws, dir, op, storeID, rpath, "read")
}
func dcnLoadGrantMask(ws *websocket.Conn, dir, op string, storeID int64, rpath, mask string) *dcnTerms {
f, err := dcnLoadFile(dir)
if err != nil {
dcnRefuse(ws, dir, op, storeID, rpath, "grant store unreadable")
return nil
}
if f.VerifyPubkey == "" {
dcnRefuse(ws, dir, op, storeID, rpath, "no grant synced to this box (default-deny)")
return nil
}
rec, ok := f.Stores[fmt.Sprint(storeID)]
if !ok {
dcnRefuse(ws, dir, op, storeID, rpath, "no grant for this store on this box (default-deny)")
return nil
}
terms, err := dcnVerifyRec(f.VerifyPubkey, rec)
if err != nil {
dcnRefuse(ws, dir, op, storeID, rpath, "grant refused: "+err.Error())
return nil
}
if terms.Class != "machine" || terms.Root == "" {
dcnRefuse(ws, dir, op, storeID, rpath, "grant is not a machine store")
return nil
}
// list/stat/read_range are read-class ops; search (freeagent.go) needs the
// 'search' mask the user granted separately.
if !dcnHasMask(terms, mask) {
dcnRefuse(ws, dir, op, storeID, rpath, "op '"+mask+"' not in the store's granted mask")
return nil
}
if rpath != "" && dcnAgentAsSent(terms.Root, rpath) {
dcnRefuse(ws, dir, op, storeID, rpath, "path is in the local search agent's own folder (.agent), which no grant reaches")
return nil
}
if rpath != "" && dcnExcludedAsSent(terms.Excludes, terms.Root, rpath) {
dcnRefuse(ws, dir, op, storeID, rpath, "path is excluded by the grant")
return nil
}
return terms
}
type dcnOpArgs struct {
Path string `json:"path"`
Prefix string `json:"prefix"`
StartByte int64 `json:"start_byte"`
EndByte int64 `json:"end_byte"`
Limit int `json:"limit"`
// local agent form (freeagent.go): a search query, and a document id for
// read_range in place of a path. Absent on the classic file ops.
Query string `json:"query"`
K int `json:"k"`
Doc string `json:"doc"`
Kind string `json:"kind"` // catalog: "dirs" (default) | "files"
Offset int `json:"offset"` // catalog: page start
}
func dcnParseArgs(ws *websocket.Conn, args []string) (int64, *dcnOpArgs, bool) {
if len(args) < 2 {
sendMgmt(ws, 'R', []byte("need <store_id> <json_args>\n"))
sendMgmtExit(ws, 1)
return 0, nil, false
}
var storeID int64
if _, err := fmt.Sscan(strings.TrimSpace(args[0]), &storeID); err != nil {
sendMgmt(ws, 'R', []byte("bad store_id\n"))
sendMgmtExit(ws, 1)
return 0, nil, false
}
var a dcnOpArgs
if err := json.Unmarshal([]byte(args[1]), &a); err != nil {
sendMgmt(ws, 'R', []byte("bad json args\n"))
sendMgmtExit(ws, 1)
return 0, nil, false
}
return storeID, &a, true
}
// handleDcnGrantSync installs/updates ONE signed grant: args[0] = b64 JSON
// {store_id, terms_canonical, signature, verify_pubkey}. First sync pins the verify
// key; a different key later is REFUSED (re-pinning is a re-enrol level event, not
// an op). The record is verified before it is stored — the box never persists a
// grant it couldn't enforce.
func handleDcnGrantSync(ws *websocket.Conn, dir string, args []string) {
if len(args) < 1 {
sendMgmt(ws, 'R', []byte("dcn_grant_sync: need <payload_b64>\n"))
sendMgmtExit(ws, 1)
return
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(args[0]))
if err != nil {
sendMgmt(ws, 'R', []byte("bad base64\n"))
sendMgmtExit(ws, 1)
return
}
var payload struct {
StoreID int64 `json:"store_id"`
TermsCanonical string `json:"terms_canonical"`
Signature string `json:"signature"`
VerifyPubkey string `json:"verify_pubkey"`
SealRecipient string `json:"seal_recipient"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
sendMgmt(ws, 'R', []byte("bad payload json\n"))
sendMgmtExit(ws, 1)
return
}
grantsMu.Lock() // one load→modify→save at a time (reconcile.go)
defer grantsMu.Unlock()
f, err := dcnLoadFile(dir)
if err != nil {
sendMgmt(ws, 'R', []byte("grant store unreadable\n"))
sendMgmtExit(ws, 1)
return
}
// The key this grant is verified under. On a LOCAL box: TOFU from this
// first sync, over the mTLS+bearer lane the pairing already trusts. On an
// ANCHORED box: the key pinned AT ANCHORING (anchor.go pinnedKeyFor) —
// the anchor's from claim-anchor, a member's from the anchor-signed
// pairing grant — and never one this sync offers. A host that could pin
// its own key on its first sync could sign its own grants.
self := ""
if a := mgmtAccountOf(ws); a != nil {
self = a.self
}
if _, why := pinVerifyKey(f, self, payload.VerifyPubkey); why != "" {
dcnRefuse(ws, dir, "grant_sync", payload.StoreID, "", why)
return
}
rec := dcnGrantRec{TermsCanonical: payload.TermsCanonical, Signature: payload.Signature,
SealRecipient: strings.TrimSpace(payload.SealRecipient)}
terms, err := dcnVerifyRec(f.VerifyPubkey, rec)
if err != nil {
dcnRefuse(ws, dir, "grant_sync", payload.StoreID, "", "grant does not verify: "+err.Error())
return
}
// The folder this grant names is pinned as it resolves now; a re-sync or a
// renewal of the same root keeps the pin it already has (dcn_store.go).
if prev, held := f.Stores[fmt.Sprint(payload.StoreID)]; held {
prevRoot := ""
if pt, perr := dcnVerifyRec(f.VerifyPubkey, prev); perr == nil {
prevRoot = pt.Root
}
rec.RootReal = dcnPinFor(&prev, prevRoot, terms.Root)
} else {
rec.RootReal = dcnPinFor(nil, "", terms.Root)
}
if hn, _ := os.Hostname(); hn != "" && terms.Server != "" && terms.Server != hn {
dcnRefuse(ws, dir, "grant_sync", payload.StoreID, "",
fmt.Sprintf("grant is for server '%s', this box is '%s'", terms.Server, hn))
return
}
// Second factor on THIS box (stepup.go): when enrolled here, the grant must
// carry an approval this machine issued for exactly these terms, after the
// user confirmed them HERE. A pod can sign anything; this box enforces only
// what the user approved on it. A re-sync of the SAME signed record while it
// is still held (drift repair) needs no gate: identical bytes, already
// approved. The approval is BURNED once the grant is installed, so a revoke
// is final: the pod cannot re-install a dropped grant under the old
// approval. Consequence: a box that loses grants.json cannot be repaired by
// the heartbeat resync — the user re-grants and approves on the machine.
gated := false
if prev, held := f.Stores[fmt.Sprint(payload.StoreID)]; !held || prev.Signature != rec.Signature {
if approvalRequired(dir) {
purpose, perr := purposeForGrant(terms)
if perr != nil {
dcnRefuse(ws, dir, "grant_sync", payload.StoreID, "", "grant refused: "+perr.Error())
return
}
if err := boxApproval(dir, terms.Approval, purpose); err != nil {
fmt.Printf("grant_sync store %d refused: %s (a re-grant approved on this machine is the repair)\n", payload.StoreID, err)
dcnRefuse(ws, dir, "grant_sync", payload.StoreID, "", err.Error())
return
}
// The user approved "for N days"; the signed expiry may not outrun it,
// and a grant with no stated duration is not approvable here at all.
if terms.TTLDays <= 0 {
dcnRefuse(ws, dir, "grant_sync", payload.StoreID, "", "grant states no duration (ttl_days)")
return
}
{
if exp, err := time.Parse(time.RFC3339, terms.Expires); err != nil ||
exp.After(time.Now().Add(time.Duration(terms.TTLDays+1)*24*time.Hour)) {
dcnRefuse(ws, dir, "grant_sync", payload.StoreID, "", "grant expiry exceeds the approved duration")
return
}
}
gated = true
}
}
f.Stores[fmt.Sprint(payload.StoreID)] = rec
if err := dcnSaveFile(dir, f); err != nil {
sendMgmt(ws, 'R', []byte("grant persist failed: "+err.Error()+"\n"))
sendMgmtExit(ws, 1)
return
}
if gated {
markApprovalUsed(dir, terms.Approval) // one approval installs the grant once; a revoke is final
}
// local agent, managed (freeagent_managed.go): a grant with 'search' means
// this box installs and runs the store's local agent itself; one without it
// stops any instance a previous grant started.
if dcnHasMask(terms, "search") {
faEnsureStore(dir, payload.StoreID, terms, sweepFromCanonical(rec.TermsCanonical), rec.SealRecipient)
} else {
faStop(dir, payload.StoreID)
}
dcnResult(ws, dir, "grant_sync", payload.StoreID, "", map[string]any{
"state": "ok", "root": terms.Root, "mask": terms.Mask,
"freeagent": dcnHasMask(terms, "search")})
}
// dcnDropStore removes one store's grant from this box and stops its managed
// local agent. Two callers: the dcn_grant_drop op (the host pushes a revoke the
// moment it happens) and the heartbeat reconcile (a revoke the box missed while
// it was offline — reconcile.go). Before this existed a revoked grant stayed
// valid on the box until its signed expiry (default 365 days) and the local agent
// kept indexing. Returns whether the store was present.
func dcnDropStore(dir string, storeID int64, why string) (bool, error) {
grantsMu.Lock()
defer grantsMu.Unlock()
return dcnDropStoreLocked(dir, storeID, why)
}
// dcnDropStoreLocked is dcnDropStore for a caller that already holds grantsMu
// (the reconcile loop, which walks the file it is modifying).
func dcnDropStoreLocked(dir string, storeID int64, why string) (bool, error) {
f, err := dcnLoadFile(dir)
if err != nil {
return false, err
}
key := strconv.FormatInt(storeID, 10)
if _, ok := f.Stores[key]; !ok {
// Nothing on disk, but never leave an orphan instance running: this
// pairing's own (faKey), never another Host's for the same store id.
faStop(dir, storeID)
return false, nil
}
delete(f.Stores, key)
if err := dcnSaveFile(dir, f); err != nil {
return true, err
}
faStop(dir, storeID)
dcnAudit(dir, "grant_drop", storeID, "", why)
return true, nil
}
// handleDcnGrantDrop — the host revoked a store grant: forget it here, now.
// A tighten needs no verification (an attacker who can speak this lane can at
// most remove access, never widen it), so the op carries just the store id.
func handleDcnGrantDrop(ws *websocket.Conn, dir string, args []string) {
if len(args) < 1 {
sendMgmt(ws, 'R', []byte("dcn_grant_drop: need <store_id>\n"))
sendMgmtExit(ws, 1)
return
}
storeID, err := strconv.ParseInt(strings.TrimSpace(args[0]), 10, 64)
if err != nil {
sendMgmt(ws, 'R', []byte("dcn_grant_drop: bad store_id\n"))
sendMgmtExit(ws, 1)
return
}
present, err := dcnDropStore(dir, storeID, "host-revoked")
if err != nil {
sendMgmt(ws, 'R', []byte("grant drop failed: "+err.Error()+"\n"))
sendMgmtExit(ws, 1)
return
}
dcnResult(ws, dir, "grant_drop", storeID, "", map[string]any{
"state": "ok", "dropped": present})
}
// handleDcnList walks the granted root (optionally under a prefix) and returns up to
// `limit` entries, excludes skipped. Read-only; never follows a path outside the root.
func handleDcnList(ws *websocket.Conn, dir string, args []string) {
storeID, a, ok := dcnParseArgs(ws, args)
if !ok {
return
}
terms := dcnLoadGrant(ws, dir, "list", storeID, a.Prefix)
if terms == nil {
return
}
base := "."
if a.Prefix != "" {
p, ok := dcnResolvePath(terms.Root, a.Prefix)
if !ok {
dcnRefuse(ws, dir, "list", storeID, a.Prefix, "prefix escapes the store root")
return
}
rel, err := dcnRel(terms.Root, p)
if err != nil {
dcnRefuse(ws, dir, "list", storeID, a.Prefix, "prefix escapes the store root")
return
}
base = filepath.ToSlash(rel)
}
r, err := dcnOpenStore(dir, storeID, terms)
if err != nil {
dcnRefuse(ws, dir, "list", storeID, a.Prefix, err.Error())
return
}
defer r.Close()
limit := a.Limit
if limit <= 0 || limit > dcnMaxListEntries {
limit = dcnMaxListEntries
}
type entry struct {
Path string `json:"path"`
Size int64 `json:"size_bytes"`
Mtime string `json:"mtime"`
}
var files []entry
truncated := false
// Walked through the store's handle: a link that leads out of it is not
// followed, whatever the names do during the walk.
_ = fs.WalkDir(r.FS(), base, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil // unreadable subtree: skip, never abort the listing
}
rel := filepath.FromSlash(p)
if rel == "." {
return nil
}
if dcnExcluded(terms.Excludes, rel) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
if len(files) >= limit {
truncated = true
return filepath.SkipAll
}
info, ierr := d.Info()
if ierr != nil {
return nil
}
files = append(files, entry{Path: filepath.ToSlash(rel), Size: info.Size(),
Mtime: info.ModTime().UTC().Format(time.RFC3339)})
return nil
})
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
dcnResult(ws, dir, "list", storeID, a.Prefix, map[string]any{
"state": "ok", "files": files, "count": len(files), "truncated": truncated})
}
// handleDcnStat stats one path. Missing → state=gone (a typed fact, not an error).
func handleDcnStat(ws *websocket.Conn, dir string, args []string) {
storeID, a, ok := dcnParseArgs(ws, args)
if !ok {
return
}
terms := dcnLoadGrant(ws, dir, "stat", storeID, a.Path)
if terms == nil {
return
}
abs, okp := dcnResolvePath(terms.Root, a.Path)
if !okp {
dcnRefuse(ws, dir, "stat", storeID, a.Path, "path escapes the store root")
return
}
rel, err := dcnRel(terms.Root, abs)
if err != nil {
dcnRefuse(ws, dir, "stat", storeID, a.Path, err.Error())
return
}
r, err := dcnOpenStore(dir, storeID, terms)
if err != nil {
dcnRefuse(ws, dir, "stat", storeID, a.Path, err.Error())
return
}
defer r.Close()
info, err := r.Stat(rel)
if err != nil {
if dcnEscaped(err) {
dcnRefuse(ws, dir, "stat", storeID, a.Path, "path escapes the store root")
return
}
if os.IsNotExist(err) {
dcnResult(ws, dir, "stat", storeID, a.Path, map[string]any{
"state": "gone", "path": a.Path})
return
}
sendMgmt(ws, 'R', []byte(err.Error()+"\n"))
sendMgmtExit(ws, 1)
return
}
dcnResult(ws, dir, "stat", storeID, a.Path, map[string]any{
"state": "ok", "path": a.Path, "size_bytes": info.Size(),
"mtime": info.ModTime().UTC().Format(time.RFC3339), "is_dir": info.IsDir()})
}
// handleDcnReadRange returns ONE scoped byte range of one file (b64). The range is
// capped box-side regardless of what was asked — the cap protects the lane.
func handleDcnReadRange(ws *websocket.Conn, dir string, args []string) {
storeID, a, ok := dcnParseArgs(ws, args)
if !ok {
return
}
// A document id (from a local agent search hit) reads the SANITIZED text
// through the local agent instead of file bytes from disk. Same op name,
// same 'read' mask, a different — narrower — source.
if strings.TrimSpace(a.Doc) != "" {
handleDcnReadRangeDoc(ws, dir, storeID, a)
return
}
terms := dcnLoadGrant(ws, dir, "read_range", storeID, a.Path)
if terms == nil {
return
}
if a.Path == "" {
sendMgmt(ws, 'R', []byte("read_range: path required\n"))
sendMgmtExit(ws, 1)
return
}
abs, okp := dcnResolvePath(terms.Root, a.Path)
if !okp {
dcnRefuse(ws, dir, "read_range", storeID, a.Path, "path escapes the store root")
return
}
rel, err := dcnRel(terms.Root, abs)
if err != nil {
dcnRefuse(ws, dir, "read_range", storeID, a.Path, err.Error())
return
}
r, err := dcnOpenStore(dir, storeID, terms)
if err != nil {
dcnRefuse(ws, dir, "read_range", storeID, a.Path, err.Error())
return
}
defer r.Close()
// Opened through the store's handle: a name swapped for a link to a file
// outside the store after the check above is refused, never followed.
fh, err := r.Open(rel)
if err != nil {
if dcnEscaped(err) {
dcnRefuse(ws, dir, "read_range", storeID, a.Path, "path escapes the store root")
return
}
if os.IsNotExist(err) {
dcnResult(ws, dir, "read_range", storeID, a.Path, map[string]any{
"state": "gone", "path": a.Path})
return
}
sendMgmt(ws, 'R', []byte(err.Error()+"\n"))
sendMgmtExit(ws, 1)
return
}
defer fh.Close()
info, err := fh.Stat()
if err != nil || info.IsDir() {
dcnRefuse(ws, dir, "read_range", storeID, a.Path, "not a readable file")
return
}
start := a.StartByte
if start < 0 {
start = 0
}
end := a.EndByte
if end <= start || end > start+dcnMaxRangeBytes {
end = start + dcnMaxRangeBytes
}
if end > info.Size() {
end = info.Size()
}
var buf []byte
if end > start {
buf = make([]byte, end-start)
if _, err := fh.ReadAt(buf, start); err != nil && end < info.Size() {
sendMgmt(ws, 'R', []byte(err.Error()+"\n"))
sendMgmtExit(ws, 1)
return
}
}
dcnResult(ws, dir, "read_range", storeID, a.Path, map[string]any{
"state": "ok", "path": a.Path, "start_byte": start, "end_byte": end,
"size_bytes": info.Size(),
"content_b64": base64.StdEncoding.EncodeToString(buf)})
}