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>
93 lines
4.5 KiB
Markdown
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.
|