| 1 | package main |
| 2 | |
| 3 | import ( |
| 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. |
| 30 | type 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. |
| 51 | type 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 | |
| 60 | func 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. |
| 70 | func (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 | |
| 109 | func (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. |
| 118 | func (rt *router) allowed(host string) bool { |
| 119 | _, ok := rt.current()[host] |
| 120 | return ok |
| 121 | } |
| 122 | |
| 123 | func (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. |
| 146 | func (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. |
| 157 | func 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). |
| 193 | func 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 | |
| 212 | func 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. |
| 218 | func 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 | |