package edge // The edge-local door (#175 P7): a second listener, on a box-local unix // socket, through which an app's own processes reach any of the app's // instances, on this box or another, with an app-scoped door key. // // A door request is a request from this box (the P7 plan's Security model, // Paul's ruling of 2026-09-28). The door believes no caller-supplied visitor // address: it deletes every header that claims one and presents the box's own // public address, the one its edges file publishes. That is what the app would // see if the calling process requested the instance's public URL, since that // request would leave the box and come back through an edge from the same // address. So the door grants nothing a public request from this box lacks. // The key says which app is calling and confines it to that app's hosts; it // confers no privilege. // // The enforcement lives in ServeHTTP (edge.go), keyed on the door mark this // file puts on the request context: // // - the tunnel and steering-probe hostnames are unknown hosts to a door // caller, in any spelling, and their handlers never run; // - a host this box does not serve is dialled only when the owner its home // box published is the caller's app, and only at the box Owner named; // - a host this box serves is checked against the FINAL upstream, after // the gate's login re-resolution, before the gate, the cache and the // dispatch. // // The mark itself never crosses a hop. A backhauled door request carries // only a signed X-Carlos-Hop-Door saying it is a door call (signHopDoor, // mesh.go), which keeps it out of the home box's rate limit and nothing more. import ( "context" "errors" "fmt" "log" "net" "net/http" "os" "path/filepath" "strings" "sync" "time" "github.com/carlosframework/platform/internal/doorkey" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // doorCallerKey marks a request that came through the door. A context value // and never a header, for hopVerifiedKey's reason: nothing outside serveDoor // can put it on a request. type doorCallerKey struct{} // doorCaller is the app a door key admitted. type doorCaller struct{ Account, App string } func doorCallerFrom(ctx context.Context) (doorCaller, bool) { c, ok := ctx.Value(doorCallerKey{}).(doorCaller) return c, ok } // doorOwnsRoute is the whole ownership rule: the route's account and app are // the caller's. func doorOwnsRoute(c doorCaller, rt registry.Route) bool { return release.RouteAccount(rt) == c.Account && rt.App == c.App } // doorReservedHost reports whether host names one of the handlers ServeHTTP // answers ahead of the route lookup: the tunnel hub (and the store door on // its hostname) and the steering probe. Compared lower-cased with one // trailing dot stripped, the normalization steeringProbe and // canonicalizeHopHost use, so a door caller cannot reach either through a // spelling the public branches would not match and a wildcard route would. func (e *Edge) doorReservedHost(host string) bool { h := strings.ToLower(strings.TrimSuffix(host, ".")) if e.tunnels != nil && h == strings.ToLower(strings.TrimSuffix(e.tunnels.Host(), ".")) { return true } return e.steeringHost != "" && h == strings.ToLower(strings.TrimSuffix(e.steeringHost, ".")) } // doorAddressHeaders are the client-address headers beyond the four // stripForwardedHeaders deletes, each read as the visitor's address by some // framework (Rails reads Client-IP; python-ipware reads X-Forwarded and // Forwarded-For among others). var doorAddressHeaders = []string{"X-Forwarded", "Forwarded-For", "Client-IP", "X-Real-IP", "True-Client-IP", "CF-Connecting-IP", "Fastly-Client-IP", "X-Client-IP", "X-Cluster-Client-IP", "X-Originating-IP"} // serveDoor is the door's handler: authenticate, rewrite, and hand the // request to ServeHTTP marked as a door caller. func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request) { in, ok := e.doorRequest(r) if !ok { doorRefused(w) return } e.ServeHTTP(w, in) } // doorRequest is serveDoor's first half, split out so a test can read the // request ServeHTTP will see. It always strips the key and the mesh's // headers; ok is false when the key is not admitted. func (e *Edge) doorRequest(r *http.Request) (*http.Request, bool) { raw := r.Header.Get(doorkey.Header) r.Header.Del(doorkey.Header) // The mesh's vocabulary is never a door caller's to speak. r.Header.Del(hopHeader) r.Header.Del(hopAuthHeader) r.Header.Del(hopDoorHeader) tok, err := doorkey.ParseToken(raw) if err != nil { return nil, false } rec, ok := e.doorKeys.Record(tok.Account, tok.App) if !ok || !rec.Admits(tok) { return nil, false } // No visitor address is believed: every header that claims one goes, // and the request comes from this box's own public address, exactly as // it would through the public URL. stripForwardedHeaders(r) for _, h := range doorAddressHeaders { r.Header.Del(h) } r.RemoteAddr = net.JoinHostPort(e.doorAddr, "0") return r.WithContext(context.WithValue(r.Context(), doorCallerKey{}, doorCaller{Account: tok.Account, App: tok.App})), true } // doorRefused answers a missing, malformed, unknown or revoked key. The body // is the same for all four, so the answer says nothing about which keys or // apps exist. func doorRefused(w http.ResponseWriter) { w.Header().Set("Cache-Control", "no-store") http.Error(w, "door key refused", http.StatusUnauthorized) } // usableDoorAddr reports whether addr can stand for this box on the public // internet: a global unicast address that is not private. Loopback, a // private address or a placeholder would read as local or trusted to some // framework, which is the one thing the door must never present. func usableDoorAddr(addr string) bool { ip := net.ParseIP(addr) return ip != nil && ip.IsGlobalUnicast() && !ip.IsPrivate() } // openDoor listens on path and serves the door there until closeDoor. It // refuses with no key mirror (every key would be refused, and a door that // answers nothing but 401 hides a wiring fault) and with no usable public // address. The socket is 0660; its group comes from /run/carlos's setgid // carlos-sockets (bootstrap.go), so nothing here changes group. func (e *Edge) openDoor(path string) (closeDoor func(), err error) { if e.doorKeys == nil { return nil, errors.New("no door-key mirror") } if !usableDoorAddr(e.doorAddr) { return nil, fmt.Errorf("%q is not a public address", e.doorAddr) } // A socket left by a previous run is replaced. Anything else at the // path is not ours to remove. if fi, err := os.Lstat(path); err == nil { if fi.Mode()&os.ModeSocket == 0 { return nil, fmt.Errorf("%s exists and is not a socket", path) } if err := os.Remove(path); err != nil { return nil, err } } else if !errors.Is(err, os.ErrNotExist) { return nil, err } // Bind in a private (0700) directory beside the target, chmod there, and // only then rename into place, so the socket is never reachable at the // umask's mode (Fable's final review). The rename stays on one // filesystem, and the directory's setgid group still applies because the // socket is created under /run/carlos. stage, err := os.MkdirTemp(filepath.Dir(path), ".door-") if err != nil { return nil, err } defer os.RemoveAll(stage) staged := filepath.Join(stage, "s") ln, err := net.Listen("unix", staged) if err != nil { return nil, err } if ul, ok := ln.(*net.UnixListener); ok { // The listener unlinks its own path on Close; after the rename that // path is gone, and closeDoor removes the real one itself. ul.SetUnlinkOnClose(false) } if err := os.Chmod(staged, 0o660); err != nil { ln.Close() return nil, err } if err := os.Rename(staged, path); err != nil { ln.Close() return nil, err } // ReadHeaderTimeout for the public listeners' Slowloris reason; no // Read/WriteTimeout, which would cut long-lived streams. srv := &http.Server{Handler: http.HandlerFunc(e.serveDoor), ReadHeaderTimeout: 10 * time.Second} go func() { if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Printf("edge: edge-local door on %s stopped: %v", path, err) } }() var once sync.Once return func() { once.Do(func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = srv.Shutdown(ctx) _ = os.Remove(path) }) }, nil } // armDoor builds the key mirror, lands one refresh, starts its loop and opens // the door. RunWithActivatorReady calls it once the home map is stored and // running, so the first door request meets both mirrors. A door that cannot // open says why in the journal and returns the reason; with no door path it // does nothing. func (e *Edge) armDoor(ctx context.Context) error { if e.doorPath == "" { return nil } var why error switch { case e.staticStore == nil: why = errors.New("no deployment store to read door keys from") case len(e.staticPub) == 0: why = errors.New("no release public key to verify door keys against") case !usableDoorAddr(e.doorAddr): why = fmt.Errorf("no usable public address for this box (%q)", e.doorAddr) } if why != nil { log.Printf("edge: edge-local door off: %v", why) return why } keys := newDoorKeyMirror(e.staticStore, e.staticPub) rctx, cancel := context.WithTimeout(ctx, doorKeyTimeout) if err := keys.Refresh(rctx); err != nil { log.Printf("edge: first door-key refresh failed: %v. Every door key is refused until a later poll lands", err) } cancel() e.doorKeys = keys stop := make(chan struct{}) go keys.run(stop) closeSrv, err := e.openDoor(e.doorPath) if err != nil { close(stop) e.doorKeys = nil log.Printf("edge: edge-local door off: cannot open %s: %v", e.doorPath, err) return err } var once sync.Once e.closeDoor = func() { once.Do(func() { closeSrv() close(stop) }) } log.Printf("edge: edge-local door open on %s. Door requests arrive from %s", e.doorPath, e.doorAddr) return nil }