carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package agentsock is the agent's own listening socket — the ONE channel
2// between a sidecar and the platform, and it points inward: children dial
3// the agent, the agent never dials a child. That is what lets a sidecar
4// bind nothing, and what lets the identical artifact run off-platform
5// behind NAT with no protocol difference.
6//
7// Every exec child runs as the agent's user, so the socket's file mode is
8// not an identity boundary. Identity is the per-wake token the activator
9// mints and passes in the child's environment (CARLOS_SIDECAR_TOKEN),
10// scoped to one host for one wake: a token for A cannot speak about B, and
11// a token from a previous wake matches nothing.
12//
13// # What the socket trusts
14//
15// Nothing the caller sends, except as material for the one check. The
16// Carlos-Sidecar-Host header names a host, but naming it proves nothing —
17// only holding that host's live token does, and every effect is then applied
18// to the host the TOKEN authorized. No body carries a host field, and none
19// ever should: that is the confused-deputy shape this design exists to
20// exclude. Bodies are read under a 4KiB cap, parsed strictly, and refused on
21// anything unexpected; a refused call — for any reason, at any stage — has no
22// effect at all.
23//
24// Refusals are deliberately uniform. Wrong token, absent token, unknown host,
25// and a host whose worker is asleep all produce one identical 401, so the
26// socket cannot be used to enumerate hosts or probe which ones are awake.
27package agentsock
28
29import (
30 "context"
31 "crypto/rand"
32 "crypto/subtle"
33 "encoding/json"
34 "errors"
35 "fmt"
36 "io"
37 "log"
38 "net"
39 "net/http"
40 "os"
41 "strings"
42 "time"
43)
44
45// Workers is the activator's half of this conversation. It is declared here,
46// not imported, so the socket stays testable and the direction stays plain:
47// this package is told about workers, and never reaches for one.
48//
49// WorkerToken returns "" for a host that is unknown, asleep, or stopped. That
50// is a REFUSAL, never "no token required" — see authorize.
51type Workers interface {
52 WorkerToken(host string) string
53 NoteWorkerContact(host string)
54 SetWorkerNextDue(host string, at int64) error
55 SleepWorker(host string)
56 NoteWorkerReady(host, version string)
57}
58
59const (
60 // hostHeader names the host a caller claims to be. Untrusted on its own.
61 hostHeader = "Carlos-Sidecar-Host"
62
63 // maxBody bounds every body we read, following the next-due decode in
64 // activator/backend_exec.go. These messages are two small fields; a
65 // caller sending more is malformed, not generous.
66 maxBody = 1 << 12
67
68 // maxVersion bounds the build string a child reports. It is displayed to
69 // members, so it is length- and character-checked before it is recorded.
70 maxVersion = 200
71
72 // socketMode keeps the socket off the world. It is defence in depth, not
73 // the boundary: exec children share the agent's uid, so the mode cannot
74 // tell one child from another. The token does that.
75 socketMode = 0o660
76
77 // Local calls, all of them. Nothing here should ever be slow, and a
78 // child that stalls mid-request must not pin a connection.
79 readHeaderTimeout = 2 * time.Second
80 readTimeout = 5 * time.Second
81 writeTimeout = 5 * time.Second
82 idleTimeout = 30 * time.Second
83 shutdownGrace = 3 * time.Second
84
85 // dialProbe is how long we wait to learn whether an existing socket file
86 // still has somebody listening on it.
87 dialProbe = 250 * time.Millisecond
88)
89
90// Server serves the agent socket. Zero per-request state: one instance
91// answers every child on the box concurrently.
92type Server struct {
93 w Workers
94}
95
96// New builds the socket server over w.
97func New(w Workers) *Server { return &Server{w: w} }
98
99// Handler is the routing table, exported so tests can exercise it without
100// binding anything. Every verb is POST-only: the method pattern means a GET
101// is turned away by the router, before authorize and before any effect.
102func (s *Server) Handler() http.Handler {
103 mux := http.NewServeMux()
104 mux.Handle("POST /v1/ready", s.guard(s.ready))
105 mux.Handle("POST /v1/next-due", s.guard(s.nextDue))
106 mux.Handle("POST /v1/idle", s.guard(s.idle))
107 mux.Handle("POST /v1/heartbeat", s.guard(s.heartbeat))
108 return mux
109}
110
111// guard is the single gate. Nothing reaches a verb without passing it, and a
112// verb never sees the request's claimed host — only the authorized one, so
113// there is no way for a handler to act on a host it was not entitled to.
114func (s *Server) guard(fn func(http.ResponseWriter, *http.Request, string)) http.Handler {
115 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
116 host, ok := s.authorize(r)
117 if !ok {
118 refuse(w)
119 return
120 }
121 fn(w, r, host)
122 })
123}
124
125// authorize resolves the caller to the one host it may speak about.
126//
127// The dangerous shape here is WorkerToken returning "" — unknown host, asleep,
128// stopped — being read as "this host needs no token", which would be a total
129// bypass for every host that is not currently awake. So the empty case is not
130// handled by a guard that a later edit could delete: an absent token is
131// replaced with fresh random bytes, and the comparison then simply cannot
132// succeed. Refusal is a property of the compare, not of a branch above it.
133//
134// A caller that names no host at all is refused here, not left to WorkerToken.
135// Asking a collaborator what "" means is the same mistake in a different
136// place: this package would be safe only for as long as the activator's map
137// happens to hold no "" key, which is not a property this package can see or
138// keep. An unnamed caller has nothing to authorize, so it never reaches the
139// lookup — and gets the same 401 as every other refusal.
140//
141// The compare itself is constant-time, and both failure paths — no live token,
142// wrong live token — run the same code, so the structure carries no timing
143// signal beyond the length leak inherent to any comparison.
144func (s *Server) authorize(r *http.Request) (string, bool) {
145 host := r.Header.Get(hostHeader)
146 presented := bearer(r.Header.Get("Authorization"))
147
148 want := ""
149 if host != "" {
150 want = s.w.WorkerToken(host)
151 }
152 if want == "" {
153 want = unmatchable()
154 }
155 return host, subtle.ConstantTimeCompare([]byte(presented), []byte(want)) == 1
156}
157
158// unmatchable returns a value no caller can present. crypto/rand.Read never
159// fails (it panics on a broken system rather than returning short), so this
160// has no error path that could degrade into a guessable constant.
161func unmatchable() string {
162 var b [32]byte
163 rand.Read(b[:])
164 return string(b[:])
165}
166
167// bearer extracts the credential from an Authorization header, returning ""
168// for anything that is not exactly a non-empty Bearer credential. "Bearer "
169// with nothing after it yields "", which authorize then cannot match.
170func bearer(h string) string {
171 const scheme = "Bearer "
172 if len(h) > len(scheme) && strings.EqualFold(h[:len(scheme)], scheme) {
173 return h[len(scheme):]
174 }
175 return ""
176}
177
178// refuse is the only 401 in the package. One status, one body, for every
179// reason — a caller learns that it was refused and nothing else.
180func refuse(w http.ResponseWriter) {
181 http.Error(w, "unauthorized", http.StatusUnauthorized)
182}
183
184// bad is the only 400 in the package, for the same reason.
185func bad(w http.ResponseWriter) {
186 http.Error(w, "bad request", http.StatusBadRequest)
187}
188
189// ready ends the settle window and records the build the child says it is
190// running. The version is the child's own claim — §9.2's honesty rule — so it
191// is bounded and character-checked, never trusted. The field must be PRESENT
192// (a typo'd body is a loud 400, not a silent "unknown"), but may be empty: a
193// child that genuinely does not know its build says so, and is still ready.
194func (s *Server) ready(w http.ResponseWriter, r *http.Request, host string) {
195 var body struct {
196 Version *string `json:"version"`
197 }
198 if !decode(r, &body) {
199 bad(w)
200 return
201 }
202 if body.Version == nil || len(*body.Version) > maxVersion || !displayable(*body.Version) {
203 bad(w)
204 return
205 }
206 s.w.NoteWorkerReady(host, *body.Version)
207 w.WriteHeader(http.StatusNoContent)
208}
209
210// nextDue records when the child expects to work again, while it is alive to
211// say so. "at" must be present and non-negative: an absent field decoding to
212// zero would silently reschedule the worker to the epoch.
213func (s *Server) nextDue(w http.ResponseWriter, r *http.Request, host string) {
214 var body struct {
215 At *int64 `json:"at"`
216 }
217 if !decode(r, &body) {
218 bad(w)
219 return
220 }
221 if body.At == nil || *body.At < 0 {
222 bad(w)
223 return
224 }
225 if err := s.w.SetWorkerNextDue(host, *body.At); err != nil {
226 // The child must not read a 204 for a schedule that did not land.
227 log.Printf("agentsock: set next-due for %s: %v", host, err)
228 http.Error(w, "internal error", http.StatusInternalServerError)
229 return
230 }
231 w.WriteHeader(http.StatusNoContent)
232}
233
234// idle is "I have nothing to do". It carries no arguments, so nothing in the
235// request can steer it: the authorized host is the whole message. Whether the
236// request is honoured at all is the activator's call — an always-on worker's
237// idle is refused there.
238func (s *Server) idle(w http.ResponseWriter, _ *http.Request, host string) {
239 s.w.SleepWorker(host)
240 w.WriteHeader(http.StatusNoContent)
241}
242
243// heartbeat is a worker's only sign of life: nothing routes to a sidecar, so
244// no request will ever stamp one. Argument-free for the same reason as idle.
245func (s *Server) heartbeat(w http.ResponseWriter, _ *http.Request, host string) {
246 s.w.NoteWorkerContact(host)
247 w.WriteHeader(http.StatusNoContent)
248}
249
250// decode parses a bounded body into v. The cap is applied to the READER, so an
251// oversized body is truncated into a parse error rather than accepted: there is
252// no size at which a caller gets a partial message honoured.
253func decode(r *http.Request, v any) bool {
254 return json.NewDecoder(io.LimitReader(r.Body, maxBody)).Decode(v) == nil
255}
256
257// displayable rejects control characters in a string bound for member-facing
258// output — a version line should not be able to carry a NUL, a newline, or a
259// terminal escape into a log or a console page.
260func displayable(s string) bool {
261 for _, r := range s {
262 if r < 0x20 || r == 0x7f {
263 return false
264 }
265 }
266 return true
267}
268
269// Serve binds path and answers on it until ctx is cancelled, then shuts down
270// and leaves no socket file behind.
271//
272// A socket file that outlives its agent is the ordinary result of a hard kill,
273// and the next start must take it over rather than die on "address already in
274// use" — so a stale file is removed before binding. A file somebody is still
275// LISTENING on is a different thing entirely: unlinking that would orphan a
276// live agent, whose children would go on talking to a process nothing else can
277// reach. So we probe first, and refuse rather than steal — and, for the same
278// reason, this function unlinks nothing it did not create (see below).
279func (s *Server) Serve(ctx context.Context, path string) error {
280 if listening(path) {
281 return fmt.Errorf("agentsock: %s is already served by a live listener", path)
282 }
283 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
284 return fmt.Errorf("agentsock: remove stale socket %s: %w", path, err)
285 }
286 ln, err := net.Listen("unix", path)
287 if err != nil {
288 return fmt.Errorf("agentsock: listen on %s: %w", path, err)
289 }
290 // Cleanup is the LISTENER's job and nothing else's. Go unlinks a unix
291 // socket it created when the listener closes, and that unlink happens
292 // inside Close — indivisibly, from the point of view of a successor
293 // trying to bind the same path.
294 //
295 // A deferred os.Remove(path) here would not be. Between the listener's
296 // own unlink and the deferred call, a successor agent can have bound the
297 // path, and we would then unlink ITS socket: the successor serves on an
298 // inode nothing can reach, its children never arrive, and the box has the
299 // same split-brain the probe above exists to refuse to cause. Guarding
300 // one door and leaving the other open is worse than guarding neither,
301 // because it reads as guarded. So there is no unconditional remove, and
302 // every return below closes the listener instead.
303 if ul, ok := ln.(*net.UnixListener); ok {
304 ul.SetUnlinkOnClose(true) // the default; pinned because it is load-bearing
305 }
306
307 if err := os.Chmod(path, socketMode); err != nil {
308 ln.Close() // unlinks the socket we just created, and only that one
309 return fmt.Errorf("agentsock: chmod %s: %w", path, err)
310 }
311
312 srv := &http.Server{
313 Handler: s.Handler(),
314 ReadHeaderTimeout: readHeaderTimeout,
315 ReadTimeout: readTimeout,
316 WriteTimeout: writeTimeout,
317 IdleTimeout: idleTimeout,
318 MaxHeaderBytes: 1 << 14,
319 }
320
321 errc := make(chan error, 1)
322 go func() { errc <- srv.Serve(ln) }()
323
324 select {
325 case err := <-errc:
326 if errors.Is(err, http.ErrServerClosed) {
327 return nil
328 }
329 return err
330 case <-ctx.Done():
331 // ctx is already cancelled, so Shutdown gets its own deadline.
332 sctx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
333 defer cancel()
334 err := srv.Shutdown(sctx)
335 <-errc
336 return err
337 }
338}
339
340// listening reports whether something is currently accepting on path. A stale
341// socket file, a regular file, and a missing path all answer false.
342func listening(path string) bool {
343 c, err := net.DialTimeout("unix", path, dialProbe)
344 if err != nil {
345 return false
346 }
347 c.Close()
348 return true
349}
350