package edge import ( "encoding/json" "net" "net/http" ) // ControlListener is an instance's local-only control channel: the socket // the agent binds beside the instance's own, through which the instance // talks back to the platform. It began as the purge channel alone — see // docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md §3-4 — // and generalized to carry the scheduled-work verbs too // (2026-08-23-scheduled-work-design.md §7.2), which is why it now exists // for every exec-backed instance rather than only for boxes with a cache. // // It is deliberately NOT reachable over the network: an externally // reachable purge endpoint would let anyone manufacture wake storms by // force-purging popular pages, exactly what this feature exists to // prevent — and an externally reachable schedule endpoint would hand the // same anyone a timer that wakes an instance on demand. type ControlListener struct { ln net.Listener srv *http.Server } // PurgeListener is what this was called when purge was the only verb. // Retained as an alias so callers and tests that predate the generalisation // keep compiling. type PurgeListener = ControlListener // purgeRequest is the body of POST /purge: one exact URL, or {"path":"*"} // for every cached entry of the listener's host — the socket-channel twin of // a Carlos-Purge: * header (amadan carlos/platform#173). Without it an exec // instance had to enumerate its own paths to do what the header channel does // in one token. // // "Whole host" means the one host the listener was built for. Entries cached // under that route's aliases or custom domains are keyed by those names, and // this channel cannot name them; an app that needs them uses the header // channel's Carlos-Purge-Host list. // // "*" is a whole-host purge only on its own. With a query beside it the body // names nothing a request could be stored under, so it is refused rather than // guessed at: widening a malformed exact purge to the whole host is not a // reading the caller asked for. type purgeRequest struct { Path string `json:"path"` Query string `json:"query"` } // purgeAllPath is the purgeRequest.Path that asks for the whole host. const purgeAllPath = "*" // ListenControl binds socketPath (removing any stale file left behind by a // crashed previous instance) and serves this host's control verbs on it. // POST /purge is registered only when cache is non-nil — a box with no edge // cache has nothing to purge, and a route that answered /purge with no cache // behind it would be a lie. mount, when non-nil, adds the caller's own verbs // to the same mux (the scheduled-work ones, schedule.Mount). // // What the host scoping actually guarantees: host is fixed here, at // construction, and never taken from the request — purgeRequest carries // only Path and Query, never a host — so no request body, however // malicious or malformed, can make this listener purge any host other than // the one it was built for. That closes a confused-deputy purge: an // attacker (or a bug) cannot use one instance's control channel to reach // into another host's cache entries THROUGH this listener. schedule.Mount // takes host by the same discipline, for the same reason. // // What it does NOT guarantee: that only the instance this listener was // created for can ever reach it. Every exec-backed instance on a box runs // as the SAME uid as the agent itself (they're its children, spawned by // plain exec.Command with no uid change — see backend_exec.go), and the // control socket path is deterministic (rt.Socket + ".control" — // backend_exec.go), so a compromised or buggy exec-backed sibling can // compute another host's control socket path and dial it directly, then // issue it a purge for ITS host (never a third host — the guarantee above // still holds inside that listener). The 0600 socket mode (see the umask // dance below) keeps other UNIX users on the box out; it does nothing // against a same-uid sibling, because same-uid processes can always open // each other's 0600 sockets. Worst case from that gap: a misbehaving // instance forces a sibling to re-fetch from ITS OWN backend on its next // request — bounded by the same TTL ceiling (DefaultCacheMaxTTL) that // already caps a lost purge, and it can never expose one host's cached // response body to another, since each listener only ever touches the one // host's cache keys it was built for. // // Purge therefore stays UNAUTHENTICATED, deliberately: every app purging // today does it with a bare POST, an auth requirement would break all of // them at once, and the blast radius above is bounded to a re-fetch. The // verbs mount adds do NOT inherit that: schedule.Mount bearer-gates every // one of them on the host's own CARLOS_ADMIN_TOKEN. // // Be precise about what that bearer buys, because the obvious reading is // wrong. It closes the channel against OTHER UNIX users on the box (who the // 0600 socket mode already stops) and against any unauthenticated dialer: // reaching the socket is no longer enough, you must also present a secret. // It does NOT close the same-uid sibling gap described above. The token // lives in /.env, mode 0600 — and 0600 means OWNER-only, with // the owner being the one uid every exec child shares with the agent (no // SysProcAttr.Credential is ever set, and the data dir is that same user's). // A misbehaving exec-backed app can therefore read another host's env file, // take its token, and drive that host's schedule verbs. The gap is narrowed, // not closed. // // What bounds the residual: a sibling holding host B's token could ALREADY // dial B's own instance socket directly and send it any request it liked // with that token. The schedule verbs add REACH — a platform-delivered wake // plus a POST at a time and path of the attacker's choosing — but no // privilege a same-uid sibling did not already have. The real fix is // per-instance uids, which is the unit-backed/DynamicUser story below, and // it is out of scope for this channel. // // Unit-backed tenants (carlos-app@.service, backend_unit.go) do NOT // share this gap, or any control socket at all, because they don't share a // uid with anything: DynamicUser=yes mints a fresh, unpredictable uid on // every unit start, with no SupplementaryGroups — never the agent's uid, // never another tenant's. That's the opposite problem from the one above: // a DynamicUser tenant can't reach a same-uid sibling's control socket // because it can't reach ANY control socket, including one built for // itself. Unit-backed tenants have no control listener wired up yet as a // result (see backend_unit.go); their cached routes rely on the TTL // ceiling alone, and their once-timers go through the console rather than // the socket, until that gets its own design. func ListenControl(socketPath, host string, cache *Cache, mount func(*http.ServeMux)) (*ControlListener, error) { ln, err := SecureUnixListen(socketPath) if err != nil { return nil, err } mux := http.NewServeMux() if cache != nil { mux.HandleFunc("POST /purge", func(w http.ResponseWriter, r *http.Request) { var req purgeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if req.Path == purgeAllPath { if req.Query != "" { http.Error(w, "bad request: path \"*\" takes no query", http.StatusBadRequest) return } // host is still the one this listener was built for: "*" widens // the purge to every path of that host, never to another host. cache.PurgeHost(host) w.WriteHeader(http.StatusOK) return } cache.PurgeURL(host, req.Path, req.Query) w.WriteHeader(http.StatusOK) }) } if mount != nil { mount(mux) } srv := &http.Server{Handler: mux} p := &ControlListener{ln: ln, srv: srv} // Close (below) closes p.ln directly, not through srv — so the error // Serve returns here is whatever a closed-listener Accept produces, not // necessarily http.ErrServerClosed. Either way it's expected and unused. go srv.Serve(ln) //nolint:errcheck return p, nil } // ListenPurge is ListenControl with mount left nil — the shape this had // before the channel generalized, kept for callers that only want the // purge verb and none of the scheduled-work ones. It is not a promise that // /purge itself is registered: ListenControl only mounts it when cache is // non-nil, so ListenPurge(socketPath, host, nil) serves nothing at all, // same as ListenControl would. func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error) { return ListenControl(socketPath, host, cache, nil) } // Close stops accepting control requests, removes the socket file, and tears // down any connection already accepted before this call — in that order, // and both steps matter independently: // // 1. p.ln.Close() first, deterministically unlinking the socket file and // ending Accept regardless of whether the Serve goroutine ListenControl // started has ever been scheduled. p.srv.Close() alone can't be trusted // for this: net/http's Server only closes listeners its own Serve call // has already registered (in an internal map populated by Serve itself, // on entry — see trackListener in net/http/server.go), and a Close // arriving before that goroutine runs loses the race and finds nothing // registered — Serve then proceeds to Accept on a listener nobody asked // it to stop, and the socket file is never unlinked. Closing the raw // net.Listener sidesteps the tracking map entirely. // 2. p.srv.Close() second, to tear down connections ALREADY accepted // before step 1 — closing only the listener stops new Accepts but does // nothing to a connection already handed off to its own handler // goroutine, which keeps right on serving requests over a still-open // keep-alive connection. A client that pooled such a connection could // otherwise keep issuing purges indefinitely after Close returned. // Calling srv.Close() after p.ln is already closed is harmless: closing // an already-closed (or never-registered) listener a second time is a // no-op error internally, swallowed here, while every connection // srv independently tracks in its own connection map — unrelated to // listener registration — still gets closed as intended. // // The returned error is p.ln's — the unlink guarantee callers actually // depend on; srv.Close()'s error, if any, is dropped. Calling Close a // second time (nobody does today) returns "use of closed network // connection" from the second p.ln.Close(). func (p *ControlListener) Close() error { err := p.ln.Close() p.srv.Close() return err }