carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
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.
22type 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.
30type 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.
47type 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.
53const 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.
126func 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.
174func 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().
208func (p *ControlListener) Close() error {
209 err := p.ln.Close()
210 p.srv.Close()
211 return err
212}
213