From a942e336d881306182f30cff6c17bfd411b4eff6 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Fri, 25 Sep 2026 10:16:51 -0700 Subject: [PATCH] Add identity: whois resolver, checker, header mode, middleware; config for wake, peers, identity Implemented-By: OpenCode session (model recorded in docs/implementer-log.md) --- docs/implementer-log.md | 1 + internal/config/config.go | 36 ++++- internal/config/config_v2_test.go | 75 +++++++++ internal/config/identity.go | 77 +++++++++ internal/identity/identity.go | 223 ++++++++++++++++++++++++++ internal/identity/identity_test.go | 90 +++++++++++ internal/identity/middleware.go | 71 ++++++++ internal/identity/middleware_test.go | 74 +++++++++ internal/identity/testdata/whois.json | 24 +++ 9 files changed, 666 insertions(+), 5 deletions(-) create mode 100644 internal/config/config_v2_test.go create mode 100644 internal/config/identity.go create mode 100644 internal/identity/identity.go create mode 100644 internal/identity/identity_test.go create mode 100644 internal/identity/middleware.go create mode 100644 internal/identity/middleware_test.go create mode 100644 internal/identity/testdata/whois.json diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 4aae4b3..474c1bc 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -5,6 +5,7 @@ owner fills in the Model column. The reviewer adds findings under "Reviews" once | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| v2/04-identity | 2026-09-25 | done | 1 | pass | new file `internal/config/identity.go` | Implemented `internal/identity/identity.go`: `ParseWhois` (Node = ComputedName, else Name minus trailing dot/domain; empty node errors), `TailscaleResolver` (`tailscale whois --json`, 3 s timeout, non-zero exit → `ErrNotAPeer`, missing binary a real deny), `Checker` with a 5-min per-address cache that also caches `ErrNotAPeer`, and `NewHeaderChecker`/`WithHeaderPeer` that read the peer from a context value. `middleware.go` names the route like the proxy (X-Crossbar-Route header, else first path segment), passes `/_crossbar/` and unknown routes straight through, and answers 403 `{"error":"forbidden route"}`. Config gains `Identity`/`Wake`/`Peers`; validation keys the peers check on the *explicit* identity value (a config with peers but no identity key passes), and `wake.wait` defaults to 45 s. Copied all four given files byte-identical; `go test -race ./internal/identity/ ./internal/config/` and `make gate` printed `gate: ok` on the first run. | ? | | v2/03-wake | 2026-09-25 | done | 1 | pass | none | Implemented wake-on-LAN in new `internal/wake/wake.go`: `MagicPacket` builds the 102-byte frame via `net.ParseMAC` (six `0xff` bytes plus the MAC repeated sixteen times) and rejects bad MACs; `Send` emits one UDP4 datagram to the resolved broadcast address, returning parse/resolve/write errors; `Waker` tracks last-sent per host under a mutex and sends at most once per `Wait` window, polling health every second (`PollEvery` is a test hook) until healthy, on `Wait` timeout, or on ctx cancellation, returning false for an unknown host without sending. Copied `internal/wake/wake_test.go` byte-identical to `docs/plans/v2/_files/`; `go test -race -count=3 ./internal/wake/` ok and `make gate` printed `gate: ok` on the first run. | llama.cpp/ornith-1.5-35b-a3b | | v2/02-ctxguard | 2026-09-25 | done | 1 | pass | none | Implemented the context-size guard in new `internal/proxy/ctxguard.go` (estimate `int(float64(len(body))/4*1.2)`; rule 2 skip on unknown/fit; rule 3 move via `leases.Move` with a `moved:>` header; rule 4 400 with `{"error":"prompt too large","estimate":E,"max":M}` and a status-400 accounting row, no forward, no mark-down) and wired it into `ServeHTTP` between the lease and the slot; added `Move` to `internal/lease/lease.go` (re-leases, deletes the old row, records a `ctx` event) and `ReasonCtx = "ctx"` to `internal/store`. Copied `internal/proxy/ctxguard_test.go` byte-identical to `docs/plans/v2/_files/`; `go test -race -count=2 ./internal/proxy/ ./internal/lease/` ok and `make gate` printed `gate: ok` on the first run. | llama.cpp/ornith-1.5-35b-a3b | | v2/01-props | 2026-09-25 | done | 1 | pass | none | Implemented /props learning in `internal/health/health.go`: added `Status.NCtx`/`Status.Slots`, `PerSlotCtx()`, and a best-effort `GET /props` appended to the poll after `/v1/models`, setting NCtx/Slots to 0 (negative → 0) on any failure without counting the poll as failed; exposed them in `internal/admin/admin.go` `HostView`. Copied `internal/health/props_test.go` and the replacement `internal/proxy/helpers_test.go` byte-identical to `docs/plans/v2/_files/`. `go test -race ./...` and `make gate` pass on the first run. | ? | diff --git a/internal/config/config.go b/internal/config/config.go index 984624e..51988c4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -68,12 +68,15 @@ type Host struct { BaseURL string `toml:"base_url"` Weight float64 `toml:"weight"` Models map[string]Model `toml:"models"` + Wake *Wake `toml:"wake"` } -// Route is an ordered list of hosts to try, with an optional default model. +// Route is an ordered list of hosts to try, with an optional default model and +// the peers allowed to reach it. type Route struct { Hosts []string `toml:"hosts"` DefaultModel string `toml:"default_model"` + Peers []string `toml:"peers"` } // Config is the whole file: what to listen on, tuning, hosts and routes. @@ -84,6 +87,7 @@ type Config struct { DB string `toml:"db"` LeaseIdle Duration `toml:"lease_idle"` Retention Duration `toml:"retention"` + Identity string `toml:"identity"` Hosts map[string]Host `toml:"hosts"` Routes map[string]Route `toml:"routes"` } @@ -109,6 +113,8 @@ const ( MinLeaseIdle = time.Minute MinRetention = 24 * time.Hour + + DefaultIdentity = "off" ) var routeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) @@ -157,7 +163,10 @@ func Parse(r io.Reader) (*Config, error) { if c.QueueMax == 0 { c.QueueMax = DefaultQueueMax } - if e := c.validate(); e != nil { + if c.Identity == "" { + c.Identity = DefaultIdentity + } + if e := c.validate(md); e != nil { return nil, e } return &c, nil @@ -184,7 +193,14 @@ func IsError(err error) (*Error, bool) { // validate checks the config in a fixed order and writes defaults back into c. // The first problem wins; every problem is an *Error with a precise field. -func (c *Config) validate() *Error { +func (c *Config) validate(md toml.MetaData) *Error { + peersDefined := make(map[string]bool, len(c.Routes)) + for name := range c.Routes { + if md.IsDefined("routes", name, "peers") { + peersDefined[name] = true + } + } + identityDefined := md.IsDefined("identity") if e := c.checkListen(); e != nil { return e } @@ -206,7 +222,13 @@ func (c *Config) validate() *Error { if e := c.checkHosts(); e != nil { return e } - return c.checkRoutes() + if e := c.checkWake(); e != nil { + return e + } + if e := c.checkRoutes(peersDefined, identityDefined); e != nil { + return e + } + return c.checkIdentity() } func (c *Config) checkListen() *Error { @@ -324,7 +346,7 @@ func (c *Config) checkHosts() *Error { return nil } -func (c *Config) checkRoutes() *Error { +func (c *Config) checkRoutes(peersDefined map[string]bool, identityDefined bool) *Error { if len(c.Routes) == 0 { return &Error{Field: "routes", Msg: "at least one required"} } @@ -367,6 +389,10 @@ func (c *Config) checkRoutes() *Error { return &Error{Field: fmt.Sprintf("routes.%s.default_model", name), Msg: "not served by any host in route"} } } + + if e := checkPeers(name, r.Peers, peersDefined[name], identityDefined, c.Identity); e != nil { + return e + } } return nil } diff --git a/internal/config/config_v2_test.go b/internal/config/config_v2_test.go new file mode 100644 index 0000000..0c4af39 --- /dev/null +++ b/internal/config/config_v2_test.go @@ -0,0 +1,75 @@ +package config_test + +import ( + "strings" + "testing" + "time" + + "git.wntrmute.dev/kyle/crossbar/internal/config" +) + +const v2Base = ` +listen = "127.0.0.1:1" +[hosts.a] +base_url = "http://a:1" +models = { "m" = { } } +[hosts.b] +base_url = "http://b:1" +models = { "m" = { } } +[hosts.b.wake] +mac = "aa:bb:cc:dd:ee:ff" +broadcast = "192.168.1.255:9" +wait = "45s" +[routes.r] +hosts = ["a", "b"] +peers = ["talos", "imladris"] +` + +func TestV2Defaults(t *testing.T) { + c, err := config.Parse(strings.NewReader(v2Base)) + if err != nil { + t.Fatal(err) + } + if c.Identity != "off" { + t.Errorf("identity default = %q, want off", c.Identity) + } + if c.Hosts["a"].Wake != nil { + t.Errorf("host without [wake] must have nil Wake") + } + w := c.Hosts["b"].Wake + if w == nil || w.MAC != "aa:bb:cc:dd:ee:ff" || w.Broadcast != "192.168.1.255:9" || w.Wait.Duration != 45*time.Second { + t.Errorf("wake = %+v", w) + } + if p := c.Routes["r"].Peers; len(p) != 2 || p[0] != "talos" { + t.Errorf("peers = %v", p) + } +} + +func TestV2Validation(t *testing.T) { + good := v2Base + for _, tc := range []struct{ name, text, field string }{ + {"bad identity", "identity = \"maybe\"\n" + good, "identity"}, + {"peers without identity", "identity = \"off\"\n" + good, "routes.r.peers"}, + {"bad mac", strings.Replace(good, `mac = "aa:bb:cc:dd:ee:ff"`, `mac = "nope"`, 1), "hosts.b.wake.mac"}, + {"no broadcast", strings.Replace(good, `broadcast = "192.168.1.255:9"`, `broadcast = ""`, 1), "hosts.b.wake.broadcast"}, + {"wait too short", strings.Replace(good, `wait = "45s"`, `wait = "2s"`, 1), "hosts.b.wake.wait"}, + {"peers on unknown route field", "identity = \"tailscale\"\n" + strings.Replace(good, `peers = ["talos", "imladris"]`, `peers = []`, 1), "routes.r.peers"}, + } { + _, err := config.Parse(strings.NewReader(tc.text)) + e, ok := config.IsError(err) + if !ok || e.Field != tc.field { + t.Errorf("%s: %v, want *Error on %s", tc.name, err, tc.field) + } + } + // identity = "header" is the test/smoke mode; "tailscale" the real one; both accept peers. + for _, mode := range []string{"header", "tailscale"} { + if _, err := config.Parse(strings.NewReader("identity = \"" + mode + "\"\n" + good)); err != nil { + t.Errorf("identity=%s with peers: %v", mode, err) + } + } + // wait defaults to 45s when the [wake] table omits it + c, err := config.Parse(strings.NewReader("identity = \"header\"\n" + strings.Replace(good, "wait = \"45s\"\n", "", 1))) + if err != nil || c.Hosts["b"].Wake == nil || c.Hosts["b"].Wake.Wait.Duration != 45*time.Second { + t.Errorf("wake.wait default: %v %+v", err, c.Hosts["b"].Wake) + } +} diff --git a/internal/config/identity.go b/internal/config/identity.go new file mode 100644 index 0000000..a83ef76 --- /dev/null +++ b/internal/config/identity.go @@ -0,0 +1,77 @@ +package config + +import ( + "fmt" + "net" + "time" +) + +// Wake is the magic-wake pattern sent to a host to rouse it: its MAC, the +// broadcast address to aim at, and how long to wait for the answer. +type Wake struct { + MAC string `toml:"mac"` + Broadcast string `toml:"broadcast"` + Wait Duration `toml:"wait"` +} + +const ( + DefaultWakeWait = 45 * time.Second + MinWakeWait = 5 * time.Second +) + +// identityMode reports whether s is a recognized identity backend. +func identityMode(s string) bool { + return s == "off" || s == "tailscale" || s == "header" +} + +// checkWake validates and defaults the magic-wake pattern of each host that has +// one. +func (c *Config) checkWake() *Error { + for name := range c.Hosts { + h := c.Hosts[name] + w := h.Wake + if w == nil { + continue + } + wakeField := fmt.Sprintf("hosts.%s.wake", name) + + mac, err := net.ParseMAC(w.MAC) + if err != nil || len(mac) != 6 { + return &Error{Field: wakeField + ".mac", Msg: "must be a MAC address"} + } + + if _, _, err := net.SplitHostPort(w.Broadcast); err != nil || w.Broadcast == "" { + return &Error{Field: wakeField + ".broadcast", Msg: "must be a non-empty host:port"} + } + + if w.Wait.Duration == 0 { + w.Wait.Duration = DefaultWakeWait + } else if w.Wait.Duration < MinWakeWait { + return &Error{Field: wakeField + ".wait", Msg: "must be at least 5s"} + } + h.Wake = w + c.Hosts[name] = h + } + return nil +} + +// checkIdentity rejects an unrecognized identity backend. +func (c *Config) checkIdentity() *Error { + if !identityMode(c.Identity) { + return &Error{Field: "identity", Msg: `must be "off", "tailscale", or "header"`} + } + return nil +} + +// checkPeers enforces the peers/identity contract for one route: peers may only +// be set with an identity backend on, and may not be an empty list. +func checkPeers(name string, peers []string, peersDefined, identityDefined bool, identity string) *Error { + peersField := fmt.Sprintf("routes.%s.peers", name) + switch { + case len(peers) > 0 && identityDefined && identity == "off": + return &Error{Field: peersField, Msg: "peers need identity = tailscale or header"} + case len(peers) == 0 && peersDefined && identityDefined && identity != "off": + return &Error{Field: peersField, Msg: "empty peers list"} + } + return nil +} diff --git a/internal/identity/identity.go b/internal/identity/identity.go new file mode 100644 index 0000000..a6d3fdf --- /dev/null +++ b/internal/identity/identity.go @@ -0,0 +1,223 @@ +// Package identity resolves a caller's address to a tailnet node name, and gates a +// route on the set of peers it allows. In production the resolver asks +// `tailscale whois`; in the smoke run it trusts a request header. A Checker caches +// the answer per address so a hot peer does not re-query whois on every request. +package identity + +import ( + "context" + "encoding/json" + "errors" + "net" + "os/exec" + "strings" + "sync" + "time" +) + +var ( + // ErrNotAPeer is returned by a resolver when the address is not a known + // tailnet node. The Checker turns it into a deny. + ErrNotAPeer = errors.New("identity: not a tailnet peer") + // ErrForbidden is returned by the Checker when the caller is not on the + // route's allow list. + ErrForbidden = errors.New("identity: forbidden route") +) + +// ID is the tailnet identity of a caller. +type ID struct { + Node, Login string +} + +// Resolver maps an IP address to the tailnet node it belongs to. +type Resolver interface { + Identity(ctx context.Context, ip string) (ID, error) +} + +// cacheTTL is how long a resolved identity (or a rejection) is held per address. +const cacheTTL = 5 * time.Minute + +// ParseWhois decodes `tailscale whois --json` output. Node is ComputedName, or +// Name with its trailing dot and domain stripped; Login is the profile login +// name. An empty node name is an error. +func ParseWhois(raw []byte) (ID, error) { + var whois struct { + Node struct { + Name string `json:"Name"` + ComputedName string `json:"ComputedName"` + } `json:"Node"` + UserProfile struct { + LoginName string `json:"LoginName"` + } `json:"UserProfile"` + } + if err := json.Unmarshal(raw, &whois); err != nil { + return ID{}, err + } + node := whois.Node.ComputedName + if node == "" { + node = stripName(whois.Node.Name) + } + if node == "" { + return ID{}, errors.New("identity: whois has no node name") + } + return ID{Node: node, Login: whois.UserProfile.LoginName}, nil +} + +// stripName takes a whois Name such as "titan.example.ts.net." and returns the +// first label, "titan". +func stripName(name string) string { + name = strings.TrimSuffix(name, ".") + if i := strings.IndexByte(name, '.'); i >= 0 { + name = name[:i] + } + return name +} + +// TailscaleResolver runs `tailscale whois --json ` and parses it. A non-zero +// exit is ErrNotAPeer; a missing binary (or other transport failure) is a real +// error the Checker treats as a deny. +type TailscaleResolver struct{ Bin string } + +// Identity runs the whois lookup with a 3 s timeout. +func (t TailscaleResolver) Identity(ctx context.Context, ip string) (ID, error) { + bin := t.Bin + if bin == "" { + bin = "tailscale" + } + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, bin, "whois", "--json", ip).Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return ID{}, ErrNotAPeer + } + return ID{}, err + } + return ParseWhois(out) +} + +// entry is a cached result, whether a node name or a rejection. +type entry struct { + id ID + err error + at time.Time +} + +// Checker resolves addresses through a Resolver, caching per address. In header +// mode it skips the cache and lets the resolver read the peer from the request. +type Checker struct { + r Resolver + header bool + + mu sync.Mutex + cache map[string]entry +} + +// NewChecker builds a Checker that resolves through r. +func NewChecker(r Resolver) *Checker { + return &Checker{r: r, cache: make(map[string]entry)} +} + +// NewHeaderChecker builds a Checker that trusts the X-Crossbar-Peer request +// header as the node name. TEST/SMOKE ONLY. +func NewHeaderChecker() *Checker { + return &Checker{r: headerResolver{}, header: true, cache: make(map[string]entry)} +} + +// WithHeaderPeer returns a context carrying the peer name the header checker +// reads. Middleware sets it from the X-Crossbar-Peer request header. +func WithHeaderPeer(ctx context.Context, peer string) context.Context { + return context.WithValue(ctx, headerPeerKey{}, peer) +} + +// Allow reports whether the caller at remoteAddr may use a route limited to peers. +// An empty peers list is an open route; otherwise the caller's node must be in +// peers. Any resolver error, an unparsable address, or a loopback address denies. +func (c *Checker) Allow(ctx context.Context, peers []string, remoteAddr string) error { + if len(peers) == 0 { + return nil + } + ip := "" + if !c.header { + var ok bool + ip, ok = peerIP(remoteAddr) + if !ok || net.ParseIP(ip).IsLoopback() { + return ErrForbidden + } + } + node, err := c.resolve(ctx, ip) + if err != nil { + return ErrForbidden + } + for _, p := range peers { + if p == node { + return nil + } + } + return ErrForbidden +} + +// resolve returns the node for ip, using the cache unless in header mode. +func (c *Checker) resolve(ctx context.Context, ip string) (string, error) { + if c.header { + id, err := c.r.Identity(ctx, ip) + if err != nil { + return "", err + } + return id.Node, nil + } + + c.mu.Lock() + e, hit := c.cache[ip] + if hit && time.Since(e.at) < cacheTTL { + c.mu.Unlock() + if e.err != nil { + return "", e.err + } + return e.id.Node, nil + } + c.mu.Unlock() + + id, err := c.r.Identity(ctx, ip) + if err != nil { + c.mu.Lock() + c.cache[ip] = entry{err: err, at: time.Now()} + c.mu.Unlock() + return "", err + } + + c.mu.Lock() + c.cache[ip] = entry{id: id, at: time.Now()} + c.mu.Unlock() + return id.Node, nil +} + +// headerPeerKey is the context key under which Middleware stores the X-Crossbar-Peer +// value for the header checker to read. +type headerPeerKey struct{} + +// headerResolver answers from the peer name Middleware placed on the context. An +// absent or empty header is a deny, so a request that forgot the header is 403. +type headerResolver struct{} + +func (headerResolver) Identity(ctx context.Context, _ string) (ID, error) { + peer, ok := ctx.Value(headerPeerKey{}).(string) + if !ok || peer == "" { + return ID{}, ErrNotAPeer + } + return ID{Node: peer, Login: peer}, nil +} + +// peerIP splits the host from a "host:port" address, returning the bare IP. +func peerIP(remoteAddr string) (string, bool) { + host := remoteAddr + if h, _, err := net.SplitHostPort(remoteAddr); err == nil { + host = h + } + ip := net.ParseIP(host) + if ip == nil { + return "", false + } + return ip.String(), true +} diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go new file mode 100644 index 0000000..b75e2de --- /dev/null +++ b/internal/identity/identity_test.go @@ -0,0 +1,90 @@ +package identity_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "git.wntrmute.dev/kyle/crossbar/internal/identity" +) + +func TestParseWhois(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "whois.json")) + if err != nil { + t.Fatal(err) + } + id, err := identity.ParseWhois(raw) + if err != nil { + t.Fatal(err) + } + if id.Node != "titan" || id.Login == "" { + t.Errorf("parsed %+v, want Node titan and a login", id) + } + if _, err := identity.ParseWhois([]byte(`{"Node":{}}`)); err == nil { + t.Error("a whois answer without a node name must be an error") + } + if _, err := identity.ParseWhois([]byte(`nope`)); err == nil { + t.Error("non-JSON must be an error") + } +} + +// fakeResolver answers from a map; "" means not a tailnet peer. +type fakeResolver map[string]string + +func (f fakeResolver) Identity(ctx context.Context, ip string) (identity.ID, error) { + n, ok := f[ip] + if !ok { + return identity.ID{}, identity.ErrNotAPeer + } + return identity.ID{Node: n, Login: n + "@example"}, nil +} + +func TestChecker(t *testing.T) { + c := identity.NewChecker(fakeResolver{"100.64.0.5": "talos", "100.64.0.9": "titan"}) + for _, tc := range []struct { + name string + peers []string + addr string + want error + }{ + {"open route", nil, "203.0.113.7:1", nil}, + {"allowed peer", []string{"talos", "titan"}, "100.64.0.5:44444", nil}, + {"other peer", []string{"talos"}, "100.64.0.9:1", identity.ErrForbidden}, + {"not a peer", []string{"talos"}, "203.0.113.7:1", identity.ErrForbidden}, + {"loopback", []string{"talos"}, "127.0.0.1:1", identity.ErrForbidden}, + {"garbage addr", []string{"talos"}, "nonsense", identity.ErrForbidden}, + } { + t.Run(tc.name, func(t *testing.T) { + got := c.Allow(context.Background(), tc.peers, tc.addr) + if !errors.Is(got, tc.want) && !(got == nil && tc.want == nil) { + t.Errorf("Allow(%v, %q) = %v, want %v", tc.peers, tc.addr, got, tc.want) + } + }) + } +} + +func TestCheckerCachesPerAddress(t *testing.T) { + calls := 0 + r := countingResolver{f: fakeResolver{"100.64.0.5": "talos"}, calls: &calls} + c := identity.NewChecker(r) + for i := 0; i < 5; i++ { + if err := c.Allow(context.Background(), []string{"talos"}, "100.64.0.5:1"); err != nil { + t.Fatal(err) + } + } + if calls != 1 { + t.Errorf("resolver called %d times for one address, want 1 (cache)", calls) + } +} + +type countingResolver struct { + f fakeResolver + calls *int +} + +func (c countingResolver) Identity(ctx context.Context, ip string) (identity.ID, error) { + *c.calls++ + return c.f.Identity(ctx, ip) +} diff --git a/internal/identity/middleware.go b/internal/identity/middleware.go new file mode 100644 index 0000000..e91b3b8 --- /dev/null +++ b/internal/identity/middleware.go @@ -0,0 +1,71 @@ +// Package identity gates a route on the set of tailnet peers allowed to use it. +// The middleware sits in front of the proxy: it names the route the same way the +// proxy does and refuses with 403 any caller a route does not allow. +package identity + +import ( + "net/http" + "strings" +) + +const ( + // routeHeader is how a caller names the route, the same header the proxy + // reads. + routeHeader = "X-Crossbar-Route" + // peerHeader carries the node name in header mode. + peerHeader = "X-Crossbar-Peer" + // adminPrefix is never gated here; the proxy's own handlers own it. + adminPrefix = "/_crossbar/" +) + +// Middleware wraps next with the peer gate. peersFor names the allow list for a +// route and reports whether it knows the route; an unknown route, like a path +// under adminPrefix, passes straight through. +func Middleware(c *Checker, peersFor func(route string) ([]string, bool), next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, adminPrefix) { + next.ServeHTTP(w, r) + return + } + route := r.Header.Get(routeHeader) + if route == "" { + route = firstSegment(r.URL.Path) + } + peers, known := peersFor(route) + if !known { + next.ServeHTTP(w, r) + return + } + ctx := WithHeaderPeer(r.Context(), r.Header.Get(peerHeader)) + if err := c.Allow(ctx, peers, r.RemoteAddr); err != nil { + writeForbidden(w) + return + } + next.ServeHTTP(w, r) + }) +} + +// writeForbidden answers the JSON 403 the tests and callers expect. +func writeForbidden(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"forbidden route"}`)) +} + +// firstSegment takes the first path segment as the route, "/a/v1/x" -> "a". +func firstSegment(path string) string { + if path == "" || path[0] != '/' { + return "" + } + after := path[1:] + if slash := strings.IndexByte(after, '/'); slash >= 0 { + if after[:slash] == "" { + return "" + } + return after[:slash] + } + if after == "" { + return "" + } + return after +} diff --git a/internal/identity/middleware_test.go b/internal/identity/middleware_test.go new file mode 100644 index 0000000..19bf778 --- /dev/null +++ b/internal/identity/middleware_test.go @@ -0,0 +1,74 @@ +package identity_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.wntrmute.dev/kyle/crossbar/internal/identity" +) + +// The middleware sits in front of the proxy: it names the route the same way the proxy does +// (X-Crossbar-Route header, else first path segment) and refuses callers a route does not list. +func TestMiddleware(t *testing.T) { + peers := map[string][]string{"locked": {"talos"}, "open": nil} + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }) + h := identity.Middleware(identity.NewChecker(fakeResolver{"100.64.0.5": "talos", "100.64.0.9": "titan"}), + func(route string) ([]string, bool) { p, ok := peers[route]; return p, ok }, inner) + for _, tc := range []struct { + name, path, hdr, addr string + want int + }{ + {"open route, anyone", "/open/v1/models", "", "203.0.113.1:5", 204}, + {"locked, right peer", "/locked/v1/models", "", "100.64.0.5:5", 204}, + {"locked, wrong peer", "/locked/v1/models", "", "100.64.0.9:5", 403}, + {"locked, not a peer", "/locked/v1/models", "", "203.0.113.1:5", 403}, + {"locked via header", "/v1/models", "locked", "100.64.0.9:5", 403}, + {"header wins over path", "/open/v1/models", "locked", "203.0.113.1:5", 403}, + {"unknown route passes through to the proxy's own 404", "/nope/v1/models", "", "203.0.113.1:5", 204}, + {"admin path is never gated here", "/_crossbar/hosts", "", "203.0.113.1:5", 204}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + req.RemoteAddr = tc.addr + if tc.hdr != "" { + req.Header.Set("X-Crossbar-Route", tc.hdr) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != tc.want { + t.Errorf("%s = %d, want %d (%s)", tc.path, rec.Code, tc.want, rec.Body.String()) + } + if rec.Code == 403 && (!strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") || !strings.Contains(rec.Body.String(), `"forbidden route"`)) { + t.Errorf("403 must be JSON {\"error\":\"forbidden route\"}: %q", rec.Body.String()) + } + }) + } +} + +// HeaderResolver is the test/smoke identity source: it trusts X-Crossbar-Peer. It exists so the +// smoke run can exercise the gate without a tailnet; config must call it out as insecure. +func TestHeaderResolver(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }) + h := identity.Middleware(identity.NewHeaderChecker(), func(route string) ([]string, bool) { return []string{"talos"}, true }, inner) + req := httptest.NewRequest(http.MethodGet, "/r/v1/models", nil) + req.Header.Set("X-Crossbar-Peer", "talos") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 204 { + t.Errorf("header peer talos: %d", rec.Code) + } + req.Header.Set("X-Crossbar-Peer", "titan") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 403 { + t.Errorf("header peer titan: %d, want 403", rec.Code) + } + req.Header.Del("X-Crossbar-Peer") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 403 { + t.Errorf("no header: %d, want 403", rec.Code) + } +} diff --git a/internal/identity/testdata/whois.json b/internal/identity/testdata/whois.json new file mode 100644 index 0000000..877b5a8 --- /dev/null +++ b/internal/identity/testdata/whois.json @@ -0,0 +1,24 @@ +{ + "Node": { + "ID": 1, + "StableID": "nEXAMPLE", + "Name": "titan.example.ts.net.", + "User": 2, + "Addresses": [ + "100.64.0.9/32", + "fd7a:115c:a1e0::9/128" + ], + "HomeDERP": 2, + "Created": "2026-01-01T00:00:00Z", + "Cap": 138, + "Online": true, + "ComputedName": "titan", + "ComputedNameWithHost": "titan" + }, + "UserProfile": { + "ID": 2, + "LoginName": "user@example.com", + "DisplayName": "Example User" + }, + "CapMap": null +} \ No newline at end of file