Files
crossbar/docs/plans/v2/04-identity.md
T
kyleandClaude Fable 5.1 74b8af4ce0 AGENTS.md: a refused tool call is not a reason to end the turn; v2 plan (props, ctx guard, wake, identity, wiring) as acceptance tests; v1.1 run note
v2 given tests compiled against a panic-only skeleton (go vet clean); no reference
implementation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 08:37:38 -07:00

4.5 KiB

v2 task 04: tailnet identity and the route gate; config additions

Branch: v2 (run git switch v2; git status --short must be empty, otherwise stop) Commit subject: Add identity: whois resolver, checker, header mode, middleware; config for wake, peers, identity

Goal

Some routes should be usable only from particular tailnet nodes ("hermes-talos only from talos"). identity resolves a caller's address to a tailnet node name — in production through tailscale whois --json <ip>, in tests through a fake, in the smoke run through a header — and a middleware in front of the proxy refuses other callers with 403. Config gains the keys the rest of v2 needs.

Files

  • Copy: internal/identity/identity_test.go, internal/identity/middleware_test.go, internal/identity/testdata/whois.json, internal/config/config_v2_test.go
  • Create: internal/identity/identity.go, internal/identity/middleware.go
  • Modify: internal/config/config.go, docs/implementer-log.md

Interfaces

package identity

var (
    ErrNotAPeer  = errors.New("identity: not a tailnet peer")
    ErrForbidden = errors.New("identity: forbidden route")
)
type ID struct{ Node, Login string }
type Resolver interface { Identity(ctx context.Context, ip string) (ID, error) }

// ParseWhois reads `tailscale whois --json` output: Node = Node.ComputedName (else Node.Name
// without its trailing dot and domain), Login = UserProfile.LoginName. Empty node → error.
func ParseWhois(raw []byte) (ID, error)

// TailscaleResolver runs `tailscale whois --json <ip>` (exec, 3 s timeout) and parses it; a
// non-zero exit is ErrNotAPeer; a missing binary is an error that the Checker treats as "deny".
type TailscaleResolver struct{ Bin string }   // Bin default "tailscale"
func (TailscaleResolver) Identity(ctx context.Context, ip string) (ID, error)

type Checker struct { /* private: resolver, cache map[ip]ID with a 5-minute TTL, mutex */ }
func NewChecker(r Resolver) *Checker
// NewHeaderChecker trusts the X-Crossbar-Peer request header as the node name. TEST/SMOKE ONLY.
func NewHeaderChecker() *Checker
// Allow: nil when peers is empty (open route); otherwise the caller's node (from remoteAddr's
// IP, or the header in header mode) must be in peers, else ErrForbidden. Any resolver error,
// unparsable address or loopback → ErrForbidden.
func (c *Checker) Allow(ctx context.Context, peers []string, remoteAddr string) error

// Middleware names the route like the proxy (X-Crossbar-Route header, else first path segment),
// asks peersFor(route), and answers 403 {"error":"forbidden route"} when Allow refuses. Paths
// under /_crossbar/ and routes peersFor does not know pass straight through.
func Middleware(c *Checker, peersFor func(route string) ([]string, bool), next http.Handler) http.Handler

Header mode: Allow needs the request to read the header, but its signature takes an address. Make the header checker's resolver read from a context.Context value that Middleware sets (identity.WithHeaderPeer(ctx, r.Header.Get("X-Crossbar-Peer"))); the tests only observe the behaviour. Cache: per address, 5 minutes, for both hit and ErrNotAPeer.

internal/config gains:

Identity string `toml:"identity"`   // "off" (default) | "tailscale" | "header"; anything else → *Error field "identity"
// on Host:
Wake *Wake `toml:"wake"`             // nil when absent
type Wake struct { MAC string `toml:"mac"`; Broadcast string `toml:"broadcast"`; Wait Duration `toml:"wait"` }
// on Route:
Peers []string `toml:"peers"`

Validation (after the existing host/route checks): wake.mac must parse (net.ParseMAC, 6 bytes) → field hosts.<h>.wake.mac; wake.broadcast non-empty host:port → hosts.<h>.wake.broadcast; wake.wait default 45 s, less than 5 s → hosts.<h>.wake.wait. routes.<r>.peers non-empty while identity == "off" → routes.<r>.peers ("peers need identity = tailscale or header"); peers = [] (present but empty) with identity on → same field ("empty peers list").

Steps

  • 1. git switch v2; mkdir -p internal/identity/testdata; copy the four given files.
  • 2. See them fail (compile). 3. Write the code. gofmt -w internal/.
  • 4. go test -race -count=1 ./internal/identity/ ./internal/config/ → ok (v0/v1 config tests included).
  • 5. make gate. 6. Row v2/04-identity; commit.
git add internal/identity internal/config docs/implementer-log.md
git commit

Done when

  • Both packages pass; gate ok; all four copied files byte-identical.