| 1 | // The agent's half of D3 (spec 2026-10-06-console-release-defences-design.md |
| 2 | // §4.3, §4.5). |
| 3 | // |
| 4 | // agentProofGate is the one release.ProofGate this process passes to every |
| 5 | // pass it runs (adoptLoop, fastConvergeLoop, the cold-wake hook), so the miss |
| 6 | // cache, the waiting list fastMembers reads and the status line all agree. |
| 7 | // |
| 8 | // mintAsker runs on the proving box's fast loop. Minting needs the root-only |
| 9 | // key, and root passes run hourly or on request, so when a listed app's live |
| 10 | // build has run for the policy's after with no proof in the bucket, the asker |
| 11 | // requests a root pass through the stage-request relay. It asks again every |
| 12 | // mintAskEvery until it sees the proof (codex 5): the relay can miss a request |
| 13 | // written while carlos-stage.service is running, and a mint write can fail. |
| 14 | // It also leaves a pending note the first time it sees a build live, which is |
| 15 | // how `carlos proof get` can say how long the build has had. |
| 16 | // |
| 17 | // It cannot check everything a mint needs (it cannot read another unit's |
| 18 | // /proc/<pid>/exe), and need not: the root pass checks every condition again. |
| 19 | // When systemctl show is denied here, it asks anyway and root decides. |
| 20 | package host |
| 21 | |
| 22 | import ( |
| 23 | "context" |
| 24 | "errors" |
| 25 | "log" |
| 26 | "os" |
| 27 | "path/filepath" |
| 28 | "sync" |
| 29 | "time" |
| 30 | |
| 31 | "github.com/carlosframework/platform/internal/registry" |
| 32 | "github.com/carlosframework/platform/internal/release" |
| 33 | ) |
| 34 | |
| 35 | var ( |
| 36 | proofGateOnce sync.Once |
| 37 | proofGateInst *release.ProofGate |
| 38 | ) |
| 39 | |
| 40 | // agentProofGate reads root-observed healthy history. Its not-proven |
| 41 | // diagnostics stay in dataDir(), beside the agent pass's refusal records. |
| 42 | func agentProofGate() *release.ProofGate { |
| 43 | proofGateOnce.Do(func() { proofGateInst = release.NewRootProofGate(release.ProofPolicyPath, dataDir()) }) |
| 44 | return proofGateInst |
| 45 | } |
| 46 | |
| 47 | const mintAskEvery = 2 * time.Minute |
| 48 | |
| 49 | type mintAsker struct { |
| 50 | reg *registry.Registry |
| 51 | store release.Store |
| 52 | sys unitSystem |
| 53 | optDir string |
| 54 | policyPath string |
| 55 | request func(host, ver string, err error) // mintRequester in production |
| 56 | now func() time.Time |
| 57 | |
| 58 | last map[string]time.Time // host -> when it was last looked at |
| 59 | proven map[provenKey]bool // proofs seen in the bucket |
| 60 | pending map[string]string // host -> sha256 whose pending note is written |
| 61 | shaByPath map[string]hashedFile |
| 62 | } |
| 63 | |
| 64 | // hashedFile is a staged file's sha256 and the identity of the file it was |
| 65 | // taken from. A version directory can be pruned and its version name reused |
| 66 | // for other bytes at the same path, so a cached hash is used only while the |
| 67 | // file is still the same one: same size, mtime and, where the platform says, |
| 68 | // device and inode (final review H5). |
| 69 | type hashedFile struct { |
| 70 | size int64 |
| 71 | mtime int64 |
| 72 | dev, ino uint64 |
| 73 | sha string |
| 74 | } |
| 75 | |
| 76 | func identify(fi os.FileInfo) hashedFile { |
| 77 | dev, ino, _ := fileInode(fi) |
| 78 | return hashedFile{size: fi.Size(), mtime: fi.ModTime().UnixNano(), dev: dev, ino: ino} |
| 79 | } |
| 80 | |
| 81 | func (h hashedFile) sameFile(o hashedFile) bool { |
| 82 | return h.size == o.size && h.mtime == o.mtime && h.dev == o.dev && h.ino == o.ino |
| 83 | } |
| 84 | |
| 85 | // provenKey is one proof object: a sha256 the bucket holds a proof for, |
| 86 | // under one account, app and prover. A host is not part of it, and neither |
| 87 | // a change of prover nor of account can reuse another's answer. |
| 88 | type provenKey struct{ acct, app, sha, prover string } |
| 89 | |
| 90 | // stagedSHA is target's sha256, hashed again whenever the file is not the |
| 91 | // one the cached hash came from. |
| 92 | func (a *mintAsker) stagedSHA(target string) (string, error) { |
| 93 | fi, err := os.Stat(target) |
| 94 | if err != nil { |
| 95 | return "", err |
| 96 | } |
| 97 | id := identify(fi) |
| 98 | if h, ok := a.shaByPath[target]; ok && h.sameFile(id) { |
| 99 | return h.sha, nil |
| 100 | } |
| 101 | sha, err := release.FileSHA256(target) |
| 102 | if err != nil { |
| 103 | return "", err |
| 104 | } |
| 105 | // The file can change between the stat and the read; a hash taken across |
| 106 | // that change is used once and not cached. |
| 107 | if fi2, err := os.Stat(target); err == nil && identify(fi2).sameFile(id) { |
| 108 | id.sha = sha |
| 109 | a.shaByPath[target] = id |
| 110 | } |
| 111 | return sha, nil |
| 112 | } |
| 113 | |
| 114 | func (a *mintAsker) tick(ctx context.Context) { |
| 115 | // A policy file that does not parse is closed (Task 2's carry): root |
| 116 | // mints nothing until it parses (proofroot.go says so on every pass, and |
| 117 | // the gate once a minute), so there is nothing to ask root for. An |
| 118 | // absent file, or one with no prove block, is a box that proves nothing. |
| 119 | pol, err := release.LoadProofPolicy(a.policyPath) |
| 120 | if err != nil || pol == nil || pol.Prove == nil { |
| 121 | return |
| 122 | } |
| 123 | routes, err := a.reg.List() |
| 124 | if err != nil { |
| 125 | return |
| 126 | } |
| 127 | if a.last == nil { |
| 128 | a.last, a.proven, a.pending, a.shaByPath = map[string]time.Time{}, map[provenKey]bool{}, map[string]string{}, map[string]hashedFile{} |
| 129 | } |
| 130 | now := a.now() |
| 131 | after := pol.ProveAfter() |
| 132 | prover := pol.Prove.Prover |
| 133 | for _, rt := range routes { |
| 134 | if !release.Adoptable(rt) || rt.Unit == "" || !pol.Proves(rt.App) { |
| 135 | continue |
| 136 | } |
| 137 | if last, ok := a.last[rt.Host]; ok && now.Sub(last) < mintAskEvery { |
| 138 | continue |
| 139 | } |
| 140 | a.last[rt.Host] = now |
| 141 | target, err := filepath.EvalSymlinks(release.LiveLink(a.optDir, rt.Host)) |
| 142 | if err != nil { |
| 143 | continue |
| 144 | } |
| 145 | sha, err := a.stagedSHA(target) |
| 146 | if err != nil { |
| 147 | continue |
| 148 | } |
| 149 | acct := release.RouteAccount(rt) |
| 150 | pk := provenKey{acct, rt.App, sha, prover} |
| 151 | if a.proven[pk] { |
| 152 | continue |
| 153 | } |
| 154 | ver, _ := release.LiveVersion(a.optDir, rt.Host) |
| 155 | if p, err := a.sys.Show(rt.Unit); err == nil { |
| 156 | if p.ActiveState != "active" { |
| 157 | continue |
| 158 | } |
| 159 | if !p.ActiveEnterTimestamp.IsZero() { |
| 160 | if a.pending[rt.Host] != sha { |
| 161 | if err := release.PutProofPending(ctx, a.store, acct, release.ProofPending{App: rt.App, |
| 162 | Platform: release.AdoptPlatform(), SHA256: sha, Version: ver, Prover: prover, |
| 163 | LiveSince: p.ActiveEnterTimestamp.UTC(), After: pol.Prove.After}); err != nil { |
| 164 | log.Printf("agent: %s: writing the proof pending note: %v", rt.Host, err) |
| 165 | } else { |
| 166 | a.pending[rt.Host] = sha |
| 167 | } |
| 168 | } |
| 169 | if now.Sub(p.ActiveEnterTimestamp) < after { |
| 170 | continue |
| 171 | } |
| 172 | } |
| 173 | } |
| 174 | _, err = release.GetProof(ctx, a.store, acct, rt.App, sha, prover) |
| 175 | switch { |
| 176 | case err == nil: |
| 177 | a.proven[pk] = true |
| 178 | continue |
| 179 | case !errors.Is(err, os.ErrNotExist): |
| 180 | log.Printf("agent: %s: reading the bucket for %s's proof: %v", rt.Host, ver, err) |
| 181 | continue |
| 182 | } |
| 183 | log.Printf("agent: %s has run %s for %s with no proof in the bucket; asking for a root pass to mint one", rt.Host, ver, after) |
| 184 | a.request(rt.Host, ver, nil) |
| 185 | } |
| 186 | } |
| 187 | |