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>
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. Rowv2/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.