Add identity: whois resolver, checker, header mode, middleware; config for wake, peers, identity

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-25 10:16:51 -07:00
parent bddf6c93f2
commit a942e336d8
9 changed files with 666 additions and 5 deletions
+223
View File
@@ -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 <ip>` 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
}
+90
View File
@@ -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)
}
+71
View File
@@ -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
}
+74
View File
@@ -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)
}
}
+24
View File
@@ -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
}