| index 1dc161356..d52f23491 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -23,6 +23,7 @@ import ( |
| "flag" |
| "fmt" |
| "log" |
| + "maps" |
| "net" |
| "net/http" |
| "net/http/httputil" |
| @@ -99,6 +100,13 @@ type Edge struct { |
| staticPub ed25519.PublicKey |
| staticCacheDir string |
| staticRecheck time.Duration |
| + // tunnels, when non-nil, is the reverse-channel hub (tunnel.go): remote |
| + // hosts register on its reserved hostname and routes with Tunnel set are |
| + // proxied over the channel they name. nil = tunnel serving not configured |
| + // on this box: a route's Tunnel binding is then ignored and it falls back |
| + // to its socket/addr, or goes unserved if it has neither — the same |
| + // nil-means-disabled convention as act/cache/staticStore. |
| + tunnels *TunnelHub |
| // cache, when non-nil, answers cacheable GET/HEAD requests without ever |
| // invoking the hibernation activator or the backend — the mitigation |
| // this whole feature exists for. nil disables caching entirely. |
| @@ -158,6 +166,14 @@ func (e *Edge) EnableCache(cache *Cache) { |
| e.cache = cache |
| } |
| |
| +// EnableTunnels wires the reverse-channel hub in: the edge answers hub.Host() |
| +// itself (registration) and routes with Tunnel set proxy over their named |
| +// channel. Call before Reload, same contract as EnableStatic — without a |
| +// Reload afterwards, tunnel routes stay skipped from the last table build. |
| +func (e *Edge) EnableTunnels(hub *TunnelHub) { |
| + e.tunnels = hub |
| +} |
| + |
| // SetStaticRecheck tunes how long a static route trusts a resolved channel |
| // pointer before re-reading it (default 60s; 0 = every request). Call after |
| // EnableStatic and before Reload; handlers built on the next reload use it. |
| @@ -212,6 +228,9 @@ func (e *Edge) Reload() error { |
| continue |
| } |
| var proxy http.Handler |
| + // viaTunnel: this host's bytes leave the box over a reverse channel, |
| + // which is what disqualifies it from hibernation below. |
| + viaTunnel := false |
| switch { |
| // Both reverse proxies get the Carlos-Purge response hook |
| // (purge_header.go): the app declares its own cache invalidations on |
| @@ -231,6 +250,22 @@ func (e *Edge) Reload() error { |
| // Static and status routes get no hook: their responses are the |
| // edge's own, built from bucket bytes by static.go/status.go, with no |
| // upstream in a position to declare anything. |
| + // |
| + // The tunnel case comes FIRST, ahead of socket/addr, because a route |
| + // can carry both: `carlos route -tunnel` is a narrow UPDATE that does |
| + // not clear socket/addr, so an operator moving a local app onto a |
| + // remote host leaves the old column set — and the binding they just |
| + // typed is the one they meant. It is guarded on e.tunnels rather than |
| + // skipping unconditionally so that an edge with no hub configured |
| + // keeps serving such a route from its old socket/addr (degrade to the |
| + // previous backend, never take the host off the air); a tunnel-only |
| + // route on such an edge matches nothing and falls to |
| + // `default: continue`, un-served and un-certed, until the hub lands. |
| + case t.Tunnel != "" && e.tunnels != nil: |
| + p := tunnelProxy(t.Tunnel, e.tunnels) |
| + p.ModifyResponse = e.purgeModifier(t) |
| + proxy = p |
| + viaTunnel = true |
| case t.Socket != "": |
| p := unixProxy(t.Socket) |
| p.ModifyResponse = e.purgeModifier(t) |
| @@ -252,7 +287,15 @@ func (e *Edge) Reload() error { |
| // flagged, the edge therefore behaves exactly as it does without one — |
| // so the binary + env can ship fleet-wide first, and the flag flips |
| // one instance at a time. |
| - hib := e.act != nil && t.Kind == "instance" && t.Hibernate |
| + // |
| + // A tunnel-served host is never activator-managed, however it is |
| + // flagged: the process lives on the far side of the channel and this |
| + // edge cannot start it, so waking would mean launching a SECOND, |
| + // local copy of the app beside the remote one. Leaving such a host out |
| + // of `managed` is also what retires the local child a repointed route |
| + // left running (RetireExcept, below) — the app moved to the remote |
| + // host, so the process it left behind here should go. |
| + hib := e.act != nil && t.Kind == "instance" && t.Hibernate && !viaTunnel |
| if hib { |
| managed[t.Host] = true |
| } |
| @@ -280,6 +323,13 @@ func (e *Edge) current() map[string]upstream { |
| // Allowed reports whether host is configured — used as the autocert |
| // HostPolicy, so a certificate is only ever requested for a host we serve. |
| func (e *Edge) Allowed(host string) bool { |
| + // The reserved tunnel hostname is not a registry route, but remote hosts |
| + // dial it over TLS on this same 443 listener — so it must be Allowed, and |
| + // autocert will mint for it. That is intended: it is a hostname the |
| + // operator configured on this box, not a stranger-supplied SNI. |
| + if e.tunnels != nil && host == e.tunnels.Host() { |
| + return true |
| + } |
| _, ok := e.current()[host] |
| return ok |
| } |
| @@ -289,6 +339,20 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| if h, _, err := net.SplitHostPort(host); err == nil { |
| host = h |
| } |
| + // The reserved tunnel hostname is answered by the hub itself — before the |
| + // route lookup, so registration cannot be intercepted by a route, and |
| + // after the host:port split, so a dialer sending "tunnel.host:443" still |
| + // lands here. It wins over a registry route of the same name: the operator |
| + // owns both names, and a shadowed route is visible (its host stops |
| + // answering) where a shadowed hub would be a locked-out fleet. |
| + // |
| + // Being ahead of the cache path is load-bearing too, not just tidy: the |
| + // hub ends its handshake in a hijack, so it must be handed the REAL |
| + // ResponseWriter, never a recordingResponseWriter wrapping it. |
| + if e.tunnels != nil && host == e.tunnels.Host() { |
| + e.tunnels.ServeHTTP(w, r) |
| + return |
| + } |
| up, ok := e.current()[host] |
| if !ok { |
| e.unknownHost(w, r) |
| @@ -802,6 +866,27 @@ func tcpProxy(addr string) *httputil.ReverseProxy { |
| } |
| } |
| |
| +// tunnelProxy reverse-proxies over a named reverse channel (TunnelHub). |
| +// Same Host preservation and purge-header discipline as tcpProxy — a |
| +// tunnel-backed host is exactly as untrusted as an addr-backed one. The |
| +// scheme is https because the h2 ClientConn requires it; no dial happens |
| +// (the channel already exists), so the value is otherwise inert. No channel |
| +// is resolved here either: hub.transportFor looks it up per request, so a |
| +// proxy built before a reconnect uses the fresh channel afterwards, and a |
| +// disconnected one fails fast into the stdlib proxy's 502 rather than hanging. |
| +func tunnelProxy(name string, hub *TunnelHub) *httputil.ReverseProxy { |
| + return &httputil.ReverseProxy{ |
| + Rewrite: func(pr *httputil.ProxyRequest) { |
| + pr.SetXForwarded() |
| + pr.Out.URL.Scheme = "https" |
| + pr.Out.URL.Host = pr.In.Host // h2 :authority = the real hostname |
| + pr.Out.Host = pr.In.Host |
| + scrubInboundPurgeHeader(pr) |
| + }, |
| + Transport: hub.transportFor(name), |
| + } |
| +} |
| + |
| // dialUnixWithRetry masks the brief gap during an instance restart: while the |
| // old process has exited and the new one hasn't rebound the socket, dials fail |
| // with ENOENT/ECONNREFUSED. Retry those for a short window so no in-flight |
| @@ -843,6 +928,17 @@ func Run(args []string) { |
| // process. act may be nil: pure proxy, exactly Run's behavior. cache may |
| // also be nil, disabling response caching entirely. |
| func RunWithActivator(args []string, act Activator, cache *Cache) { |
| + RunWithActivatorReady(args, act, cache, nil) |
| +} |
| + |
| +// RunWithActivatorReady is RunWithActivator plus a ready hook: ready (when |
| +// non-nil) is called with the fully configured Edge — every EnableXxx done, |
| +// tunnel hub armed if configured — immediately before the process blocks on |
| +// its listeners. It is the seam an in-process caller uses to reach the live |
| +// Edge (e.g. to read tunnel state for status reporting) without racing the |
| +// configuration it needs. It runs on the calling goroutine, so a hook that |
| +// blocks holds the front door shut: hand off to a goroutine and return. |
| +func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready func(*Edge)) { |
| dataDir := envOr("CARLOS_DATA_DIR", "/data") |
| fs := flag.NewFlagSet("edge", flag.ExitOnError) |
| registryPath := fs.String("registry", filepath.Join(dataDir, "registry.db"), "registry SQLite path (default $CARLOS_DATA_DIR/registry.db)") |
| @@ -873,6 +969,12 @@ func RunWithActivator(args []string, act Activator, cache *Cache) { |
| log.Fatalf("edge: static serving: %v", err) |
| } |
| |
| + // Reverse channels light up when the environment names a reserved |
| + // hostname and at least one client; otherwise tunnel routes stay skipped. |
| + if err := enableTunnelsFromEnv(e); err != nil { |
| + log.Fatalf("edge: tunnels: %v", err) |
| + } |
| + |
| go e.Watch(make(chan struct{})) // process-lifetime; never stopped |
| |
| // Reload routes on SIGHUP too (an operator's explicit "now"). |
| @@ -891,6 +993,12 @@ func RunWithActivator(args []string, act Activator, cache *Cache) { |
| stop := make(chan os.Signal, 1) |
| signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) |
| |
| + // The Edge is fully configured here — every EnableXxx applied and the |
| + // route table rebuilt behind them — and nothing is listening yet. |
| + if ready != nil { |
| + ready(e) |
| + } |
| + |
| if *dev { |
| // ReadHeaderTimeout guards against Slowloris-style slow-header |
| // connection exhaustion. No ReadTimeout/WriteTimeout — those would |
| @@ -1014,6 +1122,47 @@ func enableStaticFromEnv(e *Edge, dataDir string) error { |
| return e.Reload() |
| } |
| |
| +// enableTunnelsFromEnv arms the reverse-channel hub when the environment |
| +// names a reserved hostname (CARLOS_TUNNEL_HOST) and its authorized clients |
| +// (CARLOS_TUNNEL_CLIENTS, "name=token[,name=token]"). No CARLOS_TUNNEL_HOST is |
| +// not an error: the box simply serves no tunnels, and routes with Tunnel set |
| +// keep whatever local backend they have (Reload's tunnel case is guarded on |
| +// the hub). A host WITH no usable clients IS an error — it would open a |
| +// registration endpoint no one can ever authenticate against, which reads |
| +// like a working tunnel edge right up until a host tries to dial it. |
| +// |
| +// Channel names are validated here, at the only place a name enters the |
| +// process from config: a name the CLI would refuse (registry.ValidTunnelName, |
| +// what `carlos route -tunnel` enforces) can never match a route's Tunnel |
| +// column, so such a client would authenticate, connect, and silently carry no |
| +// traffic at all. Fail the boot instead, naming the entry. |
| +func enableTunnelsFromEnv(e *Edge) error { |
| + host := os.Getenv("CARLOS_TUNNEL_HOST") |
| + if host == "" { |
| + return nil |
| + } |
| + clients, err := parseTunnelClients(os.Getenv("CARLOS_TUNNEL_CLIENTS")) |
| + if err != nil { |
| + return err |
| + } |
| + if len(clients) == 0 { |
| + return fmt.Errorf("CARLOS_TUNNEL_HOST=%s set but CARLOS_TUNNEL_CLIENTS is empty", host) |
| + } |
| + for _, name := range slices.Sorted(maps.Keys(clients)) { |
| + if !registry.ValidTunnelName(name) { |
| + return fmt.Errorf("CARLOS_TUNNEL_CLIENTS: %q is not a valid tunnel name — want [a-z0-9-]{1,32}, and it must match a route's -tunnel to carry anything", name) |
| + } |
| + } |
| + e.EnableTunnels(NewTunnelHub(host, clients)) |
| + // Rebuild the route table so tunnel-backed routes that were skipped (or |
| + // left on their old socket/addr) by the startup reload pick up the hub. |
| + if err := e.Reload(); err != nil { |
| + return fmt.Errorf("reload after EnableTunnels: %w", err) |
| + } |
| + log.Printf("edge: tunnel hub armed on %s (%d client(s))", host, len(clients)) |
| + return nil |
| +} |
| + |
| func envOr(key, fallback string) string { |
| if v := os.Getenv(key); v != "" { |
| return v |