eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "context"
5 "crypto/tls"
6 "database/sql"
7 "errors"
8 "flag"
9 "fmt"
10 "log"
11 "net"
12 "net/http"
13 "net/http/httputil"
14 "os"
15 "os/signal"
16 "sync/atomic"
17 "syscall"
18 "time"
19
20 "golang.org/x/crypto/acme/autocert"
21
22 "lchat/internal/activator"
23 "lchat/internal/bot"
24)
25
26// router is the multi-instance front door: it terminates TLS (autocert) and
27// reverse-proxies each configured host to its backend's unix socket. The
28// host→backend map is swapped atomically, so reloads and zero-downtime deploys
29// never drop a live connection. See docs/instance-router.md.
30type router struct {
31 reg *Registry
32 upstreams atomic.Pointer[map[string]upstream]
33 // act, when non-nil, is the hibernation activator (activator.go):
34 // hibernate-flagged instance routes stop being always-on systemd backends
35 // and become on-demand child processes woken per request. Nil
36 // (LCHAT_HIBERNATE_BUCKET unset) keeps today's proxy-only behavior; with it
37 // set, unflagged routes are still proxied straight (the flag is per host).
38 act *activatorSvc
39 // fallbackURL, when set, is where requests for unknown hosts are redirected
40 // (e.g. the apex marketing site) instead of a bare 404 — a wildcard DNS
41 // record then makes typo'd or retired subdomains land somewhere useful. Note
42 // the limit: over HTTPS this only helps hosts that still HAVE a cert (e.g. a
43 // removed instance); a never-served host fails the TLS handshake first,
44 // because HostPolicy refuses to mint certs for unknown hosts (anything else
45 // would let strangers burn the ACME rate limit). The :80 path always works.
46 fallbackURL string
47}
48
49// upstream is one served host: the proxy to its backend, plus — when the
50// activator manages it — the route itself, so a request can trigger the wake.
51type upstream struct {
52 proxy *httputil.ReverseProxy
53 route Route
54 // hibernating marks an activator-managed instance: the router itself
55 // knows whether its process is live (it's the router's child), so "dead
56 // backend" detection is the activator's state table, not a failed dial.
57 hibernating bool
58}
59
60func newRouter(reg *Registry, act *activatorSvc) (*router, error) {
61 rt := &router{reg: reg, act: act}
62 if err := rt.reload(); err != nil {
63 return nil, err
64 }
65 return rt, nil
66}
67
68// reload rebuilds the host→backend map from the registry and swaps it in
69// atomically. Safe to call while serving.
70func (rt *router) reload() error {
71 routes, err := rt.reg.list()
72 if err != nil {
73 return err
74 }
75 m := make(map[string]upstream, len(routes))
76 // The hosts the activator should manage after this reload — feeds both the
77 // per-upstream gate and the retire pass below.
78 managed := make(map[string]bool)
79 for _, t := range routes {
80 // Only serve ready routes: a 'creating'/'failed' entry has no working
81 // backend, so proxying it (and minting a cert for it) would just stall → 502.
82 if t.Socket == "" || t.Status != "ready" {
83 continue
84 }
85 // Hibernation is per-host: only an instance whose route carries the
86 // hibernate flag is activator-managed; unflagged instances and sibling
87 // services (home, push) are proxied straight. With the bucket env set
88 // but no route flagged, the router therefore behaves exactly as it does
89 // without the activator — so the binary + env can ship fleet-wide
90 // first, and the flag flips one instance at a time.
91 hib := rt.act != nil && t.Kind == "instance" && t.Hibernate
92 if hib {
93 managed[t.Host] = true
94 }
95 m[t.Host] = upstream{proxy: unixProxy(t.Socket), route: t, hibernating: hib}
96 }
97 rt.upstreams.Store(&m)
98 // Reconcile the activator with the new route set: a host un-flagged (or
99 // removed) since the last reload may still have a live child holding its
100 // socket and S3 lease — retire it so a systemd unit can take over cleanly.
101 // After the map swap above, so no new request re-enters the activator for a
102 // host being retired.
103 if rt.act != nil {
104 rt.act.retireExcept(managed)
105 }
106 return nil
107}
108
109func (rt *router) current() map[string]upstream {
110 if p := rt.upstreams.Load(); p != nil {
111 return *p
112 }
113 return nil
114}
115
116// allowed reports whether host is configured — used as the autocert HostPolicy,
117// so a certificate is only ever requested for a host we actually serve.
118func (rt *router) allowed(host string) bool {
119 _, ok := rt.current()[host]
120 return ok
121}
122
123func (rt *router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
124 host := r.Host
125 if h, _, err := net.SplitHostPort(host); err == nil {
126 host = h
127 }
128 up, ok := rt.current()[host]
129 if !ok {
130 rt.unknownHost(w, r)
131 return
132 }
133 if up.hibernating {
134 // Wake (or join an in-progress wake) before proxying; the activator
135 // also stamps this request's activity for the idle clock. Note a bare
136 // TLS handshake never wakes anything — only a proxied request does.
137 rt.act.serveHTTP(w, r, up.route, up.proxy)
138 return
139 }
140 up.proxy.ServeHTTP(w, r)
141}
142
143// unknownHost answers a request for a host we don't serve: a redirect to the
144// fallback (302 — a typo'd host must not be cached as permanently moved), or
145// the classic 404 when no fallback is configured.
146func (rt *router) unknownHost(w http.ResponseWriter, r *http.Request) {
147 if rt.fallbackURL != "" {
148 http.Redirect(w, r, rt.fallbackURL, http.StatusFound)
149 return
150 }
151 http.Error(w, "unknown host", http.StatusNotFound)
152}
153
154// unixProxy reverse-proxies to a backend listening on a unix socket, preserving
155// the client's Host header (so the backend sees the real hostname) and passing
156// WebSocket upgrades straight through.
157func unixProxy(socketPath string) *httputil.ReverseProxy {
158 dialer := &net.Dialer{}
159 return &httputil.ReverseProxy{
160 Rewrite: func(pr *httputil.ProxyRequest) {
161 pr.SetXForwarded()
162 pr.Out.URL.Scheme = "http"
163 pr.Out.URL.Host = "lchat" // placeholder; the unix dialer ignores it
164 pr.Out.Host = pr.In.Host // backend sees the real hostname
165 // The sidecar marker is a router-edge signal only (activity
166 // exclusion, activator.go); strip it so nothing downstream can
167 // ever grow a meaning an outsider could spoof. The mesh's
168 // forwarded marker (one-hop loop guard, mesh.go) gets the same
169 // treatment for the same reason.
170 pr.Out.Header.Del(bot.SidecarHeader)
171 pr.Out.Header.Del(meshForwardedHeader)
172 },
173 Transport: &http.Transport{
174 DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
175 return dialUnixWithRetry(ctx, dialer, socketPath)
176 },
177 // Bound idle keep-alives so proxies replaced on reload don't leak their
178 // pooled connections + reader goroutines on the long-running router.
179 MaxIdleConns: 32,
180 IdleConnTimeout: 90 * time.Second,
181 },
182 }
183}
184
185// dialUnixWithRetry masks the brief gap during an instance restart: while the old
186// process has exited and the new one hasn't rebound the socket, dials fail with
187// ENOENT/ECONNREFUSED. Retry those for a short window so no in-flight request is
188// dropped; any other error fails fast. The window must exceed a deploy's
189// socket-absent gap — the instance drain (10s in main.go) plus rebind — so an
190// in-flight request is never 502'd mid-deploy; a sustained-down backend hangs this
191// long before 502 (rare: systemd Restart=always makes "down" transient). True
192// blue/green would remove the gap entirely (see docs/instance-router.md).
193func dialUnixWithRetry(ctx context.Context, d *net.Dialer, socketPath string) (net.Conn, error) {
194 const window = 13 * time.Second
195 deadline := time.Now().Add(window)
196 for {
197 conn, err := d.DialContext(ctx, "unix", socketPath)
198 if err == nil {
199 return conn, nil
200 }
201 if ctx.Err() != nil || !isRetryableDialErr(err) || time.Now().After(deadline) {
202 return nil, err
203 }
204 select {
205 case <-ctx.Done():
206 return nil, ctx.Err()
207 case <-time.After(100 * time.Millisecond):
208 }
209 }
210}
211
212func isRetryableDialErr(err error) bool {
213 return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT)
214}
215
216// runRouter is the `lchat router` subcommand: the shared front door for every
217// instance and sibling service on the box.
218func runRouter(args []string) {
219 fs := flag.NewFlagSet("router", flag.ExitOnError)
220 registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path (env: LCHAT_REGISTRY)")
221 acmeCache := fs.String("acme-cache", envOr("LCHAT_ACME_CACHE", "/data/acme"), "autocert certificate cache dir (env: LCHAT_ACME_CACHE)")
222 httpsAddr := fs.String("https", envOr("LCHAT_HTTPS_ADDR", ":443"), "HTTPS listen address")
223 httpAddr := fs.String("http", envOr("LCHAT_HTTP_ADDR", ":80"), "HTTP listen address (ACME HTTP-01 challenge + redirect to https)")
224 fallbackURL := fs.String("fallback-url", os.Getenv("LCHAT_FALLBACK_URL"),
225 "redirect requests for unknown hosts here, e.g. https://elevenmessenger.com (env: LCHAT_FALLBACK_URL); empty keeps the plain 404")
226 wildcardDomains := fs.String("wildcard-domains", envOr("LCHAT_WILDCARD_DOMAINS", ""),
227 "comma-separated base domains to serve a *.domain wildcard cert for, obtained via ACME DNS-01 (env: LCHAT_WILDCARD_DOMAINS; DNS-01 provider via LCHAT_DNS_PROVIDER — dnsimple (default, needs LCHAT_DNSIMPLE_TOKEN) or route53 (AWS credential chain))")
228 _ = fs.Parse(args)
229
230 reg, err := openRegistry(*registryPath)
231 if err != nil {
232 log.Fatalf("router: open registry %s: %v", *registryPath, err)
233 }
234
235 // Instance hibernation (activator.go): opt-in via LCHAT_HIBERNATE_BUCKET.
236 // Unset keeps the classic shape — instances under systemd, router as a
237 // pure proxy — so self-hosters and the current fleet are untouched.
238 var act *activatorSvc
239 hc, err := hibernateConfigFromEnv()
240 if err != nil {
241 log.Fatalf("router: %v", err)
242 }
243 // The activator mesh (mesh.go): opt-in via LCHAT_MESH_SELF, rides on the
244 // hibernation bucket (meshFromEnv errors if it's set without one). Unset =
245 // single box, no mesh codepaths.
246 mesh, err := meshFromEnv(hc)
247 if err != nil {
248 log.Fatalf("router: %v", err)
249 }
250 if hc != nil {
251 // materialize (cloud.go's controldMaterializeFunc) routes a route's
252 // first-wake staging through the control daemon instead of writing
253 // /opt/lchat directly, which this sandboxed process can't do (see
254 // s3Backend.materialize's doc comment). nil — the common case, no
255 // LoadCredential=control-token:… on this unit — falls back to the
256 // direct calls, today's long-standing behavior, unchanged.
257 materialize := controldMaterializeFunc()
258 if materialize == nil {
259 log.Printf("router: no control token available — a route materialized elsewhere (e.g. a mesh-replicated route) will fail its first wake here; see deploy/systemd/lchat-router-control-token.conf")
260 }
261 // The registry doubles as the due-time index's store (routes.next_due),
262 // so scheduled deliveries survive router restarts and fire on time even
263 // for sleeping instances.
264 act = newActivator(*hc, &s3Backend{cfg: *hc, materialize: materialize}, reg)
265 if mesh != nil {
266 // Wired and beating before act.start(): the sweeper consults the
267 // mesh from its first tick, and the first request deserves a
268 // membership view.
269 act.mesh = mesh
270 // The registry is how the mesh reads a host's replicated
271 // home_region for region-scoped placement (candidatesFor).
272 mesh.reg = reg
273 mesh.start()
274 log.Printf("router: activator mesh on — this box is %s (region %q, heartbeats s3://%s/%s every %s, peers stale after %s, placement scoped to %v)",
275 mesh.self, mesh.region, hc.Bucket, hc.meshPrefix(), mesh.interval, mesh.staleAfter, mesh.domains)
276
277 // This box's own capacity snapshot for the Fleet status page's
278 // multi-host aggregation (meshstats.go) — a separate S3 client on
279 // the same store, since meshSvc's is interface-typed for tests.
280 if statsStore, err := activator.NewHeartbeatStore(context.Background(), hc.Bucket, hc.meshPrefix(), hc.Region); err != nil {
281 log.Printf("router: mesh stats reporter: %v — this box's capacity snapshot won't publish", err)
282 } else {
283 startStatsReporter(statsStore, mesh.self, reg, act, mesh)
284 }
285 }
286 act.start()
287 log.Printf("router: hibernation active (bucket %s, prefix %q, idle %s) — flagged instances wake on demand",
288 hc.Bucket, hc.Prefix, hc.Idle)
289 }
290
291 rt, err := newRouter(reg, act)
292 if err != nil {
293 log.Fatalf("router: load routes: %v", err)
294 }
295 rt.fallbackURL = *fallbackURL
296
297 // One cert cache (certcache.go) backs every issuance artifact — autocert's
298 // per-host certs here AND the wildcard manager's PEMs/account key below —
299 // so swapping the backend (LCHAT_CERT_CACHE) moves all of it at once.
300 certCache, err := newCertCache(*acmeCache)
301 if err != nil {
302 log.Fatalf("router: %v", err)
303 }
304
305 m := &autocert.Manager{
306 Prompt: autocert.AcceptTOS,
307 Cache: certCache,
308 HostPolicy: func(_ context.Context, host string) error {
309 if rt.allowed(host) {
310 return nil
311 }
312 return fmt.Errorf("acme: host %q not in registry", host)
313 },
314 }
315
316 // Wildcard certs for the space base domain(s) (wildcard.go): any host one
317 // label under a configured domain is served the wildcard; everything else —
318 // and those hosts too, until the first wildcard issuance lands — takes the
319 // per-host autocert path above. Note the deliberate side effect: an unknown
320 // *.11ch.at host now completes the handshake and gets the fallback/404
321 // instead of a TLS error, since no per-host cert has to be minted first.
322 tlsCfg := m.TLSConfig()
323 if doms := splitDomains(*wildcardDomains); len(doms) > 0 {
324 // DNSimple by default (LCHAT_DNSIMPLE_TOKEN), Route53 as the additive
325 // off-ramp (LCHAT_DNS_PROVIDER=route53) — dnsProviderFromEnv decides. A
326 // nil provider (missing token, bad selector) keeps per-host issuance.
327 if dns, reason := dnsProviderFromEnv(); dns == nil {
328 log.Printf("router: LCHAT_WILDCARD_DOMAINS set but no DNS-01 provider (%s) — wildcard certs stay off (per-host issuance continues)", reason)
329 } else {
330 wc := newWildcardManager(doms, certCache, dns)
331 go wc.run()
332 inner := tlsCfg.GetCertificate
333 tlsCfg.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
334 if cert := wc.certFor(hello.ServerName); cert != nil {
335 return cert, nil
336 }
337 return inner(hello)
338 }
339 }
340 }
341
342 // Watch the registry for writes from other processes (the tunnel gateway
343 // publishes routes this way, without needing rights to signal us). We poll
344 // a cheap fingerprint of the routes table rather than PRAGMA data_version:
345 // the pragma proved blind to cross-process commits on the production box
346 // (a modernc/sqlite WAL-visibility quirk under the router's long-lived
347 // connections), while ordinary SELECTs on this same handle demonstrably do
348 // see fresh rows — reload()'s own queries are the proof. The fingerprint
349 // moves on any insert, delete, or status/socket change; scanning a few
350 // dozen rows every two seconds is noise.
351 go func() {
352 log.Printf("router: registry watcher armed (2s fingerprint poll)")
353 // Each poll opens a FRESH connection: a brand-new connection reads the
354 // latest committed state by construction, immune to whatever pins a
355 // long-lived handle to a stale WAL snapshot (observed in production:
356 // the shared handle went blind to cross-process commits — first with
357 // PRAGMA data_version, then intermittently with plain SELECTs — while
358 // separate processes saw fresh rows throughout). Opening SQLite costs
359 // about a millisecond; at one poll every two seconds that is nothing.
360 poll := func() (string, error) {
361 db, err := sql.Open("sqlite", *registryPath)
362 if err != nil {
363 return "", err
364 }
365 defer db.Close()
366 var fp string
367 err = db.QueryRow(`SELECT COUNT(*) || ':' || COALESCE(MAX(rowid),0) || ':' ||
368 COALESCE(SUM(rowid*17 + LENGTH(status)*3 + LENGTH(socket)),0) FROM routes`).Scan(&fp)
369 return fp, err
370 }
371 // No baseline-only first tick: the first observed fingerprint also
372 // triggers a reload, so a route published between the router's startup
373 // reload and this poll (the tunnel gateway publishes exactly that way)
374 // can't be silently baselined away. One redundant reload at startup is
375 // the cost of closing that gap.
376 var last string
377 errs := 0
378 for range time.Tick(2 * time.Second) {
379 fp, err := poll()
380 if err != nil {
381 errs++
382 if errs == 1 || errs%300 == 0 {
383 log.Printf("router: registry-watch poll error (#%d): %v", errs, err)
384 }
385 continue
386 }
387 errs = 0
388 if fp == last {
389 continue
390 }
391 last = fp
392 if err := rt.reload(); err != nil {
393 log.Printf("router: registry-watch reload failed: %v", err)
394 continue
395 }
396 log.Printf("router: registry changed — reloaded (%d route(s))", len(rt.current()))
397 }
398 }()
399
400 // Reload routes on SIGHUP (add-instance/deploy will also trigger this via the
401 // control socket in a later step).
402 sighup := make(chan os.Signal, 1)
403 signal.Notify(sighup, syscall.SIGHUP)
404 go func() {
405 for range sighup {
406 if err := rt.reload(); err != nil {
407 log.Printf("router: reload failed: %v", err)
408 continue
409 }
410 log.Printf("router: reloaded (%d route(s))", len(rt.current()))
411 }
412 }()
413
414 // The non-ACME :80 handler. Known hosts get autocert's classic redirect to
415 // https (GET/HEAD only, 302 — mirrored here since supplying any fallback
416 // replaces it); unknown hosts go to the fallback URL instead of a redirect
417 // into a doomed TLS handshake (no cert will ever exist for them).
418 httpFallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
419 host := r.Host
420 if h, _, err := net.SplitHostPort(host); err == nil {
421 host = h
422 }
423 if !rt.allowed(host) {
424 rt.unknownHost(w, r)
425 return
426 }
427 if r.Method != "GET" && r.Method != "HEAD" {
428 http.Error(w, "Use HTTPS", http.StatusBadRequest)
429 return
430 }
431 http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusFound)
432 })
433
434 // The mesh route-replication endpoint (meshreplicate.go) only exists when
435 // the mesh is on — everything else keeps proxying straight through rt, so
436 // a non-mesh box's HTTPS handler is byte-identical to before this existed.
437 var httpsHandler http.Handler = rt
438 if mesh != nil {
439 mux := http.NewServeMux()
440 mux.Handle("POST /api/mesh/route", meshRouteHandler(reg, rt, mesh.token))
441 // Deletion + reconciliation (#1346): a destroyed space's tombstone,
442 // and the route listing mesh-seed --prune diffs against.
443 mux.Handle("DELETE /api/mesh/route/{host}", meshRouteDeleteHandler(reg, rt, mesh.token))
444 mux.Handle("GET /api/mesh/routes", meshRouteListHandler(reg, rt, mesh.token, mesh.domains))
445 mux.Handle("/", rt)
446 httpsHandler = mux
447 }
448
449 // ReadHeaderTimeout guards the public front door against Slowloris-style slow-
450 // header connection exhaustion. No ReadTimeout/WriteTimeout — those would break
451 // long-lived proxied WebSockets.
452 httpSrv := &http.Server{Addr: *httpAddr, Handler: m.HTTPHandler(httpFallback), ReadHeaderTimeout: 10 * time.Second}
453 httpsSrv := &http.Server{Addr: *httpsAddr, Handler: httpsHandler, TLSConfig: tlsCfg, ReadHeaderTimeout: 10 * time.Second}
454
455 // :80 serves the ACME HTTP-01 challenge and redirects everything else to https.
456 go func() {
457 if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
458 log.Fatalf("router: http listener %s: %v", *httpAddr, err)
459 }
460 }()
461 go func() {
462 log.Printf("router: serving %d route(s) on %s (registry %s, acme cache %s)",
463 len(rt.current()), *httpsAddr, *registryPath, *acmeCache)
464 if err := httpsSrv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
465 log.Fatalf("router: https listener %s: %v", *httpsAddr, err)
466 }
467 }()
468
469 // Graceful shutdown on SIGTERM/SIGINT (e.g. a router binary upgrade): stop
470 // accepting and drain in-flight HTTP before exiting. Proxied WebSockets are
471 // hijacked (not drained); they reconnect through the new router.
472 stop := make(chan os.Signal, 1)
473 signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
474 <-stop
475 log.Printf("router: draining and shutting down")
476 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
477 defer cancel()
478 _ = httpsSrv.Shutdown(ctx)
479 _ = httpSrv.Shutdown(ctx)
480 if act != nil {
481 // Hibernated instances are the router's children: checkpoint each to
482 // S3 and release its lease before exiting, so the next incarnation
483 // wakes them cleanly instead of finding bound sockets and held leases.
484 sctx, scancel := context.WithTimeout(context.Background(), 60*time.Second)
485 defer scancel()
486 act.shutdown(sctx)
487 }
488}
489