# 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 `, 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 ```go 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 ` (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: ```go 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..wake.mac`; `wake.broadcast` non-empty `host:port` → `hosts..wake.broadcast`; `wake.wait` default 45 s, less than 5 s → `hosts..wake.wait`. `routes..peers` non-empty while `identity == "off"` → `routes..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. ```sh 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.