{"owner":"juanfont","repo":"headscale","hasSkills":true,"totalSkillsCount":1,"totalTokensCount":3805,"categories":["root-instruction"],"hasMcp":false,"mcpConfig":null,"found":["AGENTS.md"],"skills":{"AGENTS.md":"# AGENTS.md\n\nBehavioural guidance for AI agents working in this repository. Reference\nmaterial for complex procedures lives next to the code — integration\ntesting is documented in [`cmd/hi/README.md`](cmd/hi/README.md) and\n[`integration/README.md`](integration/README.md). Read those files\nbefore running tests or writing new ones.\n\nHeadscale is an open-source implementation of the Tailscale control server\nwritten in Go. It manages node registration, IP allocation, policy\nenforcement, and DERP routing for self-hosted tailnets.\n\n## Interaction Rules\n\nThese rules govern how you work in this repo. They are listed first\nbecause they shape every other decision.\n\n### Ask with comprehensive multiple-choice options\n\nWhen you need to clarify intent, scope, or approach, use the\n`AskUserQuestion` tool (or a numbered list fallback) and present the user\nwith a comprehensive set of options. Cover the likely branches explicitly\nand include an \"other — please describe\" escape.\n\n- Bad: _\"How should I handle expired nodes?\"_\n- Good: _\"How should expired nodes be handled? (a) Remain visible to peers\n  but marked expired (current behaviour); (b) Hidden from peers entirely;\n  (c) Hidden from peers but visible in admin API; (d) Other.\"_\n\nThis matters more than you think — open-ended questions waste a round\ntrip and often produce a misaligned answer.\n\n### Read the documented procedure before running complex commands\n\nBefore invoking any `hi` command, integration test, generator, or\nmigration tool, read the referenced README in full —\n`cmd/hi/README.md` for running tests, `integration/README.md` for\nwriting them. Never guess flags. If the procedure is not documented\nanywhere, ask the user rather than inventing one.\n\n### Map once, then act\n\nUse `Glob` / `Grep` to understand file structure, then execute. Do not\nre-explore the same area to \"double-check\" once you have a plan. Do not\nre-read files you edited in this session — the harness tracks state for\nyou.\n\n### Fail fast, report up\n\nIf a command fails twice with the same error, stop and report the exact\nerror to the user with context. Do not loop through variants or\n\"try one more thing\". A repeated failure means your model of the problem\nis wrong.\n\n### Confirm scope for multi-file changes\n\nBefore touching more than three files, show the user which files will\nchange and why. Use plan mode (`ExitPlanMode`) for non-trivial work.\n\n### Prefer editing existing files\n\nDo not create new files unless strictly necessary. Do not generate helper\nabstractions, wrapper utilities, or \"just in case\" configuration. Three\nsimilar lines of code is better than a premature abstraction.\n\n## Quick Start\n\n```bash\n# Enter the nix dev shell (Go 1.27.3, buf, golangci-lint, prek)\nnix develop\n\n# Full development workflow: fmt + lint + test + build\nmake dev\n\n# Individual targets\nmake build           # build the headscale binary\nmake test            # go test ./...\nmake fmt             # format Go, docs, proto\nmake lint            # lint Go, proto\nmake generate        # regenerate protobuf code (after changes to proto/)\nmake clean           # remove build artefacts\n\n# Direct go test invocations\ngo test ./...\ngo test -race ./...\n\n# Integration tests — read cmd/hi/README.md first\ngo run ./cmd/hi doctor\ngo run ./cmd/hi run \"TestName\"\n\n# Version pins — see \"Version Bumps\" below\ngo run ./tools/bump plan     # what is stale, changes nothing\ngo run ./tools/bump verify   # do the pins still agree\n```\n\nGo 1.27.3 minimum (per `go.mod:3`). `nix develop` pins the exact toolchain\nused in CI.\n\n## Pre-Commit with prek\n\n`prek` installs git hooks that run the same checks as CI.\n\n```bash\nnix develop\nprek install            # one-time setup\nprek run                # run hooks on staged files\nprek run --all-files    # run hooks on the full tree\n```\n\nHooks cover: file hygiene (trailing whitespace, line endings, BOM),\nsyntax validation (JSON/YAML/TOML/XML), merge-conflict markers, private\nkey detection, `treefmt` (the same formatter `nix fmt` and the flake's\nformatting check run), `mdformat` for `docs/`, and `golangci-lint` via\n`--new-from-rev=HEAD~1` (see the golangci-lint hook in .pre-commit-config.yaml). A manual\ninvocation with an `upstream/main` remote is equivalent:\n\n```bash\ngolangci-lint run --new-from-rev=upstream/main --timeout=5m --fix\n```\n\n`git commit --no-verify` is acceptable only for WIP commits on feature\nbranches — never on `main`.\n\n## Project Layout\n\n```\nheadscale/\n├── cmd/\n│   ├── headscale/    # Main headscale server binary\n│   └── hi/           # Integration test runner (see cmd/hi/README.md)\n├── hscontrol/        # Core control plane\n├── integration/      # End-to-end Docker-based tests (see integration/README.md)\n├── proto/            # Protocol buffer definitions\n├── gen/              # Generated code (buf output — do not edit)\n├── docs/             # User and ACL reference documentation\n└── packaging/        # Distribution packaging\n```\n\n### `hscontrol/` packages\n\n- `app.go`, `handlers.go`, `grpcv1.go`, `noise.go`, `auth.go`, `oidc.go`,\n  `poll.go`, `metrics.go`, `debug.go`, `tailsql.go`, `platform_config.go`\n  — top-level server files\n- `state/` — central coordinator (`state.go`) and the copy-on-write\n  `NodeStore` (`node_store.go`). All cross-subsystem operations go\n  through `State`.\n- `db/` — GORM layer, migrations, schema. `node.go`, `users.go`,\n  `ip.go`, `policy.go`; credentials (API keys, pre-auth keys, OAuth) share\n  one table: `secret.go` (generate/verify), `api_key.go`,\n  `preauth_keys.go`, `oauth.go`, `migrate_credentials.go`.\n- `mapper/` — streaming batcher that distributes MapResponses to\n  clients: `batcher.go`, `node_conn.go`, `builder.go`, `mapper.go`.\n  Performance-critical.\n- `policy/` — `policy/v2/` is **the** policy implementation. The\n  top-level `policy.go` is thin wrappers. There is no v1 directory.\n- `routes/`, `dns/`, `derp/`, `types/`, `util/`, `templates/`, `capver/`\n  — routing, MagicDNS, relay, core types, helpers, client templates,\n  capability versioning.\n- `servertest/` — in-memory test harness for server-level tests that\n  don't need Docker. Prefer this over `integration/` when possible.\n- `assets/` — embedded UI assets.\n\n### `cmd/hi/` files\n\n`main.go`, `run.go`, `doctor.go`, `docker.go`, `cleanup.go`, `stats.go`,\n`README.md`. **Read `cmd/hi/README.md` before running any `hi` command.**\n\n## Architecture Essentials\n\n- **`hscontrol/state/state.go`** is the central coordinator. Cross-cutting\n  operations (node updates, policy evaluation, IP allocation) go through\n  the `State` type, not directly to the database.\n- **`NodeStore`** in `hscontrol/state/node_store.go` is a copy-on-write\n  in-memory cache backed by `atomic.Pointer[Snapshot]`. Every read is a\n  pointer load; writes rebuild a new snapshot and atomically swap. It is\n  the hot path for `MapRequest` processing and peer visibility.\n- **The map-request sync point** is\n  `State.UpdateNodeFromMapRequest()` in `hscontrol/state/state.go`. This\n  is where Hostinfo changes, endpoint updates, and route advertisements\n  land in the NodeStore.\n- **Mapper subsystem** streams MapResponses via `batcher.go` and\n  `node_conn.go`. Changes here affect all connected clients.\n- **Node registration flow**: noise handshake (`noise.go`) → auth\n  (`auth.go`) → state/DB persistence (`state/`, `db/`) → initial map\n  (`mapper/`).\n\n## Database Migration Rules\n\nThese rules are load-bearing — violating them corrupts production\ndatabases. Migrations start at 0.29.0; `checkMinimumMigration` in\n`hscontrol/db/versioncheck.go` refuses older databases. All new\nmigrations must:\n\n1. **Never reorder existing migrations.** Migration order is immutable\n   once committed.\n2. **Only add new migrations to the end** of the migrations array.\n3. **Never disable foreign keys.**\n4. **Use the migration ID format** `YYYYMMDDHHMM-short-description`\n   (timestamp + descriptive suffix). Example: `202607241200-clear-tagged-node-expiry`.\n5. **Never use `AutoMigrate`** in a migration; write explicit DDL.\n6. **Run multi-statement migrations in `tx.Transaction`** so a failure\n   leaves the database retryable.\n\n## Tags-as-Identity\n\nHeadscale enforces **tags XOR user ownership**: every node is either\ntagged (owned by tags) or user-owned (owned by a user namespace), never\nboth. This is a load-bearing architectural rule.\n\n- **Use `node.IsTagged()`** (`hscontrol/types/node.go`) to determine\n  ownership, not `node.UserID().Valid()`. A tagged node may still have\n  `UserID` set for \"created by\" tracking — `IsTagged()` is authoritative.\n- `IsUserOwned()` returns `!IsTagged()`.\n- Tagged nodes are presented to Tailscale as the special\n  `TaggedDevices` user (`hscontrol/types/users.go`, ID `2147455555`).\n- `SetTags` validation is enforced by `validateNodeOwnership()` in\n  `hscontrol/state/tags.go`.\n- Examples and edge cases live in `hscontrol/types/node_tags_test.go`\n  and `hscontrol/grpcv1_test.go` (`TestSetTags_*`).\n\n**Don't do this**:\n\n```go\nif node.UserID().Valid() { /* assume user-owned */ }       // WRONG\nif node.UserID().Valid() && !node.IsTagged() { /* ok */ }  // correct\n```\n\n## Policy Engine\n\n`hscontrol/policy/v2/policy.go` is the policy implementation. The\ntop-level `hscontrol/policy/policy.go` contains only wrapper functions\naround v2. There is no v1 directory.\n\nKey concepts an agent will encounter:\n\n- **Autogroups**: `autogroup:self`, `autogroup:member`, `autogroup:internet`\n- **Tag owners**: IP-based authorization for who can claim a tag\n- **Route approvals**: auto-approval of subnet routes by policy\n- **SSH policies**: SSH access control via grants\n- **HuJSON** parsing for policy files\n\nFor usage examples, read `hscontrol/policy/v2/policy_test.go`. For ACL\nreference documentation, see `docs/`.\n\n## Integration Testing\n\n**Before running any `hi` command, read `cmd/hi/README.md` in full.**\nGuessing at `hi` flags leads to broken runs and stale containers.\n\nTest-authoring patterns (`EventuallyWithT`, `IntegrationSkip`, helper\nvariants, scenario setup) are documented in `integration/README.md`.\n\nKey reminders:\n\n- Integration test functions **must** start with `IntegrationSkip(t)`.\n- External calls (`client.Status`, `headscale.ListNodes`, etc.) belong\n  inside `EventuallyWithT`; state-mutating commands (`tailscale set`)\n  must not.\n- Tests generate ~100 MB of logs per run under `control_logs/{runID}/`.\n  Prune old runs if disk is tight.\n- Flakes are almost always code, not infrastructure. Read `hs-*.stderr.log`\n  before blaming Docker.\n\n## Version Bumps\n\n`tools/bump` keeps the pinned versions current and opens one pull request a\nday. `bump plan` reports what is stale without touching anything; `bump verify`\nexits non-zero when the pins no longer agree.\n\nThree rules it encodes. A hand-written bump has to follow them too:\n\n1. **`modernc.org/libc` moves only to the version `modernc.org/sqlite`\n   requires**, and **`gvisor.dev/gvisor` only to the version `tailscale.com`\n   requires**. Both are read off the owner's own `go.mod` through\n   `proxy.golang.org`, never guessed. If the partner cannot be resolved,\n   neither half moves. The reasoning lives in the NOTE blocks in `go.mod`;\n   `go mod tidy` can detach those comments from the lines they document, so\n   check they are still attached.\n2. **Any `go.mod` or `go.sum` change needs `go run ./cmd/vendorhash update`.**\n   `flake.nix` reads the vendor hash from `flakehashes.json`, so skipping this\n   leaves a tree that cannot `nix build`.\n3. **`go.mod`'s `go` directive must not exceed the Go nixpkgs ships.** `go get`\n   raises it silently when a dependency demands a newer toolchain, and the go\n   command then downloads one, so `go build` succeeds locally. The nix builders\n   set `GOTOOLCHAIN=local` and fail. `bump verify` reports the drift; the tool\n   never edits the directive itself.\n\nThe tool does not encode one more rule: the DNS providers (`github.com/libdns/*`)\nand `github.com/caddyserver/certmagic` must agree on the `github.com/libdns/libdns`\nmajor version. One provider instance serves both node certificates and certmagic,\nso they move together; a provider on another libdns major cannot be added until\ncertmagic moves.\n\n`Dockerfile.tailscale-HEAD` and `Dockerfile.derper` compile a tailscale tree\ncloned from an unpinned branch, so their builder image is a floor, not a\ntarget: it has to be at least upstream's `go` directive. They set\n`GOTOOLCHAIN=auto` so an upstream bump degrades to a slower build rather than\na broken one.\n\n## Code Conventions\n\n- **Commit messages** follow Go-style `package: imperative description`.\n  Recent examples from `git log`:\n  - `db: scope DestroyUser to only delete the target user's pre-auth keys`\n  - `state: fix policy change race in UpdateNodeFromMapRequest`\n  - `integration: fix ACL tests for address-family-specific resolve`\n\n  Not Conventional Commits. No `feat:`/`chore:`/`docs:` prefixes.\n\n- **Protobuf regeneration**: changes under `proto/` require\n  `make generate` (which runs `buf generate`) and should land in a\n  **separate commit** from the callers that use the regenerated types.\n- **Formatting** is enforced by `golangci-lint` with `golines` (width 88)\n  and `gofumpt`. Run `make fmt` or rely on the pre-commit hook.\n- **Logging** uses `zerolog`. Prefer single-line chains\n  (`log.Info().Str(...).Msg(...)`). For 4+ fields or conditional fields,\n  build incrementally and **reassign** the event variable:\n  `e = e.Str(\"k\", v)`. Forgetting to reassign silently drops the field.\n- **Tests**: prefer `hscontrol/servertest/` for server-level tests that\n  don't need Docker — faster than full integration tests.\n- **View types in read paths**: response serializers must read through\n  `NodeView`/`UserView`/`PreAuthKeyView`/`CredentialView` accessors. `AsStruct()` clones the\n  whole record on every read — it is only for DB-write/merge clones and mutable\n  working copies, never to build an API response. `grep AsStruct hscontrol/api`\n  must come back empty.\n\n## Gotchas\n\n- **Database**: SQLite for local dev, PostgreSQL for integration-heavy\n  tests (`go run ./cmd/hi run \"...\" --postgres`). Some race conditions\n  only surface on one backend.\n- **NodeStore writes** rebuild a full snapshot. Measure before changing\n  hot-path code.\n- **`.claude/agents/` is deprecated.** Do not create new agent files\n  there. Put behavioural guidance in this file and procedural guidance\n  in the nearest README.\n- **Do not edit `gen/`** — it is regenerated from `proto/` by\n  `make generate`.\n- **Proto changes + code changes should be two commits**, not one.\n- **The NixOS test kit is a public contract** consumed by other projects'\n  NixOS tests: the \"Contract\" in `nix/README.md`, covering\n  `nix/testkit.nix`, `nix/testkit-peer.nix`, the embedded DERP region\n  and `HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR`, and the CLI commands\n  `hs-authkey` calls. Breaking it needs a \"NixOS test kit\" BREAKING entry\n  in `CHANGELOG.md`.\n"},"files":{"AGENTS.md":"# AGENTS.md\n\nBehavioural guidance for AI agents working in this repository. Reference\nmaterial for complex procedures lives next to the code — integration\ntesting is documented in [`cmd/hi/README.md`](cmd/hi/README.md) and\n[`integration/README.md`](integration/README.md). Read those files\nbefore running tests or writing new ones.\n\nHeadscale is an open-source implementation of the Tailscale control server\nwritten in Go. It manages node registration, IP allocation, policy\nenforcement, and DERP routing for self-hosted tailnets.\n\n## Interaction Rules\n\nThese rules govern how you work in this repo. They are listed first\nbecause they shape every other decision.\n\n### Ask with comprehensive multiple-choice options\n\nWhen you need to clarify intent, scope, or approach, use the\n`AskUserQuestion` tool (or a numbered list fallback) and present the user\nwith a comprehensive set of options. Cover the likely branches explicitly\nand include an \"other — please describe\" escape.\n\n- Bad: _\"How should I handle expired nodes?\"_\n- Good: _\"How should expired nodes be handled? (a) Remain visible to peers\n  but marked expired (current behaviour); (b) Hidden from peers entirely;\n  (c) Hidden from peers but visible in admin API; (d) Other.\"_\n\nThis matters more than you think — open-ended questions waste a round\ntrip and often produce a misaligned answer.\n\n### Read the documented procedure before running complex commands\n\nBefore invoking any `hi` command, integration test, generator, or\nmigration tool, read the referenced README in full —\n`cmd/hi/README.md` for running tests, `integration/README.md` for\nwriting them. Never guess flags. If the procedure is not documented\nanywhere, ask the user rather than inventing one.\n\n### Map once, then act\n\nUse `Glob` / `Grep` to understand file structure, then execute. Do not\nre-explore the same area to \"double-check\" once you have a plan. Do not\nre-read files you edited in this session — the harness tracks state for\nyou.\n\n### Fail fast, report up\n\nIf a command fails twice with the same error, stop and report the exact\nerror to the user with context. Do not loop through variants or\n\"try one more thing\". A repeated failure means your model of the problem\nis wrong.\n\n### Confirm scope for multi-file changes\n\nBefore touching more than three files, show the user which files will\nchange and why. Use plan mode (`ExitPlanMode`) for non-trivial work.\n\n### Prefer editing existing files\n\nDo not create new files unless strictly necessary. Do not generate helper\nabstractions, wrapper utilities, or \"just in case\" configuration. Three\nsimilar lines of code is better than a premature abstraction.\n\n## Quick Start\n\n```bash\n# Enter the nix dev shell (Go 1.27.3, buf, golangci-lint, prek)\nnix develop\n\n# Full development workflow: fmt + lint + test + build\nmake dev\n\n# Individual targets\nmake build           # build the headscale binary\nmake test            # go test ./...\nmake fmt             # format Go, docs, proto\nmake lint            # lint Go, proto\nmake generate        # regenerate protobuf code (after changes to proto/)\nmake clean           # remove build artefacts\n\n# Direct go test invocations\ngo test ./...\ngo test -race ./...\n\n# Integration tests — read cmd/hi/README.md first\ngo run ./cmd/hi doctor\ngo run ./cmd/hi run \"TestName\"\n\n# Version pins — see \"Version Bumps\" below\ngo run ./tools/bump plan     # what is stale, changes nothing\ngo run ./tools/bump verify   # do the pins still agree\n```\n\nGo 1.27.3 minimum (per `go.mod:3`). `nix develop` pins the exact toolchain\nused in CI.\n\n## Pre-Commit with prek\n\n`prek` installs git hooks that run the same checks as CI.\n\n```bash\nnix develop\nprek install            # one-time setup\nprek run                # run hooks on staged files\nprek run --all-files    # run hooks on the full tree\n```\n\nHooks cover: file hygiene (trailing whitespace, line endings, BOM),\nsyntax validation (JSON/YAML/TOML/XML), merge-conflict markers, private\nkey detection, `treefmt` (the same formatter `nix fmt` and the flake's\nformatting check run), `mdformat` for `docs/`, and `golangci-lint` via\n`--new-from-rev=HEAD~1` (see the golangci-lint hook in .pre-commit-config.yaml). A manual\ninvocation with an `upstream/main` remote is equivalent:\n\n```bash\ngolangci-lint run --new-from-rev=upstream/main --timeout=5m --fix\n```\n\n`git commit --no-verify` is acceptable only for WIP commits on feature\nbranches — never on `main`.\n\n## Project Layout\n\n```\nheadscale/\n├── cmd/\n│   ├── headscale/    # Main headscale server binary\n│   └── hi/           # Integration test runner (see cmd/hi/README.md)\n├── hscontrol/        # Core control plane\n├── integration/      # End-to-end Docker-based tests (see integration/README.md)\n├── proto/            # Protocol buffer definitions\n├── gen/              # Generated code (buf output — do not edit)\n├── docs/             # User and ACL reference documentation\n└── packaging/        # Distribution packaging\n```\n\n### `hscontrol/` packages\n\n- `app.go`, `handlers.go`, `grpcv1.go`, `noise.go`, `auth.go`, `oidc.go`,\n  `poll.go`, `metrics.go`, `debug.go`, `tailsql.go`, `platform_config.go`\n  — top-level server files\n- `state/` — central coordinator (`state.go`) and the copy-on-write\n  `NodeStore` (`node_store.go`). All cross-subsystem operations go\n  through `State`.\n- `db/` — GORM layer, migrations, schema. `node.go`, `users.go`,\n  `ip.go`, `policy.go`; credentials (API keys, pre-auth keys, OAuth) share\n  one table: `secret.go` (generate/verify), `api_key.go`,\n  `preauth_keys.go`, `oauth.go`, `migrate_credentials.go`.\n- `mapper/` — streaming batcher that distributes MapResponses to\n  clients: `batcher.go`, `node_conn.go`, `builder.go`, `mapper.go`.\n  Performance-critical.\n- `policy/` — `policy/v2/` is **the** policy implementation. The\n  top-level `policy.go` is thin wrappers. There is no v1 directory.\n- `routes/`, `dns/`, `derp/`, `types/`, `util/`, `templates/`, `capver/`\n  — routing, MagicDNS, relay, core types, helpers, client templates,\n  capability versioning.\n- `servertest/` — in-memory test harness for server-level tests that\n  don't need Docker. Prefer this over `integration/` when possible.\n- `assets/` — embedded UI assets.\n\n### `cmd/hi/` files\n\n`main.go`, `run.go`, `doctor.go`, `docker.go`, `cleanup.go`, `stats.go`,\n`README.md`. **Read `cmd/hi/README.md` before running any `hi` command.**\n\n## Architecture Essentials\n\n- **`hscontrol/state/state.go`** is the central coordinator. Cross-cutting\n  operations (node updates, policy evaluation, IP allocation) go through\n  the `State` type, not directly to the database.\n- **`NodeStore`** in `hscontrol/state/node_store.go` is a copy-on-write\n  in-memory cache backed by `atomic.Pointer[Snapshot]`. Every read is a\n  pointer load; writes rebuild a new snapshot and atomically swap. It is\n  the hot path for `MapRequest` processing and peer visibility.\n- **The map-request sync point** is\n  `State.UpdateNodeFromMapRequest()` in `hscontrol/state/state.go`. This\n  is where Hostinfo changes, endpoint updates, and route advertisements\n  land in the NodeStore.\n- **Mapper subsystem** streams MapResponses via `batcher.go` and\n  `node_conn.go`. Changes here affect all connected clients.\n- **Node registration flow**: noise handshake (`noise.go`) → auth\n  (`auth.go`) → state/DB persistence (`state/`, `db/`) → initial map\n  (`mapper/`).\n\n## Database Migration Rules\n\nThese rules are load-bearing — violating them corrupts production\ndatabases. Migrations start at 0.29.0; `checkMinimumMigration` in\n`hscontrol/db/versioncheck.go` refuses older databases. All new\nmigrations must:\n\n1. **Never reorder existing migrations.** Migration order is immutable\n   once committed.\n2. **Only add new migrations to the end** of the migrations array.\n3. **Never disable foreign keys.**\n4. **Use the migration ID format** `YYYYMMDDHHMM-short-description`\n   (timestamp + descriptive suffix). Example: `202607241200-clear-tagged-node-expiry`.\n5. **Never use `AutoMigrate`** in a migration; write explicit DDL.\n6. **Run multi-statement migrations in `tx.Transaction`** so a failure\n   leaves the database retryable.\n\n## Tags-as-Identity\n\nHeadscale enforces **tags XOR user ownership**: every node is either\ntagged (owned by tags) or user-owned (owned by a user namespace), never\nboth. This is a load-bearing architectural rule.\n\n- **Use `node.IsTagged()`** (`hscontrol/types/node.go`) to determine\n  ownership, not `node.UserID().Valid()`. A tagged node may still have\n  `UserID` set for \"created by\" tracking — `IsTagged()` is authoritative.\n- `IsUserOwned()` returns `!IsTagged()`.\n- Tagged nodes are presented to Tailscale as the special\n  `TaggedDevices` user (`hscontrol/types/users.go`, ID `2147455555`).\n- `SetTags` validation is enforced by `validateNodeOwnership()` in\n  `hscontrol/state/tags.go`.\n- Examples and edge cases live in `hscontrol/types/node_tags_test.go`\n  and `hscontrol/grpcv1_test.go` (`TestSetTags_*`).\n\n**Don't do this**:\n\n```go\nif node.UserID().Valid() { /* assume user-owned */ }       // WRONG\nif node.UserID().Valid() && !node.IsTagged() { /* ok */ }  // correct\n```\n\n## Policy Engine\n\n`hscontrol/policy/v2/policy.go` is the policy implementation. The\ntop-level `hscontrol/policy/policy.go` contains only wrapper functions\naround v2. There is no v1 directory.\n\nKey concepts an agent will encounter:\n\n- **Autogroups**: `autogroup:self`, `autogroup:member`, `autogroup:internet`\n- **Tag owners**: IP-based authorization for who can claim a tag\n- **Route approvals**: auto-approval of subnet routes by policy\n- **SSH policies**: SSH access control via grants\n- **HuJSON** parsing for policy files\n\nFor usage examples, read `hscontrol/policy/v2/policy_test.go`. For ACL\nreference documentation, see `docs/`.\n\n## Integration Testing\n\n**Before running any `hi` command, read `cmd/hi/README.md` in full.**\nGuessing at `hi` flags leads to broken runs and stale containers.\n\nTest-authoring patterns (`EventuallyWithT`, `IntegrationSkip`, helper\nvariants, scenario setup) are documented in `integration/README.md`.\n\nKey reminders:\n\n- Integration test functions **must** start with `IntegrationSkip(t)`.\n- External calls (`client.Status`, `headscale.ListNodes`, etc.) belong\n  inside `EventuallyWithT`; state-mutating commands (`tailscale set`)\n  must not.\n- Tests generate ~100 MB of logs per run under `control_logs/{runID}/`.\n  Prune old runs if disk is tight.\n- Flakes are almost always code, not infrastructure. Read `hs-*.stderr.log`\n  before blaming Docker.\n\n## Version Bumps\n\n`tools/bump` keeps the pinned versions current and opens one pull request a\nday. `bump plan` reports what is stale without touching anything; `bump verify`\nexits non-zero when the pins no longer agree.\n\nThree rules it encodes. A hand-written bump has to follow them too:\n\n1. **`modernc.org/libc` moves only to the version `modernc.org/sqlite`\n   requires**, and **`gvisor.dev/gvisor` only to the version `tailscale.com`\n   requires**. Both are read off the owner's own `go.mod` through\n   `proxy.golang.org`, never guessed. If the partner cannot be resolved,\n   neither half moves. The reasoning lives in the NOTE blocks in `go.mod`;\n   `go mod tidy` can detach those comments from the lines they document, so\n   check they are still attached.\n2. **Any `go.mod` or `go.sum` change needs `go run ./cmd/vendorhash update`.**\n   `flake.nix` reads the vendor hash from `flakehashes.json`, so skipping this\n   leaves a tree that cannot `nix build`.\n3. **`go.mod`'s `go` directive must not exceed the Go nixpkgs ships.** `go get`\n   raises it silently when a dependency demands a newer toolchain, and the go\n   command then downloads one, so `go build` succeeds locally. The nix builders\n   set `GOTOOLCHAIN=local` and fail. `bump verify` reports the drift; the tool\n   never edits the directive itself.\n\nThe tool does not encode one more rule: the DNS providers (`github.com/libdns/*`)\nand `github.com/caddyserver/certmagic` must agree on the `github.com/libdns/libdns`\nmajor version. One provider instance serves both node certificates and certmagic,\nso they move together; a provider on another libdns major cannot be added until\ncertmagic moves.\n\n`Dockerfile.tailscale-HEAD` and `Dockerfile.derper` compile a tailscale tree\ncloned from an unpinned branch, so their builder image is a floor, not a\ntarget: it has to be at least upstream's `go` directive. They set\n`GOTOOLCHAIN=auto` so an upstream bump degrades to a slower build rather than\na broken one.\n\n## Code Conventions\n\n- **Commit messages** follow Go-style `package: imperative description`.\n  Recent examples from `git log`:\n  - `db: scope DestroyUser to only delete the target user's pre-auth keys`\n  - `state: fix policy change race in UpdateNodeFromMapRequest`\n  - `integration: fix ACL tests for address-family-specific resolve`\n\n  Not Conventional Commits. No `feat:`/`chore:`/`docs:` prefixes.\n\n- **Protobuf regeneration**: changes under `proto/` require\n  `make generate` (which runs `buf generate`) and should land in a\n  **separate commit** from the callers that use the regenerated types.\n- **Formatting** is enforced by `golangci-lint` with `golines` (width 88)\n  and `gofumpt`. Run `make fmt` or rely on the pre-commit hook.\n- **Logging** uses `zerolog`. Prefer single-line chains\n  (`log.Info().Str(...).Msg(...)`). For 4+ fields or conditional fields,\n  build incrementally and **reassign** the event variable:\n  `e = e.Str(\"k\", v)`. Forgetting to reassign silently drops the field.\n- **Tests**: prefer `hscontrol/servertest/` for server-level tests that\n  don't need Docker — faster than full integration tests.\n- **View types in read paths**: response serializers must read through\n  `NodeView`/`UserView`/`PreAuthKeyView`/`CredentialView` accessors. `AsStruct()` clones the\n  whole record on every read — it is only for DB-write/merge clones and mutable\n  working copies, never to build an API response. `grep AsStruct hscontrol/api`\n  must come back empty.\n\n## Gotchas\n\n- **Database**: SQLite for local dev, PostgreSQL for integration-heavy\n  tests (`go run ./cmd/hi run \"...\" --postgres`). Some race conditions\n  only surface on one backend.\n- **NodeStore writes** rebuild a full snapshot. Measure before changing\n  hot-path code.\n- **`.claude/agents/` is deprecated.** Do not create new agent files\n  there. Put behavioural guidance in this file and procedural guidance\n  in the nearest README.\n- **Do not edit `gen/`** — it is regenerated from `proto/` by\n  `make generate`.\n- **Proto changes + code changes should be two commits**, not one.\n- **The NixOS test kit is a public contract** consumed by other projects'\n  NixOS tests: the \"Contract\" in `nix/README.md`, covering\n  `nix/testkit.nix`, `nix/testkit-peer.nix`, the embedded DERP region\n  and `HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR`, and the CLI commands\n  `hs-authkey` calls. Breaking it needs a \"NixOS test kit\" BREAKING entry\n  in `CHANGELOG.md`.\n"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/juanfont/headscale/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# AGENTS.md\n\nBehavioural guidance for AI agents working in this repository. Reference\nmaterial for complex procedures lives next to the code — integration\ntesting is documented in [`cmd/hi/README.md`](cmd/hi/README.md) and\n[`integration/README.md`](integration/README.md). Read those files\nbefore running tests or writing new ones.\n\nHeadscale is an open-source implementation of the Tailscale control server\nwritten in Go. It manages node registration, IP allocation, policy\nenforcement, and DERP routing for self-hosted tailnets.\n\n## Interaction Rules\n\nThese rules govern how you work in this repo. They are listed first\nbecause they shape every other decision.\n\n### Ask with comprehensive multiple-choice options\n\nWhen you need to clarify intent, scope, or approach, use the\n`AskUserQuestion` tool (or a numbered list fallback) and present the user\nwith a comprehensive set of options. Cover the likely branches explicitly\nand include an \"other — please describe\" escape.\n\n- Bad: _\"How should I handle expired nodes?\"_\n- Good: _\"How should expired nodes be handled? (a) Remain visible to peers\n  but marked expired (current behaviour); (b) Hidden from peers entirely;\n  (c) Hidden from peers but visible in admin API; (d) Other.\"_\n\nThis matters more than you think — open-ended questions waste a round\ntrip and often produce a misaligned answer.\n\n### Read the documented procedure before running complex commands\n\nBefore invoking any `hi` command, integration test, generator, or\nmigration tool, read the referenced README in full —\n`cmd/hi/README.md` for running tests, `integration/README.md` for\nwriting them. Never guess flags. If the procedure is not documented\nanywhere, ask the user rather than inventing one.\n\n### Map once, then act\n\nUse `Glob` / `Grep` to understand file structure, then execute. Do not\nre-explore the same area to \"double-check\" once you have a plan. Do not\nre-read files you edited in this session — the harness tracks state for\nyou.\n\n### Fail fast, report up\n\nIf a command fails twice with the same error, stop and report the exact\nerror to the user with context. Do not loop through variants or\n\"try one more thing\". A repeated failure means your model of the problem\nis wrong.\n\n### Confirm scope for multi-file changes\n\nBefore touching more than three files, show the user which files will\nchange and why. Use plan mode (`ExitPlanMode`) for non-trivial work.\n\n### Prefer editing existing files\n\nDo not create new files unless strictly necessary. Do not generate helper\nabstractions, wrapper utilities, or \"just in case\" configuration. Three\nsimilar lines of code is better than a premature abstraction.\n\n## Quick Start\n\n```bash\n# Enter the nix dev shell (Go 1.27.3, buf, golangci-lint, prek)\nnix develop\n\n# Full development workflow: fmt + lint + test + build\nmake dev\n\n# Individual targets\nmake build           # build the headscale binary\nmake test            # go test ./...\nmake fmt             # format Go, docs, proto\nmake lint            # lint Go, proto\nmake generate        # regenerate protobuf code (after changes to proto/)\nmake clean           # remove build artefacts\n\n# Direct go test invocations\ngo test ./...\ngo test -race ./...\n\n# Integration tests — read cmd/hi/README.md first\ngo run ./cmd/hi doctor\ngo run ./cmd/hi run \"TestName\"\n\n# Version pins — see \"Version Bumps\" below\ngo run ./tools/bump plan     # what is stale, changes nothing\ngo run ./tools/bump verify   # do the pins still agree\n```\n\nGo 1.27.3 minimum (per `go.mod:3`). `nix develop` pins the exact toolchain\nused in CI.\n\n## Pre-Commit with prek\n\n`prek` installs git hooks that run the same checks as CI.\n\n```bash\nnix develop\nprek install            # one-time setup\nprek run                # run hooks on staged files\nprek run --all-files    # run hooks on the full tree\n```\n\nHooks cover: file hygiene (trailing whitespace, line endings, BOM),\nsyntax validation (JSON/YAML/TOML/XML), merge-conflict markers, private\nkey detection, `treefmt` (the same formatter `nix fmt` and the flake's\nformatting check run), `mdformat` for `docs/`, and `golangci-lint` via\n`--new-from-rev=HEAD~1` (see the golangci-lint hook in .pre-commit-config.yaml). A manual\ninvocation with an `upstream/main` remote is equivalent:\n\n```bash\ngolangci-lint run --new-from-rev=upstream/main --timeout=5m --fix\n```\n\n`git commit --no-verify` is acceptable only for WIP commits on feature\nbranches — never on `main`.\n\n## Project Layout\n\n```\nheadscale/\n├── cmd/\n│   ├── headscale/    # Main headscale server binary\n│   └── hi/           # Integration test runner (see cmd/hi/README.md)\n├── hscontrol/        # Core control plane\n├── integration/      # End-to-end Docker-based tests (see integration/README.md)\n├── proto/            # Protocol buffer definitions\n├── gen/              # Generated code (buf output — do not edit)\n├── docs/             # User and ACL reference documentation\n└── packaging/        # Distribution packaging\n```\n\n### `hscontrol/` packages\n\n- `app.go`, `handlers.go`, `grpcv1.go`, `noise.go`, `auth.go`, `oidc.go`,\n  `poll.go`, `metrics.go`, `debug.go`, `tailsql.go`, `platform_config.go`\n  — top-level server files\n- `state/` — central coordinator (`state.go`) and the copy-on-write\n  `NodeStore` (`node_store.go`). All cross-subsystem operations go\n  through `State`.\n- `db/` — GORM layer, migrations, schema. `node.go`, `users.go`,\n  `ip.go`, `policy.go`; credentials (API keys, pre-auth keys, OAuth) share\n  one table: `secret.go` (generate/verify), `api_key.go`,\n  `preauth_keys.go`, `oauth.go`, `migrate_credentials.go`.\n- `mapper/` — streaming batcher that distributes MapResponses to\n  clients: `batcher.go`, `node_conn.go`, `builder.go`, `mapper.go`.\n  Performance-critical.\n- `policy/` — `policy/v2/` is **the** policy implementation. The\n  top-level `policy.go` is thin wrappers. There is no v1 directory.\n- `routes/`, `dns/`, `derp/`, `types/`, `util/`, `templates/`, `capver/`\n  — routing, MagicDNS, relay, core types, helpers, client templates,\n  capability versioning.\n- `servertest/` — in-memory test harness for server-level tests that\n  don't need Docker. Prefer this over `integration/` when possible.\n- `assets/` — embedded UI assets.\n\n### `cmd/hi/` files\n\n`main.go`, `run.go`, `doctor.go`, `docker.go`, `cleanup.go`, `stats.go`,\n`README.md`. **Read `cmd/hi/README.md` before running any `hi` command.**\n\n## Architecture Essentials\n\n- **`hscontrol/state/state.go`** is the central coordinator. Cross-cutting\n  operations (node updates, policy evaluation, IP allocation) go through\n  the `State` type, not directly to the database.\n- **`NodeStore`** in `hscontrol/state/node_store.go` is a copy-on-write\n  in-memory cache backed by `atomic.Pointer[Snapshot]`. Every read is a\n  pointer load; writes rebuild a new snapshot and atomically swap. It is\n  the hot path for `MapRequest` processing and peer visibility.\n- **The map-request sync point** is\n  `State.UpdateNodeFromMapRequest()` in `hscontrol/state/state.go`. This\n  is where Hostinfo changes, endpoint updates, and route advertisements\n  land in the NodeStore.\n- **Mapper subsystem** streams MapResponses via `batcher.go` and\n  `node_conn.go`. Changes here affect all connected clients.\n- **Node registration flow**: noise handshake (`noise.go`) → auth\n  (`auth.go`) → state/DB persistence (`state/`, `db/`) → initial map\n  (`mapper/`).\n\n## Database Migration Rules\n\nThese rules are load-bearing — violating them corrupts production\ndatabases. Migrations start at 0.29.0; `checkMinimumMigration` in\n`hscontrol/db/versioncheck.go` refuses older databases. All new\nmigrations must:\n\n1. **Never reorder existing migrations.** Migration order is immutable\n   once committed.\n2. **Only add new migrations to the end** of the migrations array.\n3. **Never disable foreign keys.**\n4. **Use the migration ID format** `YYYYMMDDHHMM-short-description`\n   (timestamp + descriptive suffix). Example: `202607241200-clear-tagged-node-expiry`.\n5. **Never use `AutoMigrate`** in a migration; write explicit DDL.\n6. **Run multi-statement migrations in `tx.Transaction`** so a failure\n   leaves the database retryable.\n\n## Tags-as-Identity\n\nHeadscale enforces **tags XOR user ownership**: every node is either\ntagged (owned by tags) or user-owned (owned by a user namespace), never\nboth. This is a load-bearing architectural rule.\n\n- **Use `node.IsTagged()`** (`hscontrol/types/node.go`) to determine\n  ownership, not `node.UserID().Valid()`. A tagged node may still have\n  `UserID` set for \"created by\" tracking — `IsTagged()` is authoritative.\n- `IsUserOwned()` returns `!IsTagged()`.\n- Tagged nodes are presented to Tailscale as the special\n  `TaggedDevices` user (`hscontrol/types/users.go`, ID `2147455555`).\n- `SetTags` validation is enforced by `validateNodeOwnership()` in\n  `hscontrol/state/tags.go`.\n- Examples and edge cases live in `hscontrol/types/node_tags_test.go`\n  and `hscontrol/grpcv1_test.go` (`TestSetTags_*`).\n\n**Don't do this**:\n\n```go\nif node.UserID().Valid() { /* assume user-owned */ }       // WRONG\nif node.UserID().Valid() && !node.IsTagged() { /* ok */ }  // correct\n```\n\n## Policy Engine\n\n`hscontrol/policy/v2/policy.go` is the policy implementation. The\ntop-level `hscontrol/policy/policy.go` contains only wrapper functions\naround v2. There is no v1 directory.\n\nKey concepts an agent will encounter:\n\n- **Autogroups**: `autogroup:self`, `autogroup:member`, `autogroup:internet`\n- **Tag owners**: IP-based authorization for who can claim a tag\n- **Route approvals**: auto-approval of subnet routes by policy\n- **SSH policies**: SSH access control via grants\n- **HuJSON** parsing for policy files\n\nFor usage examples, read `hscontrol/policy/v2/policy_test.go`. For ACL\nreference documentation, see `docs/`.\n\n## Integration Testing\n\n**Before running any `hi` command, read `cmd/hi/README.md` in full.**\nGuessing at `hi` flags leads to broken runs and stale containers.\n\nTest-authoring patterns (`EventuallyWithT`, `IntegrationSkip`, helper\nvariants, scenario setup) are documented in `integration/README.md`.\n\nKey reminders:\n\n- Integration test functions **must** start with `IntegrationSkip(t)`.\n- External calls (`client.Status`, `headscale.ListNodes`, etc.) belong\n  inside `EventuallyWithT`; state-mutating commands (`tailscale set`)\n  must not.\n- Tests generate ~100 MB of logs per run under `control_logs/{runID}/`.\n  Prune old runs if disk is tight.\n- Flakes are almost always code, not infrastructure. Read `hs-*.stderr.log`\n  before blaming Docker.\n\n## Version Bumps\n\n`tools/bump` keeps the pinned versions current and opens one pull request a\nday. `bump plan` reports what is stale without touching anything; `bump verify`\nexits non-zero when the pins no longer agree.\n\nThree rules it encodes. A hand-written bump has to follow them too:\n\n1. **`modernc.org/libc` moves only to the version `modernc.org/sqlite`\n   requires**, and **`gvisor.dev/gvisor` only to the version `tailscale.com`\n   requires**. Both are read off the owner's own `go.mod` through\n   `proxy.golang.org`, never guessed. If the partner cannot be resolved,\n   neither half moves. The reasoning lives in the NOTE blocks in `go.mod`;\n   `go mod tidy` can detach those comments from the lines they document, so\n   check they are still attached.\n2. **Any `go.mod` or `go.sum` change needs `go run ./cmd/vendorhash update`.**\n   `flake.nix` reads the vendor hash from `flakehashes.json`, so skipping this\n   leaves a tree that cannot `nix build`.\n3. **`go.mod`'s `go` directive must not exceed the Go nixpkgs ships.** `go get`\n   raises it silently when a dependency demands a newer toolchain, and the go\n   command then downloads one, so `go build` succeeds locally. The nix builders\n   set `GOTOOLCHAIN=local` and fail. `bump verify` reports the drift; the tool\n   never edits the directive itself.\n\nThe tool does not encode one more rule: the DNS providers (`github.com/libdns/*`)\nand `github.com/caddyserver/certmagic` must agree on the `github.com/libdns/libdns`\nmajor version. One provider instance serves both node certificates and certmagic,\nso they move together; a provider on another libdns major cannot be added until\ncertmagic moves.\n\n`Dockerfile.tailscale-HEAD` and `Dockerfile.derper` compile a tailscale tree\ncloned from an unpinned branch, so their builder image is a floor, not a\ntarget: it has to be at least upstream's `go` directive. They set\n`GOTOOLCHAIN=auto` so an upstream bump degrades to a slower build rather than\na broken one.\n\n## Code Conventions\n\n- **Commit messages** follow Go-style `package: imperative description`.\n  Recent examples from `git log`:\n  - `db: scope DestroyUser to only delete the target user's pre-auth keys`\n  - `state: fix policy change race in UpdateNodeFromMapRequest`\n  - `integration: fix ACL tests for address-family-specific resolve`\n\n  Not Conventional Commits. No `feat:`/`chore:`/`docs:` prefixes.\n\n- **Protobuf regeneration**: changes under `proto/` require\n  `make generate` (which runs `buf generate`) and should land in a\n  **separate commit** from the callers that use the regenerated types.\n- **Formatting** is enforced by `golangci-lint` with `golines` (width 88)\n  and `gofumpt`. Run `make fmt` or rely on the pre-commit hook.\n- **Logging** uses `zerolog`. Prefer single-line chains\n  (`log.Info().Str(...).Msg(...)`). For 4+ fields or conditional fields,\n  build incrementally and **reassign** the event variable:\n  `e = e.Str(\"k\", v)`. Forgetting to reassign silently drops the field.\n- **Tests**: prefer `hscontrol/servertest/` for server-level tests that\n  don't need Docker — faster than full integration tests.\n- **View types in read paths**: response serializers must read through\n  `NodeView`/`UserView`/`PreAuthKeyView`/`CredentialView` accessors. `AsStruct()` clones the\n  whole record on every read — it is only for DB-write/merge clones and mutable\n  working copies, never to build an API response. `grep AsStruct hscontrol/api`\n  must come back empty.\n\n## Gotchas\n\n- **Database**: SQLite for local dev, PostgreSQL for integration-heavy\n  tests (`go run ./cmd/hi run \"...\" --postgres`). Some race conditions\n  only surface on one backend.\n- **NodeStore writes** rebuild a full snapshot. Measure before changing\n  hot-path code.\n- **`.claude/agents/` is deprecated.** Do not create new agent files\n  there. Put behavioural guidance in this file and procedural guidance\n  in the nearest README.\n- **Do not edit `gen/`** — it is regenerated from `proto/` by\n  `make generate`.\n- **Proto changes + code changes should be two commits**, not one.\n- **The NixOS test kit is a public contract** consumed by other projects'\n  NixOS tests: the \"Contract\" in `nix/README.md`, covering\n  `nix/testkit.nix`, `nix/testkit-peer.nix`, the embedded DERP region\n  and `HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR`, and the CLI commands\n  `hs-authkey` calls. Breaking it needs a \"NixOS test kit\" BREAKING entry\n  in `CHANGELOG.md`.\n","isInternal":false,"tokens":3805,"sizeBytes":14973}],"systemPromptSnippet":"<yakaai_skills repo=\"juanfont/headscale\">\n<!-- File: AGENTS.md (Tokens: ~3805 | Category: root-instruction) -->\n# AGENTS.md\n\nBehavioural guidance for AI agents working in this repository. Reference\nmaterial for complex procedures lives next to the code — integration\ntesting is documented in [`cmd/hi/README.md`](cmd/hi/README.md) and\n[`integration/README.md`](integration/README.md). Read those files\nbefore running tests or writing new ones.\n\nHeadscale is an open-source implementation of the Tailscale control server\nwritten in Go. It manages node registration, IP allocation, policy\nenforcement, and DERP routing for self-hosted tailnets.\n\n## Interaction Rules\n\nThese rules govern how you work in this repo. They are listed first\nbecause they shape every other decision.\n\n### Ask with comprehensive multiple-choice options\n\nWhen you need to clarify intent, scope, or approach, use the\n`AskUserQuestion` tool (or a numbered list fallback) and present the user\nwith a comprehensive set of options. Cover the likely branches explicitly\nand include an \"other — please describe\" escape.\n\n- Bad: _\"How should I handle expired nodes?\"_\n- Good: _\"How should expired nodes be handled? (a) Remain visible to peers\n  but marked expired (current behaviour); (b) Hidden from peers entirely;\n  (c) Hidden from peers but visible in admin API; (d) Other.\"_\n\nThis matters more than you think — open-ended questions waste a round\ntrip and often produce a misaligned answer.\n\n### Read the documented procedure before running complex commands\n\nBefore invoking any `hi` command, integration test, generator, or\nmigration tool, read the referenced README in full —\n`cmd/hi/README.md` for running tests, `integration/README.md` for\nwriting them. Never guess flags. If the procedure is not documented\nanywhere, ask the user rather than inventing one.\n\n### Map once, then act\n\nUse `Glob` / `Grep` to understand file structure, then execute. Do not\nre-explore the same area to \"double-check\" once you have a plan. Do not\nre-read files you edited in this session — the harness tracks state for\nyou.\n\n### Fail fast, report up\n\nIf a command fails twice with the same error, stop and report the exact\nerror to the user with context. Do not loop through variants or\n\"try one more thing\". A repeated failure means your model of the problem\nis wrong.\n\n### Confirm scope for multi-file changes\n\nBefore touching more than three files, show the user which files will\nchange and why. Use plan mode (`ExitPlanMode`) for non-trivial work.\n\n### Prefer editing existing files\n\nDo not create new files unless strictly necessary. Do not generate helper\nabstractions, wrapper utilities, or \"just in case\" configuration. Three\nsimilar lines of code is better than a premature abstraction.\n\n## Quick Start\n\n```bash\n# Enter the nix dev shell (Go 1.27.3, buf, golangci-lint, prek)\nnix develop\n\n# Full development workflow: fmt + lint + test + build\nmake dev\n\n# Individual targets\nmake build           # build the headscale binary\nmake test            # go test ./...\nmake fmt             # format Go, docs, proto\nmake lint            # lint Go, proto\nmake generate        # regenerate protobuf code (after changes to proto/)\nmake clean           # remove build artefacts\n\n# Direct go test invocations\ngo test ./...\ngo test -race ./...\n\n# Integration tests — read cmd/hi/README.md first\ngo run ./cmd/hi doctor\ngo run ./cmd/hi run \"TestName\"\n\n# Version pins — see \"Version Bumps\" below\ngo run ./tools/bump plan     # what is stale, changes nothing\ngo run ./tools/bump verify   # do the pins still agree\n```\n\nGo 1.27.3 minimum (per `go.mod:3`). `nix develop` pins the exact toolchain\nused in CI.\n\n## Pre-Commit with prek\n\n`prek` installs git hooks that run the same checks as CI.\n\n```bash\nnix develop\nprek install            # one-time setup\nprek run                # run hooks on staged files\nprek run --all-files    # run hooks on the full tree\n```\n\nHooks cover: file hygiene (trailing whitespace, line endings, BOM),\nsyntax validation (JSON/YAML/TOML/XML), merge-conflict markers, private\nkey detection, `treefmt` (the same formatter `nix fmt` and the flake's\nformatting check run), `mdformat` for `docs/`, and `golangci-lint` via\n`--new-from-rev=HEAD~1` (see the golangci-lint hook in .pre-commit-config.yaml). A manual\ninvocation with an `upstream/main` remote is equivalent:\n\n```bash\ngolangci-lint run --new-from-rev=upstream/main --timeout=5m --fix\n```\n\n`git commit --no-verify` is acceptable only for WIP commits on feature\nbranches — never on `main`.\n\n## Project Layout\n\n```\nheadscale/\n├── cmd/\n│   ├── headscale/    # Main headscale server binary\n│   └── hi/           # Integration test runner (see cmd/hi/README.md)\n├── hscontrol/        # Core control plane\n├── integration/      # End-to-end Docker-based tests (see integration/README.md)\n├── proto/            # Protocol buffer definitions\n├── gen/              # Generated code (buf output — do not edit)\n├── docs/             # User and ACL reference documentation\n└── packaging/        # Distribution packaging\n```\n\n### `hscontrol/` packages\n\n- `app.go`, `handlers.go`, `grpcv1.go`, `noise.go`, `auth.go`, `oidc.go`,\n  `poll.go`, `metrics.go`, `debug.go`, `tailsql.go`, `platform_config.go`\n  — top-level server files\n- `state/` — central coordinator (`state.go`) and the copy-on-write\n  `NodeStore` (`node_store.go`). All cross-subsystem operations go\n  through `State`.\n- `db/` — GORM layer, migrations, schema. `node.go`, `users.go`,\n  `ip.go`, `policy.go`; credentials (API keys, pre-auth keys, OAuth) share\n  one table: `secret.go` (generate/verify), `api_key.go`,\n  `preauth_keys.go`, `oauth.go`, `migrate_credentials.go`.\n- `mapper/` — streaming batcher that distributes MapResponses to\n  clients: `batcher.go`, `node_conn.go`, `builder.go`, `mapper.go`.\n  Performance-critical.\n- `policy/` — `policy/v2/` is **the** policy implementation. The\n  top-level `policy.go` is thin wrappers. There is no v1 directory.\n- `routes/`, `dns/`, `derp/`, `types/`, `util/`, `templates/`, `capver/`\n  — routing, MagicDNS, relay, core types, helpers, client templates,\n  capability versioning.\n- `servertest/` — in-memory test harness for server-level tests that\n  don't need Docker. Prefer this over `integration/` when possible.\n- `assets/` — embedded UI assets.\n\n### `cmd/hi/` files\n\n`main.go`, `run.go`, `doctor.go`, `docker.go`, `cleanup.go`, `stats.go`,\n`README.md`. **Read `cmd/hi/README.md` before running any `hi` command.**\n\n## Architecture Essentials\n\n- **`hscontrol/state/state.go`** is the central coordinator. Cross-cutting\n  operations (node updates, policy evaluation, IP allocation) go through\n  the `State` type, not directly to the database.\n- **`NodeStore`** in `hscontrol/state/node_store.go` is a copy-on-write\n  in-memory cache backed by `atomic.Pointer[Snapshot]`. Every read is a\n  pointer load; writes rebuild a new snapshot and atomically swap. It is\n  the hot path for `MapRequest` processing and peer visibility.\n- **The map-request sync point** is\n  `State.UpdateNodeFromMapRequest()` in `hscontrol/state/state.go`. This\n  is where Hostinfo changes, endpoint updates, and route advertisements\n  land in the NodeStore.\n- **Mapper subsystem** streams MapResponses via `batcher.go` and\n  `node_conn.go`. Changes here affect all connected clients.\n- **Node registration flow**: noise handshake (`noise.go`) → auth\n  (`auth.go`) → state/DB persistence (`state/`, `db/`) → initial map\n  (`mapper/`).\n\n## Database Migration Rules\n\nThese rules are load-bearing — violating them corrupts production\ndatabases. Migrations start at 0.29.0; `checkMinimumMigration` in\n`hscontrol/db/versioncheck.go` refuses older databases. All new\nmigrations must:\n\n1. **Never reorder existing migrations.** Migration order is immutable\n   once committed.\n2. **Only add new migrations to the end** of the migrations array.\n3. **Never disable foreign keys.**\n4. **Use the migration ID format** `YYYYMMDDHHMM-short-description`\n   (timestamp + descriptive suffix). Example: `202607241200-clear-tagged-node-expiry`.\n5. **Never use `AutoMigrate`** in a migration; write explicit DDL.\n6. **Run multi-statement migrations in `tx.Transaction`** so a failure\n   leaves the database retryable.\n\n## Tags-as-Identity\n\nHeadscale enforces **tags XOR user ownership**: every node is either\ntagged (owned by tags) or user-owned (owned by a user namespace), never\nboth. This is a load-bearing architectural rule.\n\n- **Use `node.IsTagged()`** (`hscontrol/types/node.go`) to determine\n  ownership, not `node.UserID().Valid()`. A tagged node may still have\n  `UserID` set for \"created by\" tracking — `IsTagged()` is authoritative.\n- `IsUserOwned()` returns `!IsTagged()`.\n- Tagged nodes are presented to Tailscale as the special\n  `TaggedDevices` user (`hscontrol/types/users.go`, ID `2147455555`).\n- `SetTags` validation is enforced by `validateNodeOwnership()` in\n  `hscontrol/state/tags.go`.\n- Examples and edge cases live in `hscontrol/types/node_tags_test.go`\n  and `hscontrol/grpcv1_test.go` (`TestSetTags_*`).\n\n**Don't do this**:\n\n```go\nif node.UserID().Valid() { /* assume user-owned */ }       // WRONG\nif node.UserID().Valid() && !node.IsTagged() { /* ok */ }  // correct\n```\n\n## Policy Engine\n\n`hscontrol/policy/v2/policy.go` is the policy implementation. The\ntop-level `hscontrol/policy/policy.go` contains only wrapper functions\naround v2. There is no v1 directory.\n\nKey concepts an agent will encounter:\n\n- **Autogroups**: `autogroup:self`, `autogroup:member`, `autogroup:internet`\n- **Tag owners**: IP-based authorization for who can claim a tag\n- **Route approvals**: auto-approval of subnet routes by policy\n- **SSH policies**: SSH access control via grants\n- **HuJSON** parsing for policy files\n\nFor usage examples, read `hscontrol/policy/v2/policy_test.go`. For ACL\nreference documentation, see `docs/`.\n\n## Integration Testing\n\n**Before running any `hi` command, read `cmd/hi/README.md` in full.**\nGuessing at `hi` flags leads to broken runs and stale containers.\n\nTest-authoring patterns (`EventuallyWithT`, `IntegrationSkip`, helper\nvariants, scenario setup) are documented in `integration/README.md`.\n\nKey reminders:\n\n- Integration test functions **must** start with `IntegrationSkip(t)`.\n- External calls (`client.Status`, `headscale.ListNodes`, etc.) belong\n  inside `EventuallyWithT`; state-mutating commands (`tailscale set`)\n  must not.\n- Tests generate ~100 MB of logs per run under `control_logs/{runID}/`.\n  Prune old runs if disk is tight.\n- Flakes are almost always code, not infrastructure. Read `hs-*.stderr.log`\n  before blaming Docker.\n\n## Version Bumps\n\n`tools/bump` keeps the pinned versions current and opens one pull request a\nday. `bump plan` reports what is stale without touching anything; `bump verify`\nexits non-zero when the pins no longer agree.\n\nThree rules it encodes. A hand-written bump has to follow them too:\n\n1. **`modernc.org/libc` moves only to the version `modernc.org/sqlite`\n   requires**, and **`gvisor.dev/gvisor` only to the version `tailscale.com`\n   requires**. Both are read off the owner's own `go.mod` through\n   `proxy.golang.org`, never guessed. If the partner cannot be resolved,\n   neither half moves. The reasoning lives in the NOTE blocks in `go.mod`;\n   `go mod tidy` can detach those comments from the lines they document, so\n   check they are still attached.\n2. **Any `go.mod` or `go.sum` change needs `go run ./cmd/vendorhash update`.**\n   `flake.nix` reads the vendor hash from `flakehashes.json`, so skipping this\n   leaves a tree that cannot `nix build`.\n3. **`go.mod`'s `go` directive must not exceed the Go nixpkgs ships.** `go get`\n   raises it silently when a dependency demands a newer toolchain, and the go\n   command then downloads one, so `go build` succeeds locally. The nix builders\n   set `GOTOOLCHAIN=local` and fail. `bump verify` reports the drift; the tool\n   never edits the directive itself.\n\nThe tool does not encode one more rule: the DNS providers (`github.com/libdns/*`)\nand `github.com/caddyserver/certmagic` must agree on the `github.com/libdns/libdns`\nmajor version. One provider instance serves both node certificates and certmagic,\nso they move together; a provider on another libdns major cannot be added until\ncertmagic moves.\n\n`Dockerfile.tailscale-HEAD` and `Dockerfile.derper` compile a tailscale tree\ncloned from an unpinned branch, so their builder image is a floor, not a\ntarget: it has to be at least upstream's `go` directive. They set\n`GOTOOLCHAIN=auto` so an upstream bump degrades to a slower build rather than\na broken one.\n\n## Code Conventions\n\n- **Commit messages** follow Go-style `package: imperative description`.\n  Recent examples from `git log`:\n  - `db: scope DestroyUser to only delete the target user's pre-auth keys`\n  - `state: fix policy change race in UpdateNodeFromMapRequest`\n  - `integration: fix ACL tests for address-family-specific resolve`\n\n  Not Conventional Commits. No `feat:`/`chore:`/`docs:` prefixes.\n\n- **Protobuf regeneration**: changes under `proto/` require\n  `make generate` (which runs `buf generate`) and should land in a\n  **separate commit** from the callers that use the regenerated types.\n- **Formatting** is enforced by `golangci-lint` with `golines` (width 88)\n  and `gofumpt`. Run `make fmt` or rely on the pre-commit hook.\n- **Logging** uses `zerolog`. Prefer single-line chains\n  (`log.Info().Str(...).Msg(...)`). For 4+ fields or conditional fields,\n  build incrementally and **reassign** the event variable:\n  `e = e.Str(\"k\", v)`. Forgetting to reassign silently drops the field.\n- **Tests**: prefer `hscontrol/servertest/` for server-level tests that\n  don't need Docker — faster than full integration tests.\n- **View types in read paths**: response serializers must read through\n  `NodeView`/`UserView`/`PreAuthKeyView`/`CredentialView` accessors. `AsStruct()` clones the\n  whole record on every read — it is only for DB-write/merge clones and mutable\n  working copies, never to build an API response. `grep AsStruct hscontrol/api`\n  must come back empty.\n\n## Gotchas\n\n- **Database**: SQLite for local dev, PostgreSQL for integration-heavy\n  tests (`go run ./cmd/hi run \"...\" --postgres`). Some race conditions\n  only surface on one backend.\n- **NodeStore writes** rebuild a full snapshot. Measure before changing\n  hot-path code.\n- **`.claude/agents/` is deprecated.** Do not create new agent files\n  there. Put behavioural guidance in this file and procedural guidance\n  in the nearest README.\n- **Do not edit `gen/`** — it is regenerated from `proto/` by\n  `make generate`.\n- **Proto changes + code changes should be two commits**, not one.\n- **The NixOS test kit is a public contract** consumed by other projects'\n  NixOS tests: the \"Contract\" in `nix/README.md`, covering\n  `nix/testkit.nix`, `nix/testkit-peer.nix`, the embedded DERP region\n  and `HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR`, and the CLI commands\n  `hs-authkey` calls. Breaking it needs a \"NixOS test kit\" BREAKING entry\n  in `CHANGELOG.md`.\n</yakaai_skills>"}