| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net" |
| 6 | "net/http" |
| 7 | ) |
| 8 | |
| 9 | // ControlListener is an instance's local-only control channel: the socket |
| 10 | // the agent binds beside the instance's own, through which the instance |
| 11 | // talks back to the platform. It began as the purge channel alone — see |
| 12 | // docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md §3-4 — |
| 13 | // and generalized to carry the scheduled-work verbs too |
| 14 | // (2026-08-23-scheduled-work-design.md §7.2), which is why it now exists |
| 15 | // for every exec-backed instance rather than only for boxes with a cache. |
| 16 | // |
| 17 | // It is deliberately NOT reachable over the network: an externally |
| 18 | // reachable purge endpoint would let anyone manufacture wake storms by |
| 19 | // force-purging popular pages, exactly what this feature exists to |
| 20 | // prevent — and an externally reachable schedule endpoint would hand the |
| 21 | // same anyone a timer that wakes an instance on demand. |
| 22 | type ControlListener struct { |
| 23 | ln net.Listener |
| 24 | srv *http.Server |
| 25 | } |
| 26 | |
| 27 | // PurgeListener is what this was called when purge was the only verb. |
| 28 | // Retained as an alias so callers and tests that predate the generalisation |
| 29 | // keep compiling. |
| 30 | type PurgeListener = ControlListener |
| 31 | |
| 32 | // purgeRequest is the body of POST /purge: one exact URL, or {"path":"*"} |
| 33 | // for every cached entry of the listener's host — the socket-channel twin of |
| 34 | // a Carlos-Purge: * header (amadan carlos/platform#173). Without it an exec |
| 35 | // instance had to enumerate its own paths to do what the header channel does |
| 36 | // in one token. |
| 37 | // |
| 38 | // "Whole host" means the one host the listener was built for. Entries cached |
| 39 | // under that route's aliases or custom domains are keyed by those names, and |
| 40 | // this channel cannot name them; an app that needs them uses the header |
| 41 | // channel's Carlos-Purge-Host list. |
| 42 | // |
| 43 | // "*" is a whole-host purge only on its own. With a query beside it the body |
| 44 | // names nothing a request could be stored under, so it is refused rather than |
| 45 | // guessed at: widening a malformed exact purge to the whole host is not a |
| 46 | // reading the caller asked for. |
| 47 | type purgeRequest struct { |
| 48 | Path string `json:"path"` |
| 49 | Query string `json:"query"` |
| 50 | } |
| 51 | |
| 52 | // purgeAllPath is the purgeRequest.Path that asks for the whole host. |
| 53 | const purgeAllPath = "*" |
| 54 | |
| 55 | // ListenControl binds socketPath (removing any stale file left behind by a |
| 56 | // crashed previous instance) and serves this host's control verbs on it. |
| 57 | // POST /purge is registered only when cache is non-nil — a box with no edge |
| 58 | // cache has nothing to purge, and a route that answered /purge with no cache |
| 59 | // behind it would be a lie. mount, when non-nil, adds the caller's own verbs |
| 60 | // to the same mux (the scheduled-work ones, schedule.Mount). |
| 61 | // |
| 62 | // What the host scoping actually guarantees: host is fixed here, at |
| 63 | // construction, and never taken from the request — purgeRequest carries |
| 64 | // only Path and Query, never a host — so no request body, however |
| 65 | // malicious or malformed, can make this listener purge any host other than |
| 66 | // the one it was built for. That closes a confused-deputy purge: an |
| 67 | // attacker (or a bug) cannot use one instance's control channel to reach |
| 68 | // into another host's cache entries THROUGH this listener. schedule.Mount |
| 69 | // takes host by the same discipline, for the same reason. |
| 70 | // |
| 71 | // What it does NOT guarantee: that only the instance this listener was |
| 72 | // created for can ever reach it. Every exec-backed instance on a box runs |
| 73 | // as the SAME uid as the agent itself (they're its children, spawned by |
| 74 | // plain exec.Command with no uid change — see backend_exec.go), and the |
| 75 | // control socket path is deterministic (rt.Socket + ".control" — |
| 76 | // backend_exec.go), so a compromised or buggy exec-backed sibling can |
| 77 | // compute another host's control socket path and dial it directly, then |
| 78 | // issue it a purge for ITS host (never a third host — the guarantee above |
| 79 | // still holds inside that listener). The 0600 socket mode (see the umask |
| 80 | // dance below) keeps other UNIX users on the box out; it does nothing |
| 81 | // against a same-uid sibling, because same-uid processes can always open |
| 82 | // each other's 0600 sockets. Worst case from that gap: a misbehaving |
| 83 | // instance forces a sibling to re-fetch from ITS OWN backend on its next |
| 84 | // request — bounded by the same TTL ceiling (DefaultCacheMaxTTL) that |
| 85 | // already caps a lost purge, and it can never expose one host's cached |
| 86 | // response body to another, since each listener only ever touches the one |
| 87 | // host's cache keys it was built for. |
| 88 | // |
| 89 | // Purge therefore stays UNAUTHENTICATED, deliberately: every app purging |
| 90 | // today does it with a bare POST, an auth requirement would break all of |
| 91 | // them at once, and the blast radius above is bounded to a re-fetch. The |
| 92 | // verbs mount adds do NOT inherit that: schedule.Mount bearer-gates every |
| 93 | // one of them on the host's own CARLOS_ADMIN_TOKEN. |
| 94 | // |
| 95 | // Be precise about what that bearer buys, because the obvious reading is |
| 96 | // wrong. It closes the channel against OTHER UNIX users on the box (who the |
| 97 | // 0600 socket mode already stops) and against any unauthenticated dialer: |
| 98 | // reaching the socket is no longer enough, you must also present a secret. |
| 99 | // It does NOT close the same-uid sibling gap described above. The token |
| 100 | // lives in <DataDir>/<host>.env, mode 0600 — and 0600 means OWNER-only, with |
| 101 | // the owner being the one uid every exec child shares with the agent (no |
| 102 | // SysProcAttr.Credential is ever set, and the data dir is that same user's). |
| 103 | // A misbehaving exec-backed app can therefore read another host's env file, |
| 104 | // take its token, and drive that host's schedule verbs. The gap is narrowed, |
| 105 | // not closed. |
| 106 | // |
| 107 | // What bounds the residual: a sibling holding host B's token could ALREADY |
| 108 | // dial B's own instance socket directly and send it any request it liked |
| 109 | // with that token. The schedule verbs add REACH — a platform-delivered wake |
| 110 | // plus a POST at a time and path of the attacker's choosing — but no |
| 111 | // privilege a same-uid sibling did not already have. The real fix is |
| 112 | // per-instance uids, which is the unit-backed/DynamicUser story below, and |
| 113 | // it is out of scope for this channel. |
| 114 | // |
| 115 | // Unit-backed tenants (carlos-app@<host>.service, backend_unit.go) do NOT |
| 116 | // share this gap, or any control socket at all, because they don't share a |
| 117 | // uid with anything: DynamicUser=yes mints a fresh, unpredictable uid on |
| 118 | // every unit start, with no SupplementaryGroups — never the agent's uid, |
| 119 | // never another tenant's. That's the opposite problem from the one above: |
| 120 | // a DynamicUser tenant can't reach a same-uid sibling's control socket |
| 121 | // because it can't reach ANY control socket, including one built for |
| 122 | // itself. Unit-backed tenants have no control listener wired up yet as a |
| 123 | // result (see backend_unit.go); their cached routes rely on the TTL |
| 124 | // ceiling alone, and their once-timers go through the console rather than |
| 125 | // the socket, until that gets its own design. |
| 126 | func ListenControl(socketPath, host string, cache *Cache, mount func(*http.ServeMux)) (*ControlListener, error) { |
| 127 | ln, err := SecureUnixListen(socketPath) |
| 128 | if err != nil { |
| 129 | return nil, err |
| 130 | } |
| 131 | |
| 132 | mux := http.NewServeMux() |
| 133 | if cache != nil { |
| 134 | mux.HandleFunc("POST /purge", func(w http.ResponseWriter, r *http.Request) { |
| 135 | var req purgeRequest |
| 136 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| 137 | http.Error(w, "bad request", http.StatusBadRequest) |
| 138 | return |
| 139 | } |
| 140 | if req.Path == purgeAllPath { |
| 141 | if req.Query != "" { |
| 142 | http.Error(w, "bad request: path \"*\" takes no query", http.StatusBadRequest) |
| 143 | return |
| 144 | } |
| 145 | // host is still the one this listener was built for: "*" widens |
| 146 | // the purge to every path of that host, never to another host. |
| 147 | cache.PurgeHost(host) |
| 148 | w.WriteHeader(http.StatusOK) |
| 149 | return |
| 150 | } |
| 151 | cache.PurgeURL(host, req.Path, req.Query) |
| 152 | w.WriteHeader(http.StatusOK) |
| 153 | }) |
| 154 | } |
| 155 | if mount != nil { |
| 156 | mount(mux) |
| 157 | } |
| 158 | |
| 159 | srv := &http.Server{Handler: mux} |
| 160 | p := &ControlListener{ln: ln, srv: srv} |
| 161 | // Close (below) closes p.ln directly, not through srv — so the error |
| 162 | // Serve returns here is whatever a closed-listener Accept produces, not |
| 163 | // necessarily http.ErrServerClosed. Either way it's expected and unused. |
| 164 | go srv.Serve(ln) //nolint:errcheck |
| 165 | return p, nil |
| 166 | } |
| 167 | |
| 168 | // ListenPurge is ListenControl with mount left nil — the shape this had |
| 169 | // before the channel generalized, kept for callers that only want the |
| 170 | // purge verb and none of the scheduled-work ones. It is not a promise that |
| 171 | // /purge itself is registered: ListenControl only mounts it when cache is |
| 172 | // non-nil, so ListenPurge(socketPath, host, nil) serves nothing at all, |
| 173 | // same as ListenControl would. |
| 174 | func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error) { |
| 175 | return ListenControl(socketPath, host, cache, nil) |
| 176 | } |
| 177 | |
| 178 | // Close stops accepting control requests, removes the socket file, and tears |
| 179 | // down any connection already accepted before this call — in that order, |
| 180 | // and both steps matter independently: |
| 181 | // |
| 182 | // 1. p.ln.Close() first, deterministically unlinking the socket file and |
| 183 | // ending Accept regardless of whether the Serve goroutine ListenControl |
| 184 | // started has ever been scheduled. p.srv.Close() alone can't be trusted |
| 185 | // for this: net/http's Server only closes listeners its own Serve call |
| 186 | // has already registered (in an internal map populated by Serve itself, |
| 187 | // on entry — see trackListener in net/http/server.go), and a Close |
| 188 | // arriving before that goroutine runs loses the race and finds nothing |
| 189 | // registered — Serve then proceeds to Accept on a listener nobody asked |
| 190 | // it to stop, and the socket file is never unlinked. Closing the raw |
| 191 | // net.Listener sidesteps the tracking map entirely. |
| 192 | // 2. p.srv.Close() second, to tear down connections ALREADY accepted |
| 193 | // before step 1 — closing only the listener stops new Accepts but does |
| 194 | // nothing to a connection already handed off to its own handler |
| 195 | // goroutine, which keeps right on serving requests over a still-open |
| 196 | // keep-alive connection. A client that pooled such a connection could |
| 197 | // otherwise keep issuing purges indefinitely after Close returned. |
| 198 | // Calling srv.Close() after p.ln is already closed is harmless: closing |
| 199 | // an already-closed (or never-registered) listener a second time is a |
| 200 | // no-op error internally, swallowed here, while every connection |
| 201 | // srv independently tracks in its own connection map — unrelated to |
| 202 | // listener registration — still gets closed as intended. |
| 203 | // |
| 204 | // The returned error is p.ln's — the unlink guarantee callers actually |
| 205 | // depend on; srv.Close()'s error, if any, is dropped. Calling Close a |
| 206 | // second time (nobody does today) returns "use of closed network |
| 207 | // connection" from the second p.ln.Close(). |
| 208 | func (p *ControlListener) Close() error { |
| 209 | err := p.ln.Close() |
| 210 | p.srv.Close() |
| 211 | return err |
| 212 | } |
| 213 | |