| 1 | package edge |
| 2 | |
| 3 | // The edge-local door (#175 P7): a second listener, on a box-local unix |
| 4 | // socket, through which an app's own processes reach any of the app's |
| 5 | // instances, on this box or another, with an app-scoped door key. |
| 6 | // |
| 7 | // A door request is a request from this box (the P7 plan's Security model, |
| 8 | // Paul's ruling of 2026-09-28). The door believes no caller-supplied visitor |
| 9 | // address: it deletes every header that claims one and presents the box's own |
| 10 | // public address, the one its edges file publishes. That is what the app would |
| 11 | // see if the calling process requested the instance's public URL, since that |
| 12 | // request would leave the box and come back through an edge from the same |
| 13 | // address. So the door grants nothing a public request from this box lacks. |
| 14 | // The key says which app is calling and confines it to that app's hosts; it |
| 15 | // confers no privilege. |
| 16 | // |
| 17 | // The enforcement lives in ServeHTTP (edge.go), keyed on the door mark this |
| 18 | // file puts on the request context: |
| 19 | // |
| 20 | // - the tunnel and steering-probe hostnames are unknown hosts to a door |
| 21 | // caller, in any spelling, and their handlers never run; |
| 22 | // - a host this box does not serve is dialled only when the owner its home |
| 23 | // box published is the caller's app, and only at the box Owner named; |
| 24 | // - a host this box serves is checked against the FINAL upstream, after |
| 25 | // the gate's login re-resolution, before the gate, the cache and the |
| 26 | // dispatch. |
| 27 | // |
| 28 | // Nothing carries the mark across a hop. A backhauled door request is an |
| 29 | // ordinary hop, and acceptHop is unchanged. |
| 30 | |
| 31 | import ( |
| 32 | "context" |
| 33 | "errors" |
| 34 | "fmt" |
| 35 | "log" |
| 36 | "net" |
| 37 | "net/http" |
| 38 | "os" |
| 39 | "path/filepath" |
| 40 | "strings" |
| 41 | "sync" |
| 42 | "time" |
| 43 | |
| 44 | "github.com/carlosframework/platform/internal/doorkey" |
| 45 | "github.com/carlosframework/platform/internal/registry" |
| 46 | "github.com/carlosframework/platform/internal/release" |
| 47 | ) |
| 48 | |
| 49 | // doorCallerKey marks a request that came through the door. A context value |
| 50 | // and never a header, for hopVerifiedKey's reason: nothing outside serveDoor |
| 51 | // can put it on a request. |
| 52 | type doorCallerKey struct{} |
| 53 | |
| 54 | // doorCaller is the app a door key admitted. |
| 55 | type doorCaller struct{ Account, App string } |
| 56 | |
| 57 | func doorCallerFrom(ctx context.Context) (doorCaller, bool) { |
| 58 | c, ok := ctx.Value(doorCallerKey{}).(doorCaller) |
| 59 | return c, ok |
| 60 | } |
| 61 | |
| 62 | // doorOwnsRoute is the whole ownership rule: the route's account and app are |
| 63 | // the caller's. |
| 64 | func doorOwnsRoute(c doorCaller, rt registry.Route) bool { |
| 65 | return release.RouteAccount(rt) == c.Account && rt.App == c.App |
| 66 | } |
| 67 | |
| 68 | // doorReservedHost reports whether host names one of the handlers ServeHTTP |
| 69 | // answers ahead of the route lookup: the tunnel hub (and the store door on |
| 70 | // its hostname) and the steering probe. Compared lower-cased with one |
| 71 | // trailing dot stripped, the normalization steeringProbe and |
| 72 | // canonicalizeHopHost use, so a door caller cannot reach either through a |
| 73 | // spelling the public branches would not match and a wildcard route would. |
| 74 | func (e *Edge) doorReservedHost(host string) bool { |
| 75 | h := strings.ToLower(strings.TrimSuffix(host, ".")) |
| 76 | if e.tunnels != nil && h == strings.ToLower(strings.TrimSuffix(e.tunnels.Host(), ".")) { |
| 77 | return true |
| 78 | } |
| 79 | return e.steeringHost != "" && h == strings.ToLower(strings.TrimSuffix(e.steeringHost, ".")) |
| 80 | } |
| 81 | |
| 82 | // doorAddressHeaders are the client-address headers beyond the four |
| 83 | // stripForwardedHeaders deletes, each read as the visitor's address by some |
| 84 | // framework (Rails reads Client-IP; python-ipware reads X-Forwarded and |
| 85 | // Forwarded-For among others). |
| 86 | var doorAddressHeaders = []string{"X-Forwarded", "Forwarded-For", "Client-IP", "X-Real-IP", "True-Client-IP", |
| 87 | "CF-Connecting-IP", "Fastly-Client-IP", "X-Client-IP", "X-Cluster-Client-IP", "X-Originating-IP"} |
| 88 | |
| 89 | // serveDoor is the door's handler: authenticate, rewrite, and hand the |
| 90 | // request to ServeHTTP marked as a door caller. |
| 91 | func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request) { |
| 92 | in, ok := e.doorRequest(r) |
| 93 | if !ok { |
| 94 | doorRefused(w) |
| 95 | return |
| 96 | } |
| 97 | e.ServeHTTP(w, in) |
| 98 | } |
| 99 | |
| 100 | // doorRequest is serveDoor's first half, split out so a test can read the |
| 101 | // request ServeHTTP will see. It always strips the key and the mesh's |
| 102 | // headers; ok is false when the key is not admitted. |
| 103 | func (e *Edge) doorRequest(r *http.Request) (*http.Request, bool) { |
| 104 | raw := r.Header.Get(doorkey.Header) |
| 105 | r.Header.Del(doorkey.Header) |
| 106 | // The mesh's vocabulary is never a door caller's to speak. |
| 107 | r.Header.Del(hopHeader) |
| 108 | r.Header.Del(hopAuthHeader) |
| 109 | tok, err := doorkey.ParseToken(raw) |
| 110 | if err != nil { |
| 111 | return nil, false |
| 112 | } |
| 113 | rec, ok := e.doorKeys.Record(tok.Account, tok.App) |
| 114 | if !ok || !rec.Admits(tok) { |
| 115 | return nil, false |
| 116 | } |
| 117 | // No visitor address is believed: every header that claims one goes, |
| 118 | // and the request comes from this box's own public address, exactly as |
| 119 | // it would through the public URL. |
| 120 | stripForwardedHeaders(r) |
| 121 | for _, h := range doorAddressHeaders { |
| 122 | r.Header.Del(h) |
| 123 | } |
| 124 | r.RemoteAddr = net.JoinHostPort(e.doorAddr, "0") |
| 125 | return r.WithContext(context.WithValue(r.Context(), doorCallerKey{}, doorCaller{Account: tok.Account, App: tok.App})), true |
| 126 | } |
| 127 | |
| 128 | // doorRefused answers a missing, malformed, unknown or revoked key. The body |
| 129 | // is the same for all four, so the answer says nothing about which keys or |
| 130 | // apps exist. |
| 131 | func doorRefused(w http.ResponseWriter) { |
| 132 | w.Header().Set("Cache-Control", "no-store") |
| 133 | http.Error(w, "door key refused", http.StatusUnauthorized) |
| 134 | } |
| 135 | |
| 136 | // usableDoorAddr reports whether addr can stand for this box on the public |
| 137 | // internet: a global unicast address that is not private. Loopback, a |
| 138 | // private address or a placeholder would read as local or trusted to some |
| 139 | // framework, which is the one thing the door must never present. |
| 140 | func usableDoorAddr(addr string) bool { |
| 141 | ip := net.ParseIP(addr) |
| 142 | return ip != nil && ip.IsGlobalUnicast() && !ip.IsPrivate() |
| 143 | } |
| 144 | |
| 145 | // openDoor listens on path and serves the door there until closeDoor. It |
| 146 | // refuses with no key mirror (every key would be refused, and a door that |
| 147 | // answers nothing but 401 hides a wiring fault) and with no usable public |
| 148 | // address. The socket is 0660; its group comes from /run/carlos's setgid |
| 149 | // carlos-sockets (bootstrap.go), so nothing here changes group. |
| 150 | func (e *Edge) openDoor(path string) (closeDoor func(), err error) { |
| 151 | if e.doorKeys == nil { |
| 152 | return nil, errors.New("no door-key mirror") |
| 153 | } |
| 154 | if !usableDoorAddr(e.doorAddr) { |
| 155 | return nil, fmt.Errorf("%q is not a public address", e.doorAddr) |
| 156 | } |
| 157 | // A socket left by a previous run is replaced. Anything else at the |
| 158 | // path is not ours to remove. |
| 159 | if fi, err := os.Lstat(path); err == nil { |
| 160 | if fi.Mode()&os.ModeSocket == 0 { |
| 161 | return nil, fmt.Errorf("%s exists and is not a socket", path) |
| 162 | } |
| 163 | if err := os.Remove(path); err != nil { |
| 164 | return nil, err |
| 165 | } |
| 166 | } else if !errors.Is(err, os.ErrNotExist) { |
| 167 | return nil, err |
| 168 | } |
| 169 | // Bind in a private (0700) directory beside the target, chmod there, and |
| 170 | // only then rename into place, so the socket is never reachable at the |
| 171 | // umask's mode (Fable's final review). The rename stays on one |
| 172 | // filesystem, and the directory's setgid group still applies because the |
| 173 | // socket is created under /run/carlos. |
| 174 | stage, err := os.MkdirTemp(filepath.Dir(path), ".door-") |
| 175 | if err != nil { |
| 176 | return nil, err |
| 177 | } |
| 178 | defer os.RemoveAll(stage) |
| 179 | staged := filepath.Join(stage, "s") |
| 180 | ln, err := net.Listen("unix", staged) |
| 181 | if err != nil { |
| 182 | return nil, err |
| 183 | } |
| 184 | if ul, ok := ln.(*net.UnixListener); ok { |
| 185 | // The listener unlinks its own path on Close; after the rename that |
| 186 | // path is gone, and closeDoor removes the real one itself. |
| 187 | ul.SetUnlinkOnClose(false) |
| 188 | } |
| 189 | if err := os.Chmod(staged, 0o660); err != nil { |
| 190 | ln.Close() |
| 191 | return nil, err |
| 192 | } |
| 193 | if err := os.Rename(staged, path); err != nil { |
| 194 | ln.Close() |
| 195 | return nil, err |
| 196 | } |
| 197 | // ReadHeaderTimeout for the public listeners' Slowloris reason; no |
| 198 | // Read/WriteTimeout, which would cut long-lived streams. |
| 199 | srv := &http.Server{Handler: http.HandlerFunc(e.serveDoor), ReadHeaderTimeout: 10 * time.Second} |
| 200 | go func() { |
| 201 | if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { |
| 202 | log.Printf("edge: edge-local door on %s stopped: %v", path, err) |
| 203 | } |
| 204 | }() |
| 205 | var once sync.Once |
| 206 | return func() { |
| 207 | once.Do(func() { |
| 208 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| 209 | defer cancel() |
| 210 | _ = srv.Shutdown(ctx) |
| 211 | _ = os.Remove(path) |
| 212 | }) |
| 213 | }, nil |
| 214 | } |
| 215 | |
| 216 | // armDoor builds the key mirror, lands one refresh, starts its loop and opens |
| 217 | // the door. RunWithActivatorReady calls it once the home map is stored and |
| 218 | // running, so the first door request meets both mirrors. A door that cannot |
| 219 | // open says why in the journal and returns the reason; with no door path it |
| 220 | // does nothing. |
| 221 | func (e *Edge) armDoor(ctx context.Context) error { |
| 222 | if e.doorPath == "" { |
| 223 | return nil |
| 224 | } |
| 225 | var why error |
| 226 | switch { |
| 227 | case e.staticStore == nil: |
| 228 | why = errors.New("no deployment store to read door keys from") |
| 229 | case len(e.staticPub) == 0: |
| 230 | why = errors.New("no release public key to verify door keys against") |
| 231 | case !usableDoorAddr(e.doorAddr): |
| 232 | why = fmt.Errorf("no usable public address for this box (%q)", e.doorAddr) |
| 233 | } |
| 234 | if why != nil { |
| 235 | log.Printf("edge: edge-local door off: %v", why) |
| 236 | return why |
| 237 | } |
| 238 | keys := newDoorKeyMirror(e.staticStore, e.staticPub) |
| 239 | rctx, cancel := context.WithTimeout(ctx, doorKeyTimeout) |
| 240 | if err := keys.Refresh(rctx); err != nil { |
| 241 | log.Printf("edge: first door-key refresh failed: %v. Every door key is refused until a later poll lands", err) |
| 242 | } |
| 243 | cancel() |
| 244 | e.doorKeys = keys |
| 245 | stop := make(chan struct{}) |
| 246 | go keys.run(stop) |
| 247 | closeSrv, err := e.openDoor(e.doorPath) |
| 248 | if err != nil { |
| 249 | close(stop) |
| 250 | e.doorKeys = nil |
| 251 | log.Printf("edge: edge-local door off: cannot open %s: %v", e.doorPath, err) |
| 252 | return err |
| 253 | } |
| 254 | var once sync.Once |
| 255 | e.closeDoor = func() { |
| 256 | once.Do(func() { |
| 257 | closeSrv() |
| 258 | close(stop) |
| 259 | }) |
| 260 | } |
| 261 | log.Printf("edge: edge-local door open on %s. Door requests arrive from %s", e.doorPath, e.doorAddr) |
| 262 | return nil |
| 263 | } |
| 264 | |