package main // local agent, MANAGED: the bastion installs and runs the user's local agent // itself, from the signed store grant, so a member does nothing but approve // the store (the step-up they already do) and have Python on the box. // // For each synced machine-store grant whose mask includes 'search': // 1. fetch the agent .pyz from privatae.ai/bastion-agent/version.json and // verify its sha256 — the same hash-verified path the bastion's own // self-update uses (update.go); // 2. mint a P-256 key + self-signed cert (CN = the store's local agent id). // That one identity is the local agent's TLS cert, its CA root AND the // manifest signing key. The bastion is the only client, and the real // authorization is the signed DCN grant this box already enforces on // every op (dcn.go) — the local agent's gate then only has to prove the // manifest was written by this box; // 3. write the manifest FROM THE GRANT (root, excludes, mask, sweep), // seal to the user's registered backup key when the host sent one // (seal_recipient in grant sync; an age recipient), pick the local // ollama embedding model and learn its dimension by probing, and sign // the manifest with the local agent's own `sign` (so the canonical bytes // are exactly what its gate verifies); // 4. supervise `sweep` (the cadence loop) and `serve` (mTLS on // 127.0.0.1:0, the real port read back from a port file) as child // processes with restart backoff, logs under the store's directory. // // dcn_search / doc-form dcn_read_range prefer the managed instance for the // store; PRIVATAE_BASTION_AGENT_* env remains the manual escape hatch for a // local agent someone runs by hand (freeagent.go). The heartbeat's // `freeagent` capability means at least one instance is serving; the // per-store detail (state, version, what is missing — e.g. "python") rides // in `freeagent_stores` so the Network app can say the one thing to fix. import ( "bytes" "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/sha256" "crypto/x509" "crypto/x509/pkix" "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" "errors" "fmt" "math/big" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync" "time" ) // faManifestURL is the agent's published manifest. A var so tests can point it // at a stub. // // Bastions from 0.20.0 to 0.41.x also tried the legacy // privatae.ai/freeagent/version.json when this one failed. That fallback ended // in 0.42.0. A box that cannot reach this address keeps the agent it has // installed (faEnsurePyz), so losing the second address costs an update, not // the agent. var faManifestURL = "https://privatae.ai/bastion-agent/version.json" // faFetchManifest returns the published manifest, with the URL it came from // and the decoded document. An answer that verifies but does not parse as a // manifest is an error, not an empty manifest. func faFetchManifest() ([]byte, string, faPublished, error) { u := faManifestURL // Signed like the bastion's own manifest (release_sign.go): the pyz // digest below is only worth what the manifest carrying it is worth. body, err := fetchSignedManifest(u, releaseKindAgent) if err != nil { return nil, "", faPublished{}, err } var pub faPublished if err := json.Unmarshal(body, &pub); err != nil || pub.Version == "" { return nil, "", faPublished{}, fmt.Errorf("manifest %s: malformed", u) } return body, u, pub, nil } const ( faOllamaAddr = "127.0.0.1:11434" faEmbedProbeText = "freeagent embedding probe" faReconcileEvery = 10 * time.Minute // retries missing-python/failed; picks up rotated recipients ) // var so a test can shorten it var faRestartBackoff = 60 * time.Second type faInstance struct { StoreID int64 Dir string // the child's state: vault, port, logs, its key copy (owner-owned on a root install) PrivDir string // root-private: the minted identity, recipient, manifest staging (== Dir when not dropping) PubDir string // root-owned, readable: host.crt the signature names (== Dir when not dropping) AgentDir string // /.agent — the agent WITH its folder: manifest + prepared output CN string Cert, Key string // Cert: the readable cert (PubDir); Key: the daemon's copy (PrivDir) ChildKey string // the key the child serves with (a copy in Dir when dropping; "" = Key) Manifest string State string // installing | running | missing-python | failed Detail string Version string // artifact version the instance runs Text string // the manifest body it was signed from — a change restarts it Serving bool // ensuring is true from the moment faEnsureStore hands off to its goroutine // until that goroutine returns. State is NOT a substitute: a RUNNING // instance keeps State "running" while it re-fetches, so a grant re-sync // arriving mid-fetch started a second concurrent ensure for the same store // — two downloads, two identity/manifest passes, two restarts of the same // children. The window is a fetch, and this change made a fetch longer by // trying a second manifest address. ensuring bool owner *faOwner // who the children run as (freeagent_drop.go); nil until resolved cancel context.CancelFunc } // faSnapshot copies the fields a reader needs, under the lock. func faSnapshot(dcnDir string, storeID int64) (faInstance, bool) { faMgr.Lock() defer faMgr.Unlock() inst, ok := faMgr.inst[faKey{dcnDir, storeID}] if !ok { return faInstance{}, false } return *inst, true } // faKey is one pairing's store: store ids are each Host's own, so the same id // from two Hosts is two stores. Keyed by store id alone, a machine paired with // two Hosts ran one agent for both, and a search from one Host for its store // was answered by the agent over the other Host's folder. type faKey struct { Dir string // the pairing's DCN state dir (Config.dcnDir) ID int64 } var faMgr = struct { sync.Mutex inst map[faKey]*faInstance }{inst: map[faKey]*faInstance{}} // ── locations ─────────────────────────────────────────────────────────────── // faBaseDir holds the runtime (.pyz + version). A root install keeps it under // the traversable state base so a dropped child can read it (freeagent_drop.go); // a non-root install keeps it in configDir. func faBaseDir() (string, error) { if base := faStateBase(); base != "" { p := filepath.Join(base, "runtime") return p, faMkdirAllMode(p, 0o755) } d, err := configDir() if err != nil { return "", err } p := filepath.Join(d, "freeagent") return p, os.MkdirAll(p, 0o700) } // faStoreDir is the store's private state (identity, vault, port, logs). On a // root install it is //: every ancestor 0755 root, the // leaf 0700 and — once the owner is resolved — owned by the store's uid, so // the dropped child can reach its own state without configDir being opened. // A non-root install keeps /freeagent/. func faStoreDir(dcnDir string, storeID int64) (string, error) { dir, _, _, err := faStoreDirs(dcnDir, storeID) return dir, err } // ── python ────────────────────────────────────────────────────────────────── // faPython finds a Python >= 3.8. Windows first tries the launcher (`py -3`), // because the Store's `python3` alias is a stub that opens the Store and // exits non-zero — the version check below rejects it like any other miss. func faPython() (string, []string, error) { var candidates [][]string if runtime.GOOS == "windows" { candidates = [][]string{{"py", "-3"}, {"python"}, {"python3"}} } else { candidates = [][]string{{"python3"}, {"python"}} } for _, c := range candidates { exe, err := exec.LookPath(c[0]) if err != nil { continue } args := append(append([]string{}, c[1:]...), "-c", "import sys; print('%d.%d' % sys.version_info[:2])") ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) probe := exec.CommandContext(ctx, exe, args...) hideConsole(probe) // console_windows.go out, err := probe.Output() cancel() if err != nil { continue } parts := strings.SplitN(strings.TrimSpace(string(out)), ".", 2) if len(parts) != 2 { continue } maj, _ := strconv.Atoi(parts[0]) min, _ := strconv.Atoi(parts[1]) if maj > 3 || (maj == 3 && min >= 8) { return exe, c[1:], nil } } return "", nil, errors.New("no Python >= 3.8 on PATH") } // faSignManifest signs the agent's manifest with the key this bastion minted // (faEnsureIdentity), in Go: the agent prints the exact bytes its gate checks // (`payload`), the bastion signs them, and the agent records the signature // (`sign --value`). The agent's gate verifies in its own standard library, so // no step needs openssl — which Windows does not ship: every Windows agent // used to stop at `missing-openssl` and its store was never indexed. func faSignManifest(python string, pyArgs []string, pyz, staged, keyPath, certPath string, env []string) error { run := func(args ...string) ([]byte, []byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() cmd := exec.CommandContext(ctx, python, append(append([]string{}, pyArgs...), args...)...) cmd.Env = env hideConsole(cmd) // console_windows.go var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr err := cmd.Run() return stdout.Bytes(), stderr.Bytes(), err } payload, errOut, err := run(pyz, "payload", staged) if err != nil { // The agent's own words when it said any; the exec/timeout error always. return fmt.Errorf("payload: %v: %s", err, strings.TrimSpace(string(errOut))) } kb, err := os.ReadFile(keyPath) if err != nil { return fmt.Errorf("key: %w", err) } blk, _ := pem.Decode(kb) if blk == nil || blk.Type != "EC PRIVATE KEY" { return errors.New("key: not a PEM EC private key") } key, err := x509.ParseECPrivateKey(blk.Bytes) if err != nil { return fmt.Errorf("key: %w", err) } sum := sha256.Sum256(payload) sig, err := ecdsa.SignASN1(rand.Reader, key, sum[:]) if err != nil { return fmt.Errorf("sign: %w", err) } out, errOut, err := run(pyz, "sign", staged, "--value", base64.StdEncoding.EncodeToString(sig), "--cert", certPath) if err != nil { return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)+string(errOut))) } return nil } // ── the artifact ──────────────────────────────────────────────────────────── type faPublished struct { Version string `json:"version"` Files map[string]struct { URL string `json:"url"` SHA256 string `json:"sha256"` } `json:"files"` } // faMinAgentVersion: the oldest agent that signs its manifest the way // faSignManifest drives it (`payload` + `sign --value`). const faMinAgentVersion = "0.1.11" // faVersionLess compares dotted numeric versions ("0.1.9" < "0.1.11"). An // unparseable or empty version counts as older than any real one. func faVersionLess(a, b string) bool { pa, pb := strings.Split(strings.TrimSpace(a), "."), strings.Split(strings.TrimSpace(b), ".") for i := 0; i < len(pa) || i < len(pb); i++ { var x, y int if i < len(pa) { n, err := strconv.Atoi(pa[i]) if err != nil { return true } x = n } if i < len(pb) { y, _ = strconv.Atoi(pb[i]) } if x != y { return x < y } } return false } // faEnsurePyz downloads the published local agent when the local copy is // absent or a different version; sha256-verified before it is written. // Returns the pyz path and its version. func faEnsurePyz() (string, string, error) { base, err := faBaseDir() if err != nil { return "", "", err } pyz := filepath.Join(base, "freeagent.pyz") verFile := filepath.Join(base, "freeagent.version") _, _, pub, err := faFetchManifest() if err != nil { // offline, or nothing served anything usable: a previously installed // copy still serves. if _, e := os.Stat(pyz); e == nil { v, _ := os.ReadFile(verFile) return pyz, strings.TrimSpace(string(v)), nil } return "", "", fmt.Errorf("agent manifest: %w", err) } if v, _ := os.ReadFile(verFile); strings.TrimSpace(string(v)) == pub.Version { if _, e := os.Stat(pyz); e == nil { return pyz, pub.Version, nil } } // Never step backwards. A signature proves a manifest was once published, // not that it is the current one, so a replayed older manifest would // otherwise reinstall an agent with bugs already fixed. The publish script // refuses to publish a version that is not newer, so the real channel only // moves forward and this refuses nothing it sends. if v, _ := os.ReadFile(verFile); len(v) > 0 && faVersionLess(pub.Version, string(v)) { if _, e := os.Stat(pyz); e == nil { return pyz, strings.TrimSpace(string(v)), nil } } // Only the new artifact name. The legacy freeagent-.pyz entry is for // bastions older than 0.20.0, which build that name themselves and read it // from the legacy address. name := "bastion-agent-" + pub.Version + ".pyz" f, ok := pub.Files[name] if !ok { return "", "", fmt.Errorf("agent manifest: no entry for %s", name) } data, err := download(f.URL) if err != nil { return "", "", err } if sum := sha256.Sum256(data); hex.EncodeToString(sum[:]) != strings.ToLower(f.SHA256) { return "", "", errors.New("freeagent download: sha256 mismatch — refusing to install") } if err := atomicWrite(pyz, data); err != nil { return "", "", err } if err := atomicWrite(verFile, []byte(pub.Version+"\n")); err != nil { return "", "", err } if faStateBase() != "" { // atomicWrite makes 0600; a dropped child must read the runtime. It is // the published artifact — world-readable costs nothing. _ = os.Chmod(pyz, 0o644) _ = os.Chmod(verFile, 0o644) if err := faShareRuntime(filepath.Dir(pyz)); err != nil { return "", "", fmt.Errorf("the runtime cannot be shared with the agent's account: %w", err) } } return pyz, pub.Version, nil } // ── identity ──────────────────────────────────────────────────────────────── // faEnsureIdentity mints the store's local agent identity once: P-256 key and a // self-signed cert with CN = cn (the manifest's owner.host), 10 years. Used // as TLS server cert, CA root and manifest signing key — the bastion is the // only party on either end. // // minted reports whether this call created the identity — a manifest signed // under an earlier one must then be re-signed. func faEnsureIdentity(dir, cn string) (certPath, keyPath string, minted bool, err error) { certPath = filepath.Join(dir, "host.crt") keyPath = filepath.Join(dir, "host.key") if _, err := os.Stat(certPath); err == nil { if _, err := os.Stat(keyPath); err == nil { return certPath, keyPath, false, nil } } key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return "", "", false, err } serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 100)) tpl := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{CommonName: cn}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, BasicConstraintsValid: true, IsCA: true, // it is its own root (FREEAGENT_CA_ROOT) DNSNames: []string{cn, "localhost"}, } der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key) if err != nil { return "", "", false, err } kder, err := x509.MarshalECPrivateKey(key) if err != nil { return "", "", false, err } if err := atomicWrite(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kder})); err != nil { return "", "", false, err } _ = os.Chmod(keyPath, 0o600) if err := atomicWrite(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})); err != nil { return "", "", false, err } return certPath, keyPath, true, nil } // ── embedding model on this box ───────────────────────────────────────────── // faEmbedModel picks the local ollama's embedding model (first tag naming // "embed") and learns its dimension by embedding a probe. ("", 0) when the // box has none: the manifest then stops at catalog and search is refused // with a reason the host can show. func faEmbedModel() (string, int) { cl := &http.Client{Timeout: 30 * time.Second} resp, err := cl.Get("http://" + faOllamaAddr + "/api/tags") if err != nil { return "", 0 } defer resp.Body.Close() var tags struct { Models []struct { Name string `json:"name"` } `json:"models"` } if json.NewDecoder(resp.Body).Decode(&tags) != nil { return "", 0 } model := "" for _, m := range tags.Models { if strings.Contains(strings.ToLower(m.Name), "embed") { model = m.Name break } } if model == "" { return "", 0 } body, _ := json.Marshal(map[string]any{"model": model, "input": []string{faEmbedProbeText}}) r2, err := cl.Post("http://"+faOllamaAddr+"/api/embed", "application/json", bytes.NewReader(body)) if err != nil { return "", 0 } defer r2.Body.Close() var out struct { Embeddings [][]float64 `json:"embeddings"` } if json.NewDecoder(r2.Body).Decode(&out) != nil || len(out.Embeddings) == 0 { return "", 0 } return model, len(out.Embeddings[0]) } // ── the manifest ──────────────────────────────────────────────────────────── func yamlStr(s string) string { b, _ := json.Marshal(s) // a JSON string is a valid YAML scalar return string(b) } // faManifestText renders the local agent manifest from the grant. mask is the // grant's mask restricted to what a local agent understands; stages depend on // what the box can do (embedding model → chunk+embed; recipient → seal). func faManifestText(id, cn string, terms *dcnTerms, sweep, recipient, embedModel string, embedDim int) string { mask := []string{"read"} for _, m := range terms.Mask { if (m == "search" || m == "index") && !containsStr(mask, m) { mask = append(mask, m) } } stages := []string{"walk", "extract", "sanitize"} canIndex := containsStr(mask, "index") if canIndex && embedModel != "" && embedDim > 0 { stages = append(stages, "chunk", "embed") } if canIndex { stages = append(stages, "catalog") if recipient != "" { stages = append(stages, "seal") } } sw := "hourly" switch sweep { case "15m", "hourly", "daily", "manual": sw = sweep } var b strings.Builder fmt.Fprintf(&b, "# Written by the bastion from signed store grant %s — do not edit; the grant is the source.\n", id) fmt.Fprintf(&b, "freeagent: 1\nid: %s\nowner:\n host: %s\n", yamlStr(id), yamlStr(cn)) fmt.Fprintf(&b, "scope:\n roots: [%s]\n", yamlStr(terms.Root)) if len(terms.Excludes) > 0 { var ex []string for _, e := range terms.Excludes { ex = append(ex, yamlStr(e)) } fmt.Fprintf(&b, " exclude: [%s]\n", strings.Join(ex, ", ")) } fmt.Fprintf(&b, "mask: [%s]\nstages: [%s]\n", strings.Join(mask, ", "), strings.Join(stages, ", ")) fmt.Fprintf(&b, "sanitize:\n mode: %s\n", faManagedSanitizeMode) if containsStr(stages, "embed") { fmt.Fprintf(&b, "embed:\n model: %s\n dim: %d\n chunk: {size_tokens: 512, overlap_tokens: 64}\n", yamlStr(embedModel), embedDim) } fmt.Fprintf(&b, "sweep: %s\n", sw) catalogSink := "local" if containsStr(stages, "seal") { catalogSink = "host" } fmt.Fprintf(&b, "sink:\n catalog: %s\n vectors: local\n plaintext: never\n", catalogSink) return b.String() } // ── supervision ───────────────────────────────────────────────────────────── // faChildEnv is an ALLOWLIST, not the daemon's environment: the platform // bearer and anything else the daemon might carry never reach a child or // the external tools the extract stage spawns from it. func faChildEnv(inst *faInstance) []string { env := []string{ "FREEAGENT_CA_ROOT=" + inst.Cert, "FREEAGENT_TLS_CERT=" + inst.Cert, "FREEAGENT_TLS_KEY=" + firstNonEmpty(inst.ChildKey, inst.Key), // the vault holds plaintext PII values: keep it in the bastion's state // dir, never inside the folder (which may be cloud-synced) "FREEAGENT_VAULT_DIR=" + inst.Dir, "EMBED_URL=http://" + faOllamaAddr, "PYTHONUNBUFFERED=1", "PYTHONIOENCODING=utf-8", } passthrough := []string{"PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TEMP", "TMP", "SystemRoot", "SYSTEMROOT", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA", "ComSpec", "PATHEXT", "XDG_CONFIG_HOME"} if inst.owner != nil && inst.owner.drop { // A dropped child is told who it is and keeps only the daemon's // variables that are not about the daemon's own account (faDropEnv, // per platform): its home is the owner's, or the owner's state dir when // the account has none. set, keep := faDropEnv(inst.owner, inst.Dir) env = append(env, set...) passthrough = keep } for _, k := range passthrough { if v := os.Getenv(k); v != "" { env = append(env, k+"="+v) } } if r := faRecipientOf(inst); r != "" { env = append(env, "FREEAGENT_OWNER_RECIPIENT="+r) } return env } // faOwnChildren holds the pids of the local agent children this daemon is // running right now, so the agent OBSERVE plane can exclude them. // // Without this, every box with a managed local agent reports its own component // as an unsanctioned agent on every heartbeat: the sweep is `python3 // freeagent.pyz ...` talking to the local embedder, which is precisely the // shape of the "interpreter with a connection to an inference port" the // network signal now looks for. The user would be alerted, forever, about the // thing they approved. var faOwnChildren = struct { sync.Mutex pid map[int]bool }{pid: map[int]bool{}} func faTrackPID(pid int) { if pid <= 0 { return } faOwnChildren.Lock() faOwnChildren.pid[pid] = true faOwnChildren.Unlock() } func faUntrackPID(pid int) { faOwnChildren.Lock() delete(faOwnChildren.pid, pid) faOwnChildren.Unlock() } // faIsOwnChild reports whether a pid is a local agent this daemon started. func faIsOwnChild(pid int) bool { faOwnChildren.Lock() defer faOwnChildren.Unlock() return faOwnChildren.pid[pid] } // faRunOnce runs one child to completion and returns its error. // Start+Wait rather than Run (which is exactly those two) so the child's pid is // known while it lives and the observe plane can exclude it. func faRunOnce(ctx context.Context, inst *faInstance, logName, python string, pyArgs []string, args ...string) error { faMgr.Lock() owner := inst.owner faMgr.Unlock() // The log is opened INSIDE the child's dir by os.Root and checked to be a // plain single-link file before the child gets the fd: a link // planted by the child must never become root's append handle. A refusal // is an error the supervisor reports and retries; it never starts a child // on a bad handle. lf, err := faOpenLog(inst.Dir, logName, owner) if err != nil { return err } defer lf.Close() cmd := exec.CommandContext(ctx, python, append(append([]string{}, pyArgs...), args...)...) cmd.Dir = inst.Dir cmd.Env = faChildEnv(inst) // The drop (freeagent_drop.go): the three functional children run as the // granted folder's owner on a root install; nil = the daemon's own user. cmd.SysProcAttr = owner.sysProcAttr() // AFTER the drop, never before: sysProcAttr() returns nil on Windows // (freeagent_drop_windows.go), so setting the flags first would assign a // struct and then have it replaced by that nil. console_windows.go. hideConsole(cmd) cmd.Stdout, cmd.Stderr = lf, lf // faStartChild: an ordinary start, except on Windows with a drop, where the // child is created under the owner's own token (freeagent_drop_windows.go). child, err := faStartChild(ctx, cmd, owner) if err != nil { return err } pid := child.Pid() faTrackPID(pid) defer faUntrackPID(pid) return child.Wait() } // faRunChild supervises a long-running child (serve; a cadenced sweep): // restart after faRestartBackoff whenever it exits while the instance is // alive. A `manual` sweep is NOT supervised — it exits after one run by // contract, and re-running it every backoff would be an IO storm against a // term the user signed. func faRunChild(ctx context.Context, inst *faInstance, logName string, python string, pyArgs []string, args ...string) { for ctx.Err() == nil { err := faRunOnce(ctx, inst, logName, python, pyArgs, args...) if ctx.Err() != nil { return } faMgr.Lock() inst.Detail = fmt.Sprintf("%s exited: %v; restarting in %s", logName, err, faRestartBackoff) faMgr.Unlock() select { case <-ctx.Done(): return case <-time.After(faRestartBackoff): } } } // faRecipientOf reads the store's recipient from the ROOT-PRIVATE dir: it is // read by root and handed to the child as an env value, so it must never sit // where the child could replace it with a link to a root file. func faRecipientOf(inst *faInstance) string { b, err := os.ReadFile(filepath.Join(firstNonEmpty(inst.PrivDir, inst.Dir), "recipient")) if err != nil { return "" } return strings.TrimSpace(string(b)) } // faManagedConfig is the client config for a store's managed instance, or // false when none is running (port file absent). func faManagedConfig(dcnDir string, storeID int64) (freeagentConfig, bool) { inst, ok := faSnapshot(dcnDir, storeID) if !ok { return freeagentConfig{}, false } p, err := faReadIn(inst.Dir, "port") // written by the child; never follow a link out of its dir if err != nil { return freeagentConfig{}, false } port, err := strconv.Atoi(strings.TrimSpace(string(p))) if err != nil || port <= 0 { return freeagentConfig{}, false } return freeagentConfig{Addr: "127.0.0.1:" + strconv.Itoa(port), Cert: inst.Cert, Key: inst.Key, CA: inst.Cert, Mode: faManagedSanitizeMode}, true } // ── ensure / reconcile ────────────────────────────────────────────────────── // faEnsureStore installs (idempotently) and runs the local agent for one store. // Called after a successful grant sync and at daemon start for every synced // grant with 'search'. Never blocks the caller: the work runs in its own // goroutine and reports through the instance state. func faEnsureStore(dcnDir string, storeID int64, terms *dcnTerms, sweep, recipient string) { if !dcnHasMask(terms, "search") || terms.Root == "" { return } dir, priv, pub, err := faStoreDirs(dcnDir, storeID) if err != nil { return } cn := "store-" + strconv.FormatInt(storeID, 10) faMgr.Lock() key := faKey{dcnDir, storeID} inst, ok := faMgr.inst[key] if !ok { inst = &faInstance{StoreID: storeID, Dir: dir, PrivDir: priv, PubDir: pub, CN: cn, State: "installing"} faMgr.inst[key] = inst } else if inst.State == "installing" || inst.ensuring { faMgr.Unlock() return // one ensure at a time per store — see faInstance.ensuring } inst.ensuring = true faMgr.Unlock() go func() { // Cleared on EVERY exit from this goroutine, including the failure // returns below — a flag left set would wedge the store out of every // future ensure, which is worse than the double-ensure it prevents. defer func() { faMgr.Lock() inst.ensuring = false faMgr.Unlock() }() fail := func(state, detail string) { faMgr.Lock() inst.State, inst.Detail = state, detail faMgr.Unlock() } // Who the agent runs as — decided before anything is fetched or // written, and a refusal is the store's state (the card says why). owner, err := faResolveOwner(terms.Root, dir) if err != nil { fail("failed", err.Error()) return } faMgr.Lock() inst.owner = owner faMgr.Unlock() python, pyArgs, err := faPython() if err != nil { fail("missing-python", err.Error()) return } pyz, version, err := faEnsurePyz() if err != nil { fail("failed", err.Error()) return } // The manifest is signed through the agent's `payload` / `sign --value` // (faSignManifest), which an older agent does not have. A cached older // copy (the release manifest unreachable) or a release not yet // published is named as that, not left to fail as an unknown flag. if faVersionLess(version, faMinAgentVersion) { fail("failed", fmt.Sprintf("the installed agent is %s and this bastion needs %s or "+ "newer; it is fetched on the next retry once published", version, faMinAgentVersion)) return } // The identity is minted and kept in the root-private dir; the child // gets a copy of the key in its own dir and reads the cert from the // root-owned public dir — the path its manifest signature names, which // it can verify but never replace. cert, key, minted, err := faEnsureIdentity(priv, cn) if err != nil { fail("failed", "identity: "+err.Error()) return } if recipient != "" { _ = atomicWrite(filepath.Join(priv, "recipient"), []byte(recipient+"\n")) } // The state leaf is the child's from here: its path is root-owned all // the way down (faStoreDirs), so this by-path chown cannot be steered. if err := faOwn(owner, dir); err != nil { fail("failed", "state dir: "+err.Error()) return } // One handle per directory root writes into, verified to be the // owner's, held for the whole ensure (freeagent_drop.go faOpenOwned). rDir, err := faOpenOwned(dir, owner) if err != nil { fail("failed", "state dir: "+err.Error()) return } defer rDir.Close() childKey, childCert := key, cert if owner.drop { kb, err := os.ReadFile(key) if err == nil { err = faOwnedWrite(rDir, "host.key", kb, 0o600, owner) } if err != nil { fail("failed", "key copy: "+err.Error()) return } childKey = filepath.Join(dir, "host.key") rPub, err := os.OpenRoot(pub) if err != nil { fail("failed", "cert publish: "+err.Error()) return } cb, err := os.ReadFile(cert) if err == nil { err = faOwnedWrite(rPub, "host.crt", cb, 0o644, nil) } rPub.Close() if err != nil { fail("failed", "cert publish: "+err.Error()) return } if err := faSharePub(pub, owner); err != nil { fail("failed", "cert publish: "+err.Error()) return } childCert = filepath.Join(pub, "host.crt") // vault, port, logs, key copy: the child's, re-applied every ensure if err := faOwnTree(rDir, ".", owner); err != nil { fail("failed", "state dir ownership: "+err.Error()) return } } model, dim := faEmbedModel() text := faManifestText(cn, cn, terms, sweep, recipient, model, dim) // files-and-folders: the agent lives in /.agent (manifest + // outputs); the walker always excludes it. Secrets stay in inst.Dir. // Created and written by name inside the folder (os.Root): a `.agent` // the child replaced with a link out of the folder is refused. // agentRoot/agentRel address the agent dir INSIDE its parent's handle: // the folder's for /.agent, the state leaf's for the fallback. agentRoot, agentRel, agentParent := rDir, "out", dir agentNote := "" var rFolder *os.Root inFolder := faAgentDirInFolder(owner) if inFolder { rFolder, err = faOpenOwned(terms.Root, owner) if err == nil { err = faOwnedMkdir(rFolder, ".agent", owner) } } else { // Not a fault in the folder: on this platform the bastion writes // nothing into it (faAgentDirInFolder), and the card says so plainly. err = errors.New(faAgentKeptInStateNote) } if err == nil { defer rFolder.Close() agentRoot, agentRel, agentParent = rFolder, ".agent", terms.Root // what a member may commit from .agent is the DEFINITION, never the // stores (the extract store is plaintext text of every file) _ = faOwnedWrite(rFolder, ".agent/.gitignore", []byte("*\n!manifest.yaml\n!.gitignore\n"), 0o600, owner) // residue from a pre-0.13.3 install: outputs used to live in the // state dir (private); nothing reads them now _ = rDir.RemoveAll("out") _ = rDir.Remove("manifest.yaml") } else { // a read-only root (archive mount, read-only share) can still be // walked and searched: the agent then lives in the bastion's // state dir instead, and the store card says so if rFolder != nil { rFolder.Close() } agentNote = faAgentDirNote(inFolder, err) if err := faOwnedMkdir(rDir, "out", owner); err != nil { fail("failed", "agent dir: "+err.Error()) return } } agentDir := filepath.Join(agentParent, agentRel) manifestRel := agentRel + "/manifest.yaml" // Same manifest, same artifact, already running → nothing to do. A // re-approved grant with different terms (excludes, mask, sweep, a // recipient registered later) or a new artifact version restarts the // instance under the new manifest — the early return used to come // before this comparison. faMgr.Lock() unchanged := inst.State == "running" && inst.Text == text && inst.Version == version && inst.cancel != nil faMgr.Unlock() if unchanged { return } fail("installing", "") manifest := filepath.Join(agentDir, "manifest.yaml") old, _ := agentRoot.ReadFile(manifestRel) // Re-sign unless the body is unchanged AND its signature names the // cert the child will verify against: a fresh identity (new layout, // a wiped base, a re-pair) with a surviving manifest would otherwise // keep a signature the gate refuses. if minted || !bytes.HasPrefix(old, []byte(text)) || !bytes.Contains(old, []byte("cert: "+childCert+"\n")) { // `sign` runs root-side over a ROOT-PRIVATE staging copy — never // in place inside the folder, where the child could swap the file // for a link between the write and the sign. Only the // finished bytes are placed in .agent, by name, then handed over. staged := filepath.Join(priv, "manifest.yaml") if err := atomicWrite(staged, []byte(text)); err != nil { fail("failed", "manifest: "+err.Error()) return } if err := faSignManifest(python, pyArgs, pyz, staged, key, childCert, faChildEnv(&faInstance{Dir: priv, Cert: cert, Key: key})); err != nil { fail("failed", "sign: "+err.Error()) return } signed, err := os.ReadFile(staged) if err == nil { err = faOwnedWrite(agentRoot, manifestRel, signed, 0o600, owner) } if err != nil { fail("failed", "manifest place: "+err.Error()) return } } else if err := faOwnIn(agentRoot, manifestRel, owner); err != nil { fail("failed", "manifest ownership: "+err.Error()) return } // an .agent subtree left by an earlier root run (extract//) must be // the owner's too, or the child's os.replace into it fails if err := faOwnTree(agentRoot, agentRel, owner); err != nil { fail("failed", "agent dir ownership: "+err.Error()) return } faMgr.Lock() if inst.cancel != nil { inst.cancel() // stop the previous children before the new ones bind } inst.Cert, inst.Key, inst.ChildKey, inst.Manifest, inst.Version, inst.Text = childCert, key, childKey, manifest, version, text inst.AgentDir = agentDir inst.State, inst.Detail = "running", agentNote ctx, cancel := context.WithCancel(context.Background()) inst.cancel = cancel faMgr.Unlock() outDir := agentDir _ = os.Remove(filepath.Join(dir, "port")) if sweep == "manual" { go func() { _ = faRunOnce(ctx, inst, "sweep.log", python, pyArgs, pyz, "sweep", manifest, "--out", outDir, "--once") }() } else { go faRunChild(ctx, inst, "sweep.log", python, pyArgs, pyz, "sweep", manifest, "--out", outDir) } go faRunChild(ctx, inst, "serve.log", python, pyArgs, pyz, "serve", manifest, "--out", outDir, "--bind", "127.0.0.1:0", "--port-file", filepath.Join(dir, "port")) }() } // faReconcileLoop runs faReconcile at start and every faReconcileEvery, so a // box that lacked Python (or openssl, or was offline for the download) picks // the install up once the gap is fixed, and a changed artifact or recipient // is applied without a daemon restart. func faReconcileLoop(ctx context.Context, dcnDir string) { for { faReconcile(dcnDir) select { case <-ctx.Done(): return case <-time.After(faReconcileEvery): } } } // faReconcile brings up managed instances for every synced grant that carries // 'search' — at daemon start, so a restart never loses a running local agent. func faReconcile(dcnDir string) { if dcnDir == "" { return } f, err := dcnLoadFile(dcnDir) if err != nil || f.VerifyPubkey == "" { return } for id, rec := range f.Stores { terms, err := dcnVerifyRec(f.VerifyPubkey, rec) if err != nil { continue } sid, err := strconv.ParseInt(id, 10, 64) if err != nil { continue } faEnsureStore(dcnDir, sid, terms, sweepFromCanonical(rec.TermsCanonical), rec.SealRecipient) } } // sweepFromCanonical reads the optional `sweep` from the signed terms bytes. func sweepFromCanonical(termsB64 string) string { raw, err := base64.StdEncoding.DecodeString(termsB64) if err != nil { return "" } var t struct { Sweep string `json:"sweep"` } if json.Unmarshal(raw, &t) != nil { return "" } switch t.Sweep { case "15m", "hourly", "daily", "manual": return t.Sweep } return "" } // faManagedProbe reports (anyManaged, allServing) for ONE pairing's instances // and refreshes their Serving flags by probing each /health over mTLS. Another // pairing's agents are never counted: its health is not this Host's to hear, // and this Host reads the capability as "my stores here are served" when its // own freeagent_stores is empty (faManagedStatus). "" probes none. func faManagedProbe(dcnDir string) (bool, bool) { faMgr.Lock() keys := make([]faKey, 0, len(faMgr.inst)) for k := range faMgr.inst { if dcnDir != "" && k.Dir == dcnDir { keys = append(keys, k) } } faMgr.Unlock() if len(keys) == 0 { return false, false } all := true for _, k := range keys { serving := false if c, ok := faManagedConfig(k.Dir, k.ID); ok { status, body, err := freeagentCallTimeout(c, http.MethodGet, "/health", nil, freeagentProbeTimeout) if err == nil && status == http.StatusOK { var h struct { OK bool `json:"ok"` Serving bool `json:"serving"` } serving = json.Unmarshal(body, &h) == nil && h.OK && h.Serving } } faMgr.Lock() if inst, ok := faMgr.inst[k]; ok { inst.Serving = serving } faMgr.Unlock() if !serving { all = false } } return true, all } // faManagedStatus is one pairing's per-store detail for its heartbeat: only // its own stores, never another Host's. func faManagedStatus(dcnDir string) map[string]any { faMgr.Lock() defer faMgr.Unlock() out := map[string]any{} if dcnDir == "" { return out } for k, inst := range faMgr.inst { if k.Dir != dcnDir { continue } out[strconv.FormatInt(k.ID, 10)] = map[string]any{ "state": inst.State, "serving": inst.Serving, "version": inst.Version, "detail": inst.Detail, } } return out } // faStop tears down a store's managed instance (grant revoked / re-synced // without search). func faStop(dcnDir string, storeID int64) { faMgr.Lock() defer faMgr.Unlock() key := faKey{dcnDir, storeID} if inst, ok := faMgr.inst[key]; ok { if inst.cancel != nil { inst.cancel() } delete(faMgr.inst, key) } } func firstNonEmpty(a, b string) string { if a != "" { return a } return b }