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>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user