Files
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

93 lines
4.5 KiB
Markdown

# 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
```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 <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:
```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.<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.
```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.