Files
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

1088 lines
39 KiB
Go

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 // <root>/.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 <base>/<pairing>/<storeID>: 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 <dcnDir>/freeagent/<storeID>.
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-<v>.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 <root>/.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 <root>/.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/<xx>/) 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
}