From 84fe3de251eb05302832b038740e08efebd09163 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 27 Oct 2025 12:08:52 +0100 Subject: [PATCH 001/178] integration: reduce TestAutoApproveMultiNetwork matrix to 3 tests (#2815) --- integration/hsic/hsic.go | 32 +++++++++++++++++--------------- integration/route_test.go | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/integration/hsic/hsic.go b/integration/hsic/hsic.go index 88fc4da2..775e7937 100644 --- a/integration/hsic/hsic.go +++ b/integration/hsic/hsic.go @@ -1232,26 +1232,26 @@ func (h *HeadscaleInContainer) writePolicy(pol *policyv2.Policy) error { } func (h *HeadscaleInContainer) PID() (int, error) { - cmd := []string{"bash", "-c", `ps aux | grep headscale | grep -v grep | awk '{print $2}'`} - output, err := h.Execute(cmd) + // Use pidof to find the headscale process, which is more reliable than grep + // as it only looks for the actual binary name, not processes that contain + // "headscale" in their command line (like the dlv debugger). + output, err := h.Execute([]string{"pidof", "headscale"}) if err != nil { - return 0, fmt.Errorf("failed to execute command: %w", err) + // pidof returns exit code 1 when no process is found + return 0, os.ErrNotExist } - lines := strings.TrimSpace(output) - if lines == "" { - return 0, os.ErrNotExist // No output means no process found + // pidof returns space-separated PIDs on a single line + pidStrs := strings.Fields(strings.TrimSpace(output)) + if len(pidStrs) == 0 { + return 0, os.ErrNotExist } - pids := make([]int, 0, len(lines)) - for _, line := range strings.Split(lines, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - pidInt, err := strconv.Atoi(line) + pids := make([]int, 0, len(pidStrs)) + for _, pidStr := range pidStrs { + pidInt, err := strconv.Atoi(pidStr) if err != nil { - return 0, fmt.Errorf("parsing PID: %w", err) + return 0, fmt.Errorf("parsing PID %q: %w", pidStr, err) } // We dont care about the root pid for the container if pidInt == 1 { @@ -1266,7 +1266,9 @@ func (h *HeadscaleInContainer) PID() (int, error) { case 1: return pids[0], nil default: - return 0, errors.New("multiple headscale processes running") + // If we still have multiple PIDs, return the first one as a fallback + // This can happen in edge cases during startup/shutdown + return pids[0], nil } } diff --git a/integration/route_test.go b/integration/route_test.go index 15b66d6b..867aa9b7 100644 --- a/integration/route_test.go +++ b/integration/route_test.go @@ -24,6 +24,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" xmaps "golang.org/x/exp/maps" + "tailscale.com/envknob" "tailscale.com/ipn/ipnstate" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" @@ -2215,11 +2216,31 @@ func TestAutoApproveMultiNetwork(t *testing.T) { }, } + // Check if we should run the full matrix of tests + // By default, we only run a minimal subset to avoid overwhelming Docker/disk + // Set HEADSCALE_INTEGRATION_FULL_MATRIX=1 to run all combinations + fullMatrix := envknob.Bool("HEADSCALE_INTEGRATION_FULL_MATRIX") + + // Minimal test set: 3 tests covering all key dimensions + // - Both auth methods (authkey, webauth) + // - All 3 approver types (tag, user, group) + // - Both policy modes (database, file) + // - Both advertiseDuringUp values (true, false) + minimalTestSet := map[string]bool{ + "authkey-tag-advertiseduringup-false-pol-database": true, // authkey + database + tag + false + "webauth-user-advertiseduringup-true-pol-file": true, // webauth + file + user + true + "authkey-group-advertiseduringup-false-pol-file": true, // authkey + file + group + false + } + for _, tt := range tests { for _, polMode := range []types.PolicyMode{types.PolicyModeDB, types.PolicyModeFile} { for _, advertiseDuringUp := range []bool{false, true} { name := fmt.Sprintf("%s-advertiseduringup-%t-pol-%s", tt.name, advertiseDuringUp, polMode) t.Run(name, func(t *testing.T) { + // Skip tests not in minimal set unless full matrix is enabled + if !fullMatrix && !minimalTestSet[name] { + t.Skip("Skipping to reduce test matrix size. Set HEADSCALE_INTEGRATION_FULL_MATRIX=1 to run all tests.") + } scenario, err := NewScenario(tt.spec) require.NoErrorf(t, err, "failed to create scenario: %s", err) defer scenario.ShutdownAssertNoPanics(t) From 19a33394f6e0924e2fb63d2d68ad38d5c61b6630 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 27 Oct 2025 12:14:02 +0100 Subject: [PATCH 002/178] changelog: set 0.27 date (#2823) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0900c141..da547451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Next -## 0.27.0 (2025-xx-xx) +## 0.27.0 (2025-10-27) **Minimum supported Tailscale client version: v1.64.0** From 4bd614a559ea52bb7c77983b61247d23299237df Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Mon, 27 Oct 2025 20:29:41 +0100 Subject: [PATCH 003/178] Use current stable base images for Debian and Alpine --- Dockerfile.derper | 2 +- Dockerfile.integration | 5 ++--- Dockerfile.tailscale-HEAD | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Dockerfile.derper b/Dockerfile.derper index 62adc7cf..395d9586 100644 --- a/Dockerfile.derper +++ b/Dockerfile.derper @@ -12,7 +12,7 @@ WORKDIR /go/src/tailscale ARG TARGETARCH RUN GOARCH=$TARGETARCH go install -v ./cmd/derper -FROM alpine:3.18 +FROM alpine:3.22 RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables curl COPY --from=build-env /go/bin/* /usr/local/bin/ diff --git a/Dockerfile.integration b/Dockerfile.integration index 6baf4564..72becdf9 100644 --- a/Dockerfile.integration +++ b/Dockerfile.integration @@ -2,13 +2,12 @@ # and are in no way endorsed by Headscale's maintainers as an # official nor supported release or distribution. -FROM docker.io/golang:1.25-bookworm +FROM docker.io/golang:1.25-trixie ARG VERSION=dev ENV GOPATH /go WORKDIR /go/src/headscale -RUN apt-get update \ - && apt-get install --no-install-recommends --yes less jq sqlite3 dnsutils \ +RUN apt-get --update install --no-install-recommends --yes less jq sqlite3 dnsutils \ && rm -rf /var/lib/apt/lists/* \ && apt-get clean RUN mkdir -p /var/run/headscale diff --git a/Dockerfile.tailscale-HEAD b/Dockerfile.tailscale-HEAD index 43e68992..240d528b 100644 --- a/Dockerfile.tailscale-HEAD +++ b/Dockerfile.tailscale-HEAD @@ -36,7 +36,7 @@ RUN GOARCH=$TARGETARCH go install -tags="${BUILD_TAGS}" -ldflags="\ -X tailscale.com/version.gitCommitStamp=$VERSION_GIT_HASH" \ -v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot -FROM alpine:3.18 +FROM alpine:3.22 RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables curl COPY --from=build-env /go/bin/* /usr/local/bin/ From 0a43aab8f5c876935f84ab9725f0e8b47dffe809 Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Mon, 27 Oct 2025 20:43:33 +0100 Subject: [PATCH 004/178] Use Debian 12 as minimum version for the deb package --- docs/setup/install/official.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/setup/install/official.md b/docs/setup/install/official.md index cd77ec5d..17d23b23 100644 --- a/docs/setup/install/official.md +++ b/docs/setup/install/official.md @@ -7,7 +7,7 @@ Both are available on the [GitHub releases page](https://github.com/juanfont/hea It is recommended to use our DEB packages to install headscale on a Debian based system as those packages configure a local user to run headscale, provide a default configuration and ship with a systemd service file. Supported -distributions are Ubuntu 22.04 or newer, Debian 11 or newer. +distributions are Ubuntu 22.04 or newer, Debian 12 or newer. 1. Download the [latest headscale package](https://github.com/juanfont/headscale/releases/latest) for your platform (`.deb` for Ubuntu and Debian). From ddbd3e14ba6fb26468a7ae4925551b09fda0eda5 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 1 Nov 2025 08:03:37 +0100 Subject: [PATCH 005/178] db: remove all old, unused tables (#2844) --- hscontrol/db/db.go | 20 ++++++++++ ...ump_schema-to-0.27.0-old-table-cleanup.sql | 40 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index e18f2e5d..581ca6d5 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -932,6 +932,26 @@ AND auth_key_id NOT IN ( }, Rollback: func(db *gorm.DB) error { return nil }, }, + { + // Drop all tables that are no longer in use and has existed. + // They potentially still present from broken migrations in the past. + ID: "202510311551", + Migrate: func(tx *gorm.DB) error { + for _, oldTable := range []string{"namespaces", "machines", "shared_machines", "kvs", "pre_auth_key_acl_tags", "routes"} { + err := tx.Migrator().DropTable(oldTable) + if err != nil { + log.Trace().Str("table", oldTable). + Err(err). + Msg("Error dropping old table, continuing...") + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, + }, // From this point, the following rules must be followed: // - NEVER use gorm.AutoMigrate, write the exact migration steps needed // - AutoMigrate depends on the struct staying exactly the same, which it won't over time. diff --git a/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql b/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql new file mode 100644 index 00000000..388fefbc --- /dev/null +++ b/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql @@ -0,0 +1,40 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE `migrations` (`id` text,PRIMARY KEY (`id`)); +INSERT INTO migrations VALUES('202312101416'); +INSERT INTO migrations VALUES('202312101430'); +INSERT INTO migrations VALUES('202402151347'); +INSERT INTO migrations VALUES('2024041121742'); +INSERT INTO migrations VALUES('202406021630'); +INSERT INTO migrations VALUES('202409271400'); +INSERT INTO migrations VALUES('202407191627'); +INSERT INTO migrations VALUES('202408181235'); +INSERT INTO migrations VALUES('202501221827'); +INSERT INTO migrations VALUES('202501311657'); +INSERT INTO migrations VALUES('202502070949'); +INSERT INTO migrations VALUES('202502131714'); +INSERT INTO migrations VALUES('202502171819'); +INSERT INTO migrations VALUES('202505091439'); +INSERT INTO migrations VALUES('202505141324'); +CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`name` text,`display_name` text,`email` text,`provider_identifier` text,`provider` text,`profile_pic_url` text); +CREATE TABLE `pre_auth_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`key` text,`user_id` integer,`reusable` numeric,`ephemeral` numeric DEFAULT false,`used` numeric DEFAULT false,`tags` text,`created_at` datetime,`expiration` datetime,CONSTRAINT `fk_pre_auth_keys_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL); +CREATE TABLE `api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`prefix` text,`hash` blob,`created_at` datetime,`expiration` datetime,`last_seen` datetime); +CREATE TABLE IF NOT EXISTS "nodes" (`id` integer PRIMARY KEY AUTOINCREMENT,`machine_key` text,`node_key` text,`disco_key` text,`endpoints` text,`host_info` text,`ipv4` text,`ipv6` text,`hostname` text,`given_name` varchar(63),`user_id` integer,`register_method` text,`forced_tags` text,`auth_key_id` integer,`expiry` datetime,`last_seen` datetime,`approved_routes` text,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,CONSTRAINT `fk_nodes_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,CONSTRAINT `fk_nodes_auth_key` FOREIGN KEY (`auth_key_id`) REFERENCES `pre_auth_keys`(`id`)); +CREATE TABLE `policies` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`data` text); +DELETE FROM sqlite_sequence; +INSERT INTO sqlite_sequence VALUES('nodes',0); +CREATE INDEX `idx_users_deleted_at` ON `users`(`deleted_at`); +CREATE UNIQUE INDEX `idx_api_keys_prefix` ON `api_keys`(`prefix`); +CREATE INDEX `idx_policies_deleted_at` ON `policies`(`deleted_at`); +CREATE UNIQUE INDEX idx_provider_identifier ON users (provider_identifier) WHERE provider_identifier IS NOT NULL; +CREATE UNIQUE INDEX idx_name_provider_identifier ON users (name,provider_identifier); +CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users (name) WHERE provider_identifier IS NULL; + +-- Create all the old tables we have had and ensure they are clean up. +CREATE TABLE `namespaces` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `machines` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `kvs` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `shared_machines` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `pre_auth_key_acl_tags` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `routes` (`id` text,PRIMARY KEY (`id`)); +COMMIT; From 456a5d5cceea654b8c7c9b4a5db2336b62de8bec Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 1 Nov 2025 08:08:22 +0100 Subject: [PATCH 006/178] db: ignore _litestream tables when validating (#2843) --- flake.nix | 2 +- go.mod | 6 +++--- go.sum | 6 ++++++ hscontrol/db/db.go | 12 +++++++++++- .../sqlite/headscale_0.26.1_schema-litestream.sql | 14 ++++++++++++++ 5 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 hscontrol/db/testdata/sqlite/headscale_0.26.1_schema-litestream.sql diff --git a/flake.nix b/flake.nix index c064c7fe..f8eb6dd1 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ overlay = _: prev: let pkgs = nixpkgs.legacyPackages.${prev.system}; buildGo = pkgs.buildGo125Module; - vendorHash = "sha256-GUIzlPRsyEq1uSTzRNds9p1uVu4pTeH5PAxrJ5Njhis="; + vendorHash = "sha256-VOi4PGZ8I+2MiwtzxpKc/4smsL5KcH/pHVkjJfAFPJ0="; in { headscale = buildGo { pname = "headscale"; diff --git a/go.mod b/go.mod index b96cedf1..67c6c089 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/tailscale/hujson v0.0.0-20250226034555-ec1d1c113d33 - github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694 + github.com/tailscale/squibble v0.0.0-20251030164342-4d5df9caa993 github.com/tailscale/tailsql v0.0.0-20250421235516-02f85f087b97 github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e go4.org/netipx v0.0.0-20231129151722-fdeea329fbba @@ -115,7 +115,7 @@ require ( github.com/containerd/errdefs v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect - github.com/creachadair/mds v0.25.2 // indirect + github.com/creachadair/mds v0.25.10 // indirect github.com/dblohm7/wingoes v0.0.0-20240123200102-b75a8a7d7eb0 // indirect github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect github.com/distribution/reference v0.6.0 // indirect @@ -159,7 +159,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jsimonetti/rtnetlink v1.4.1 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.1 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/lib/pq v1.10.9 // indirect diff --git a/go.sum b/go.sum index 1b09acc5..e78e9aff 100644 --- a/go.sum +++ b/go.sum @@ -126,6 +126,8 @@ github.com/creachadair/flax v0.0.5 h1:zt+CRuXQASxwQ68e9GHAOnEgAU29nF0zYMHOCrL5wz github.com/creachadair/flax v0.0.5/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8= github.com/creachadair/mds v0.25.2 h1:xc0S0AfDq5GX9KUR5sLvi5XjA61/P6S5e0xFs1vA18Q= github.com/creachadair/mds v0.25.2/go.mod h1:+s4CFteFRj4eq2KcGHW8Wei3u9NyzSPzNV32EvjyK/Q= +github.com/creachadair/mds v0.25.10 h1:9k9JB35D1xhOCFl0liBhagBBp8fWWkKZrA7UXsfoHtA= +github.com/creachadair/mds v0.25.10/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -278,6 +280,8 @@ github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNa github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= +github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= @@ -461,6 +465,8 @@ github.com/tailscale/setec v0.0.0-20250305161714-445cadbbca3d h1:mnqtPWYyvNiPU9l github.com/tailscale/setec v0.0.0-20250305161714-445cadbbca3d/go.mod h1:9BzmlFc3OLqLzLTF/5AY+BMs+clxMqyhSGzgXIm8mNI= github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694 h1:95eIP97c88cqAFU/8nURjgI9xxPbD+Ci6mY/a79BI/w= github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694/go.mod h1:veguaG8tVg1H/JG5RfpoUW41I+O8ClPElo/fTYr8mMk= +github.com/tailscale/squibble v0.0.0-20251030164342-4d5df9caa993 h1:FyiiAvDAxpB0DrW2GW3KOVfi3YFOtsQUEeFWbf55JJU= +github.com/tailscale/squibble v0.0.0-20251030164342-4d5df9caa993/go.mod h1:xJkMmR3t+thnUQhA3Q4m2VSlS5pcOq+CIjmU/xfKKx4= github.com/tailscale/tailsql v0.0.0-20250421235516-02f85f087b97 h1:JJkDnrAhHvOCttk8z9xeZzcDlzzkRA7+Duxj9cwOyxk= github.com/tailscale/tailsql v0.0.0-20250421235516-02f85f087b97/go.mod h1:9jS8HxwsP2fU4ESZ7DZL+fpH/U66EVlVMzdgznH12RM= github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index 581ca6d5..04c6cc0a 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -982,7 +982,17 @@ AND auth_key_id NOT IN ( ctx, cancel := context.WithTimeout(context.Background(), contextTimeoutSecs*time.Second) defer cancel() - if err := squibble.Validate(ctx, sqlConn, dbSchema); err != nil { + opts := squibble.DigestOptions{ + IgnoreTables: []string{ + // Litestream tables, these are inserted by + // litestream and not part of our schema + // https://litestream.io/how-it-works + "_litestream_lock", + "_litestream_seq", + }, + } + + if err := squibble.Validate(ctx, sqlConn, dbSchema, &opts); err != nil { return nil, fmt.Errorf("validating schema: %w", err) } } diff --git a/hscontrol/db/testdata/sqlite/headscale_0.26.1_schema-litestream.sql b/hscontrol/db/testdata/sqlite/headscale_0.26.1_schema-litestream.sql new file mode 100644 index 00000000..3fc2b319 --- /dev/null +++ b/hscontrol/db/testdata/sqlite/headscale_0.26.1_schema-litestream.sql @@ -0,0 +1,14 @@ +CREATE TABLE `migrations` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`name` text,`display_name` text,`email` text,`provider_identifier` text,`provider` text,`profile_pic_url` text); +CREATE INDEX `idx_users_deleted_at` ON `users`(`deleted_at`); +CREATE TABLE `pre_auth_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`key` text,`user_id` integer,`reusable` numeric,`ephemeral` numeric DEFAULT false,`used` numeric DEFAULT false,`tags` text,`created_at` datetime,`expiration` datetime,CONSTRAINT `fk_pre_auth_keys_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL); +CREATE TABLE `api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`prefix` text,`hash` blob,`created_at` datetime,`expiration` datetime,`last_seen` datetime); +CREATE UNIQUE INDEX `idx_api_keys_prefix` ON `api_keys`(`prefix`); +CREATE TABLE IF NOT EXISTS "nodes" (`id` integer PRIMARY KEY AUTOINCREMENT,`machine_key` text,`node_key` text,`disco_key` text,`endpoints` text,`host_info` text,`ipv4` text,`ipv6` text,`hostname` text,`given_name` varchar(63),`user_id` integer,`register_method` text,`forced_tags` text,`auth_key_id` integer,`expiry` datetime,`last_seen` datetime,`approved_routes` text,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,CONSTRAINT `fk_nodes_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,CONSTRAINT `fk_nodes_auth_key` FOREIGN KEY (`auth_key_id`) REFERENCES `pre_auth_keys`(`id`)); +CREATE TABLE `policies` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`data` text); +CREATE INDEX `idx_policies_deleted_at` ON `policies`(`deleted_at`); +CREATE UNIQUE INDEX idx_provider_identifier ON users (provider_identifier) WHERE provider_identifier IS NOT NULL; +CREATE UNIQUE INDEX idx_name_provider_identifier ON users (name,provider_identifier); +CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users (name) WHERE provider_identifier IS NULL; +CREATE TABLE _litestream_seq (id INTEGER PRIMARY KEY, seq INTEGER); +CREATE TABLE _litestream_lock (id INTEGER); From f9bb88ad24d95c2dc35fae6e433e1e8eb8faa926 Mon Sep 17 00:00:00 2001 From: Andrey <3942342+bobelev@users.noreply.github.com> Date: Sat, 1 Nov 2025 09:09:13 +0200 Subject: [PATCH 007/178] expire nodes with a custom timestamp (#2828) --- .github/workflows/test-integration.yaml | 1 + CHANGELOG.md | 5 + cmd/headscale/cli/nodes.go | 27 +++++ gen/go/headscale/v1/apikey.pb.go | 2 +- gen/go/headscale/v1/device.pb.go | 2 +- gen/go/headscale/v1/headscale.pb.go | 2 +- gen/go/headscale/v1/headscale.pb.gw.go | 14 +++ gen/go/headscale/v1/node.pb.go | 36 ++++--- gen/go/headscale/v1/policy.pb.go | 2 +- gen/go/headscale/v1/preauthkey.pb.go | 2 +- gen/go/headscale/v1/user.pb.go | 2 +- .../headscale/v1/headscale.swagger.json | 7 ++ hscontrol/db/node.go | 4 +- hscontrol/grpcv1.go | 7 +- integration/general_test.go | 98 +++++++++++++++++++ proto/headscale/v1/node.proto | 5 +- 16 files changed, 191 insertions(+), 25 deletions(-) diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 37aa792e..735c50bf 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -70,6 +70,7 @@ jobs: - TestTaildrop - TestUpdateHostnameFromClient - TestExpireNode + - TestSetNodeExpiryInFuture - TestNodeOnlineStatus - TestPingAllByIPManyUpDown - Test2118DeletingOnlineNodePanics diff --git a/CHANGELOG.md b/CHANGELOG.md index da547451..02986867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Next +### Changes + +- Expire nodes with a custom timestamp + [#2828](https://github.com/juanfont/headscale/pull/2828) + ## 0.27.0 (2025-10-27) **Minimum supported Tailscale client version: v1.64.0** diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index e1b8e7b3..e1b040f0 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -15,6 +15,7 @@ import ( "github.com/samber/lo" "github.com/spf13/cobra" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" "tailscale.com/types/key" ) @@ -51,6 +52,7 @@ func init() { nodeCmd.AddCommand(registerNodeCmd) expireNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)") + expireNodeCmd.Flags().StringP("expiry", "e", "", "Set expire to (RFC3339 format, e.g. 2025-08-27T10:00:00Z), or leave empty to expire immediately.") err = expireNodeCmd.MarkFlagRequired("identifier") if err != nil { log.Fatal(err.Error()) @@ -289,12 +291,37 @@ var expireNodeCmd = &cobra.Command{ ) } + expiry, err := cmd.Flags().GetString("expiry") + if err != nil { + ErrorOutput( + err, + fmt.Sprintf("Error converting expiry to string: %s", err), + output, + ) + + return + } + expiryTime := time.Now() + if expiry != "" { + expiryTime, err = time.Parse(time.RFC3339, expiry) + if err != nil { + ErrorOutput( + err, + fmt.Sprintf("Error converting expiry to string: %s", err), + output, + ) + + return + } + } + ctx, client, conn, cancel := newHeadscaleCLIWithConfig() defer cancel() defer conn.Close() request := &v1.ExpireNodeRequest{ NodeId: identifier, + Expiry: timestamppb.New(expiryTime), } response, err := client.ExpireNode(ctx, request) diff --git a/gen/go/headscale/v1/apikey.pb.go b/gen/go/headscale/v1/apikey.pb.go index 38aaf55a..a9f6a7b8 100644 --- a/gen/go/headscale/v1/apikey.pb.go +++ b/gen/go/headscale/v1/apikey.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: headscale/v1/apikey.proto diff --git a/gen/go/headscale/v1/device.pb.go b/gen/go/headscale/v1/device.pb.go index c31bd754..8b150f96 100644 --- a/gen/go/headscale/v1/device.pb.go +++ b/gen/go/headscale/v1/device.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: headscale/v1/device.proto diff --git a/gen/go/headscale/v1/headscale.pb.go b/gen/go/headscale/v1/headscale.pb.go index e9fdfd7f..2c594f5a 100644 --- a/gen/go/headscale/v1/headscale.pb.go +++ b/gen/go/headscale/v1/headscale.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: headscale/v1/headscale.proto diff --git a/gen/go/headscale/v1/headscale.pb.gw.go b/gen/go/headscale/v1/headscale.pb.gw.go index fcd7fa2b..2a8ac365 100644 --- a/gen/go/headscale/v1/headscale.pb.gw.go +++ b/gen/go/headscale/v1/headscale.pb.gw.go @@ -471,6 +471,8 @@ func local_request_HeadscaleService_DeleteNode_0(ctx context.Context, marshaler return msg, metadata, err } +var filter_HeadscaleService_ExpireNode_0 = &utilities.DoubleArray{Encoding: map[string]int{"node_id": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}} + func request_HeadscaleService_ExpireNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var ( protoReq ExpireNodeRequest @@ -485,6 +487,12 @@ func request_HeadscaleService_ExpireNode_0(ctx context.Context, marshaler runtim if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ExpireNode_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } msg, err := client.ExpireNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err } @@ -503,6 +511,12 @@ func local_request_HeadscaleService_ExpireNode_0(ctx context.Context, marshaler if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ExpireNode_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } msg, err := server.ExpireNode(ctx, &protoReq) return msg, metadata, err } diff --git a/gen/go/headscale/v1/node.pb.go b/gen/go/headscale/v1/node.pb.go index 60d8fb95..f04c7e2d 100644 --- a/gen/go/headscale/v1/node.pb.go +++ b/gen/go/headscale/v1/node.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: headscale/v1/node.proto @@ -729,6 +729,7 @@ func (*DeleteNodeResponse) Descriptor() ([]byte, []int) { type ExpireNodeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` + Expiry *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expiry,proto3" json:"expiry,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -770,6 +771,13 @@ func (x *ExpireNodeRequest) GetNodeId() uint64 { return 0 } +func (x *ExpireNodeRequest) GetExpiry() *timestamppb.Timestamp { + if x != nil { + return x.Expiry + } + return nil +} + type ExpireNodeResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` @@ -1349,9 +1357,10 @@ const file_headscale_v1_node_proto_rawDesc = "" + "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\",\n" + "\x11DeleteNodeRequest\x12\x17\n" + "\anode_id\x18\x01 \x01(\x04R\x06nodeId\"\x14\n" + - "\x12DeleteNodeResponse\",\n" + + "\x12DeleteNodeResponse\"`\n" + "\x11ExpireNodeRequest\x12\x17\n" + - "\anode_id\x18\x01 \x01(\x04R\x06nodeId\"<\n" + + "\anode_id\x18\x01 \x01(\x04R\x06nodeId\x122\n" + + "\x06expiry\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x06expiry\"<\n" + "\x12ExpireNodeResponse\x12&\n" + "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"G\n" + "\x11RenameNodeRequest\x12\x17\n" + @@ -1439,16 +1448,17 @@ var file_headscale_v1_node_proto_depIdxs = []int32{ 1, // 7: headscale.v1.GetNodeResponse.node:type_name -> headscale.v1.Node 1, // 8: headscale.v1.SetTagsResponse.node:type_name -> headscale.v1.Node 1, // 9: headscale.v1.SetApprovedRoutesResponse.node:type_name -> headscale.v1.Node - 1, // 10: headscale.v1.ExpireNodeResponse.node:type_name -> headscale.v1.Node - 1, // 11: headscale.v1.RenameNodeResponse.node:type_name -> headscale.v1.Node - 1, // 12: headscale.v1.ListNodesResponse.nodes:type_name -> headscale.v1.Node - 1, // 13: headscale.v1.MoveNodeResponse.node:type_name -> headscale.v1.Node - 1, // 14: headscale.v1.DebugCreateNodeResponse.node:type_name -> headscale.v1.Node - 15, // [15:15] is the sub-list for method output_type - 15, // [15:15] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 25, // 10: headscale.v1.ExpireNodeRequest.expiry:type_name -> google.protobuf.Timestamp + 1, // 11: headscale.v1.ExpireNodeResponse.node:type_name -> headscale.v1.Node + 1, // 12: headscale.v1.RenameNodeResponse.node:type_name -> headscale.v1.Node + 1, // 13: headscale.v1.ListNodesResponse.nodes:type_name -> headscale.v1.Node + 1, // 14: headscale.v1.MoveNodeResponse.node:type_name -> headscale.v1.Node + 1, // 15: headscale.v1.DebugCreateNodeResponse.node:type_name -> headscale.v1.Node + 16, // [16:16] is the sub-list for method output_type + 16, // [16:16] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_headscale_v1_node_proto_init() } diff --git a/gen/go/headscale/v1/policy.pb.go b/gen/go/headscale/v1/policy.pb.go index 4ac6e3b2..fefcfb22 100644 --- a/gen/go/headscale/v1/policy.pb.go +++ b/gen/go/headscale/v1/policy.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: headscale/v1/policy.proto diff --git a/gen/go/headscale/v1/preauthkey.pb.go b/gen/go/headscale/v1/preauthkey.pb.go index de7f3248..661f170d 100644 --- a/gen/go/headscale/v1/preauthkey.pb.go +++ b/gen/go/headscale/v1/preauthkey.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: headscale/v1/preauthkey.proto diff --git a/gen/go/headscale/v1/user.pb.go b/gen/go/headscale/v1/user.pb.go index 97fcaff9..fa6d49bb 100644 --- a/gen/go/headscale/v1/user.pb.go +++ b/gen/go/headscale/v1/user.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: headscale/v1/user.proto diff --git a/gen/openapiv2/headscale/v1/headscale.swagger.json b/gen/openapiv2/headscale/v1/headscale.swagger.json index 2900d65f..6a7b48ad 100644 --- a/gen/openapiv2/headscale/v1/headscale.swagger.json +++ b/gen/openapiv2/headscale/v1/headscale.swagger.json @@ -406,6 +406,13 @@ "required": true, "type": "string", "format": "uint64" + }, + { + "name": "expiry", + "in": "query", + "required": false, + "type": "string", + "format": "date-time" } ], "tags": [ diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go index 5493a55c..4e4533be 100644 --- a/hscontrol/db/node.go +++ b/hscontrol/db/node.go @@ -27,9 +27,7 @@ const ( NodeGivenNameTrimSize = 2 ) -var ( - invalidDNSRegex = regexp.MustCompile("[^a-z0-9-.]+") -) +var invalidDNSRegex = regexp.MustCompile("[^a-z0-9-.]+") var ( ErrNodeNotFound = errors.New("node not found") diff --git a/hscontrol/grpcv1.go b/hscontrol/grpcv1.go index 1d620ba6..6d5189b8 100644 --- a/hscontrol/grpcv1.go +++ b/hscontrol/grpcv1.go @@ -416,9 +416,12 @@ func (api headscaleV1APIServer) ExpireNode( ctx context.Context, request *v1.ExpireNodeRequest, ) (*v1.ExpireNodeResponse, error) { - now := time.Now() + expiry := time.Now() + if request.GetExpiry() != nil { + expiry = request.GetExpiry().AsTime() + } - node, nodeChange, err := api.h.state.SetNodeExpiry(types.NodeID(request.GetNodeId()), now) + node, nodeChange, err := api.h.state.SetNodeExpiry(types.NodeID(request.GetNodeId()), expiry) if err != nil { return nil, err } diff --git a/integration/general_test.go b/integration/general_test.go index 2432db9c..c68768f7 100644 --- a/integration/general_test.go +++ b/integration/general_test.go @@ -819,6 +819,104 @@ func TestExpireNode(t *testing.T) { } } +// TestSetNodeExpiryInFuture tests setting arbitrary expiration date +// New expiration date should be stored in the db and propagated to all peers +func TestSetNodeExpiryInFuture(t *testing.T) { + IntegrationSkip(t) + + spec := ScenarioSpec{ + NodesPerUser: len(MustTestVersions), + Users: []string{"user1"}, + } + + scenario, err := NewScenario(spec) + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("expirenodefuture")) + requireNoErrHeadscaleEnv(t, err) + + allClients, err := scenario.ListTailscaleClients() + requireNoErrListClients(t, err) + + err = scenario.WaitForTailscaleSync() + requireNoErrSync(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + targetExpiry := time.Now().Add(2 * time.Hour).Round(time.Second).UTC() + + result, err := headscale.Execute( + []string{ + "headscale", "nodes", "expire", + "--identifier", "1", + "--output", "json", + "--expiry", targetExpiry.Format(time.RFC3339), + }, + ) + require.NoError(t, err) + + var node v1.Node + err = json.Unmarshal([]byte(result), &node) + require.NoError(t, err) + + require.True(t, node.GetExpiry().AsTime().After(time.Now())) + require.WithinDuration(t, targetExpiry, node.GetExpiry().AsTime(), 2*time.Second) + + var nodeKey key.NodePublic + err = nodeKey.UnmarshalText([]byte(node.GetNodeKey())) + require.NoError(t, err) + + for _, client := range allClients { + if client.Hostname() == node.GetName() { + continue + } + + assert.EventuallyWithT( + t, func(ct *assert.CollectT) { + status, err := client.Status() + assert.NoError(ct, err) + + peerStatus, ok := status.Peer[nodeKey] + assert.True(ct, ok, "node key should be present in peer list") + + if !ok { + return + } + + assert.NotNil(ct, peerStatus.KeyExpiry) + assert.NotNil(ct, peerStatus.Expired) + + if peerStatus.KeyExpiry != nil { + assert.WithinDuration( + ct, + targetExpiry, + *peerStatus.KeyExpiry, + 5*time.Second, + "node %q should have key expiry near the requested future time", + peerStatus.HostName, + ) + + assert.Truef( + ct, + peerStatus.KeyExpiry.After(time.Now()), + "node %q should have a key expiry timestamp in the future", + peerStatus.HostName, + ) + } + + assert.Falsef( + ct, + peerStatus.Expired, + "node %q should not be marked as expired", + peerStatus.HostName, + ) + }, 3*time.Minute, 5*time.Second, "Waiting for future expiry to propagate", + ) + } +} + func TestNodeOnlineStatus(t *testing.T) { IntegrationSkip(t) diff --git a/proto/headscale/v1/node.proto b/proto/headscale/v1/node.proto index 89d2c347..fb074008 100644 --- a/proto/headscale/v1/node.proto +++ b/proto/headscale/v1/node.proto @@ -82,7 +82,10 @@ message DeleteNodeRequest { uint64 node_id = 1; } message DeleteNodeResponse {} -message ExpireNodeRequest { uint64 node_id = 1; } +message ExpireNodeRequest { + uint64 node_id = 1; + google.protobuf.Timestamp expiry = 2; +} message ExpireNodeResponse { Node node = 1; } From d23fa26395ce64cf41aa0c47f38060c4fec03942 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 10:05:23 +0100 Subject: [PATCH 008/178] Fix flaky TestShuffleDERPMapDeterministic by ensuring deterministic map iteration (#2848) Co-authored-by: kradalby <98431+kradalby@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- hscontrol/derp/derp.go | 13 ++++++- hscontrol/derp/derp_test.go | 76 +++++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/hscontrol/derp/derp.go b/hscontrol/derp/derp.go index 6c8244f5..42d74abe 100644 --- a/hscontrol/derp/derp.go +++ b/hscontrol/derp/derp.go @@ -12,6 +12,7 @@ import ( "net/url" "os" "reflect" + "slices" "sync" "time" @@ -126,7 +127,17 @@ func shuffleDERPMap(dm *tailcfg.DERPMap) { return } - for id, region := range dm.Regions { + // Collect region IDs and sort them to ensure deterministic iteration order. + // Map iteration order is non-deterministic in Go, which would cause the + // shuffle to be non-deterministic even with a fixed seed. + ids := make([]int, 0, len(dm.Regions)) + for id := range dm.Regions { + ids = append(ids, id) + } + slices.Sort(ids) + + for _, id := range ids { + region := dm.Regions[id] if len(region.Nodes) == 0 { continue } diff --git a/hscontrol/derp/derp_test.go b/hscontrol/derp/derp_test.go index 9334de05..91d605a6 100644 --- a/hscontrol/derp/derp_test.go +++ b/hscontrol/derp/derp_test.go @@ -83,9 +83,9 @@ func TestShuffleDERPMapDeterministic(t *testing.T) { RegionCode: "sea", RegionName: "Seattle", Nodes: []*tailcfg.DERPNode{ - {Name: "10b", RegionID: 10, HostName: "derp10b.tailscale.com"}, - {Name: "10c", RegionID: 10, HostName: "derp10c.tailscale.com"}, {Name: "10d", RegionID: 10, HostName: "derp10d.tailscale.com"}, + {Name: "10c", RegionID: 10, HostName: "derp10c.tailscale.com"}, + {Name: "10b", RegionID: 10, HostName: "derp10b.tailscale.com"}, }, }, 2: { @@ -93,9 +93,9 @@ func TestShuffleDERPMapDeterministic(t *testing.T) { RegionCode: "sfo", RegionName: "San Francisco", Nodes: []*tailcfg.DERPNode{ - {Name: "2f", RegionID: 2, HostName: "derp2f.tailscale.com"}, - {Name: "2e", RegionID: 2, HostName: "derp2e.tailscale.com"}, {Name: "2d", RegionID: 2, HostName: "derp2d.tailscale.com"}, + {Name: "2e", RegionID: 2, HostName: "derp2e.tailscale.com"}, + {Name: "2f", RegionID: 2, HostName: "derp2f.tailscale.com"}, }, }, }, @@ -169,6 +169,74 @@ func TestShuffleDERPMapDeterministic(t *testing.T) { }, }, }, + { + name: "same dataset with another base domain", + baseDomain: "another.example.com", + derpMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 4: { + RegionID: 4, + RegionCode: "fra", + RegionName: "Frankfurt", + Nodes: []*tailcfg.DERPNode{ + {Name: "4f", RegionID: 4, HostName: "derp4f.tailscale.com"}, + {Name: "4g", RegionID: 4, HostName: "derp4g.tailscale.com"}, + {Name: "4h", RegionID: 4, HostName: "derp4h.tailscale.com"}, + {Name: "4i", RegionID: 4, HostName: "derp4i.tailscale.com"}, + }, + }, + }, + }, + expected: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 4: { + RegionID: 4, + RegionCode: "fra", + RegionName: "Frankfurt", + Nodes: []*tailcfg.DERPNode{ + {Name: "4h", RegionID: 4, HostName: "derp4h.tailscale.com"}, + {Name: "4f", RegionID: 4, HostName: "derp4f.tailscale.com"}, + {Name: "4g", RegionID: 4, HostName: "derp4g.tailscale.com"}, + {Name: "4i", RegionID: 4, HostName: "derp4i.tailscale.com"}, + }, + }, + }, + }, + }, + { + name: "same dataset with yet another base domain", + baseDomain: "yetanother.example.com", + derpMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 4: { + RegionID: 4, + RegionCode: "fra", + RegionName: "Frankfurt", + Nodes: []*tailcfg.DERPNode{ + {Name: "4f", RegionID: 4, HostName: "derp4f.tailscale.com"}, + {Name: "4g", RegionID: 4, HostName: "derp4g.tailscale.com"}, + {Name: "4h", RegionID: 4, HostName: "derp4h.tailscale.com"}, + {Name: "4i", RegionID: 4, HostName: "derp4i.tailscale.com"}, + }, + }, + }, + }, + expected: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 4: { + RegionID: 4, + RegionCode: "fra", + RegionName: "Frankfurt", + Nodes: []*tailcfg.DERPNode{ + {Name: "4i", RegionID: 4, HostName: "derp4i.tailscale.com"}, + {Name: "4h", RegionID: 4, HostName: "derp4h.tailscale.com"}, + {Name: "4f", RegionID: 4, HostName: "derp4f.tailscale.com"}, + {Name: "4g", RegionID: 4, HostName: "derp4g.tailscale.com"}, + }, + }, + }, + }, + }, } for _, tt := range tests { From 02c7c1a0e7eb09de9af74fc39098a034ef3d77a0 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sun, 2 Nov 2025 10:42:59 +0100 Subject: [PATCH 009/178] cli: only validate bypass-grpc set policy (#2854) --- cmd/headscale/cli/policy.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cmd/headscale/cli/policy.go b/cmd/headscale/cli/policy.go index b8a9a2ad..f99d5390 100644 --- a/cmd/headscale/cli/policy.go +++ b/cmd/headscale/cli/policy.go @@ -127,12 +127,6 @@ var setPolicy = &cobra.Command{ ErrorOutput(err, fmt.Sprintf("Error reading the policy file: %s", err), output) } - _, err = policy.NewPolicyManager(policyBytes, nil, views.Slice[types.NodeView]{}) - if err != nil { - ErrorOutput(err, fmt.Sprintf("Error parsing the policy file: %s", err), output) - return - } - if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass { confirm := false force, _ := cmd.Flags().GetBool("force") @@ -159,6 +153,17 @@ var setPolicy = &cobra.Command{ ErrorOutput(err, fmt.Sprintf("Failed to open database: %s", err), output) } + users, err := d.ListUsers() + if err != nil { + ErrorOutput(err, fmt.Sprintf("Failed to load users for policy validation: %s", err), output) + } + + _, err = policy.NewPolicyManager(policyBytes, users, views.Slice[types.NodeView]{}) + if err != nil { + ErrorOutput(err, fmt.Sprintf("Error parsing the policy file: %s", err), output) + return + } + _, err = d.SetPolicy(string(policyBytes)) if err != nil { ErrorOutput(err, fmt.Sprintf("Failed to set ACL Policy: %s", err), output) From af2de35b6caebd4665f2fcc74a0f3fe1b1b094fe Mon Sep 17 00:00:00 2001 From: Vitalij Dovhanyc <45185420+vdovhanych@users.noreply.github.com> Date: Sun, 2 Nov 2025 11:48:27 +0100 Subject: [PATCH 010/178] chore: fix autogroup:self with other acl rules (#2842) --- hscontrol/policy/v2/filter.go | 382 +++++++++++++++++------------ hscontrol/policy/v2/filter_test.go | 67 +++++ hscontrol/policy/v2/policy_test.go | 80 ++++++ integration/acl_test.go | 223 ++++++++++++++--- 4 files changed, 568 insertions(+), 184 deletions(-) diff --git a/hscontrol/policy/v2/filter.go b/hscontrol/policy/v2/filter.go index abdd4ffb..bb7d089a 100644 --- a/hscontrol/policy/v2/filter.go +++ b/hscontrol/policy/v2/filter.go @@ -99,14 +99,16 @@ func (pol *Policy) compileFilterRulesForNode( return nil, ErrInvalidAction } - rule, err := pol.compileACLWithAutogroupSelf(acl, users, node, nodes) + aclRules, err := pol.compileACLWithAutogroupSelf(acl, users, node, nodes) if err != nil { log.Trace().Err(err).Msgf("compiling ACL") continue } - if rule != nil { - rules = append(rules, *rule) + for _, rule := range aclRules { + if rule != nil { + rules = append(rules, *rule) + } } } @@ -115,27 +117,32 @@ func (pol *Policy) compileFilterRulesForNode( // compileACLWithAutogroupSelf compiles a single ACL rule, handling // autogroup:self per-node while supporting all other alias types normally. +// It returns a slice of filter rules because when an ACL has both autogroup:self +// and other destinations, they need to be split into separate rules with different +// source filtering logic. func (pol *Policy) compileACLWithAutogroupSelf( acl ACL, users types.Users, node types.NodeView, nodes views.Slice[types.NodeView], -) (*tailcfg.FilterRule, error) { - // Check if any destination uses autogroup:self - hasAutogroupSelfInDst := false +) ([]*tailcfg.FilterRule, error) { + var autogroupSelfDests []AliasWithPorts + var otherDests []AliasWithPorts for _, dest := range acl.Destinations { if ag, ok := dest.Alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { - hasAutogroupSelfInDst = true - break + autogroupSelfDests = append(autogroupSelfDests, dest) + } else { + otherDests = append(otherDests, dest) } } - var srcIPs netipx.IPSetBuilder + protocols, _ := acl.Protocol.parseProtocol() + var rules []*tailcfg.FilterRule + + var resolvedSrcIPs []*netipx.IPSet - // Resolve sources to only include devices from the same user as the target node. for _, src := range acl.Sources { - // autogroup:self is not allowed in sources if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { return nil, fmt.Errorf("autogroup:self cannot be used in sources") } @@ -147,92 +154,121 @@ func (pol *Policy) compileACLWithAutogroupSelf( } if ips != nil { - if hasAutogroupSelfInDst { - // Instead of iterating all addresses (which could be millions), - // check each node's IPs against the source set - for _, n := range nodes.All() { - if n.User().ID == node.User().ID && !n.IsTagged() { - // Check if any of this node's IPs are in the source set - for _, nodeIP := range n.IPs() { - if ips.Contains(nodeIP) { - n.AppendToIPSet(&srcIPs) - break // Found this node, move to next - } - } - } - } - } else { - // No autogroup:self in destination, use all resolved sources - srcIPs.AddSet(ips) - } + resolvedSrcIPs = append(resolvedSrcIPs, ips) } } - srcSet, err := srcIPs.IPSet() - if err != nil { - return nil, err + if len(resolvedSrcIPs) == 0 { + return rules, nil } - if srcSet == nil || len(srcSet.Prefixes()) == 0 { - // No sources resolved, skip this rule - return nil, nil //nolint:nilnil - } + // Handle autogroup:self destinations (if any) + if len(autogroupSelfDests) > 0 { + // Pre-filter to same-user untagged devices once - reuse for both sources and destinations + sameUserNodes := make([]types.NodeView, 0) + for _, n := range nodes.All() { + if n.User().ID == node.User().ID && !n.IsTagged() { + sameUserNodes = append(sameUserNodes, n) + } + } - protocols, _ := acl.Protocol.parseProtocol() - - var destPorts []tailcfg.NetPortRange - - for _, dest := range acl.Destinations { - if ag, ok := dest.Alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { - for _, n := range nodes.All() { - if n.User().ID == node.User().ID && !n.IsTagged() { - for _, port := range dest.Ports { - for _, ip := range n.IPs() { - pr := tailcfg.NetPortRange{ - IP: ip.String(), - Ports: port, - } - destPorts = append(destPorts, pr) + if len(sameUserNodes) > 0 { + // Filter sources to only same-user untagged devices + var srcIPs netipx.IPSetBuilder + for _, ips := range resolvedSrcIPs { + for _, n := range sameUserNodes { + // Check if any of this node's IPs are in the source set + for _, nodeIP := range n.IPs() { + if ips.Contains(nodeIP) { + n.AppendToIPSet(&srcIPs) + break } } } } - } else { - ips, err := dest.Resolve(pol, users, nodes) + + srcSet, err := srcIPs.IPSet() if err != nil { - log.Trace().Err(err).Msgf("resolving destination ips") - continue + return nil, err } - if ips == nil { - log.Debug().Msgf("destination resolved to nil ips: %v", dest) - continue - } - - prefixes := ips.Prefixes() - - for _, pref := range prefixes { - for _, port := range dest.Ports { - pr := tailcfg.NetPortRange{ - IP: pref.String(), - Ports: port, + if srcSet != nil && len(srcSet.Prefixes()) > 0 { + var destPorts []tailcfg.NetPortRange + for _, dest := range autogroupSelfDests { + for _, n := range sameUserNodes { + for _, port := range dest.Ports { + for _, ip := range n.IPs() { + destPorts = append(destPorts, tailcfg.NetPortRange{ + IP: ip.String(), + Ports: port, + }) + } + } } - destPorts = append(destPorts, pr) + } + + if len(destPorts) > 0 { + rules = append(rules, &tailcfg.FilterRule{ + SrcIPs: ipSetToPrefixStringList(srcSet), + DstPorts: destPorts, + IPProto: protocols, + }) } } } } - if len(destPorts) == 0 { - // No destinations resolved, skip this rule - return nil, nil //nolint:nilnil + if len(otherDests) > 0 { + var srcIPs netipx.IPSetBuilder + + for _, ips := range resolvedSrcIPs { + srcIPs.AddSet(ips) + } + + srcSet, err := srcIPs.IPSet() + if err != nil { + return nil, err + } + + if srcSet != nil && len(srcSet.Prefixes()) > 0 { + var destPorts []tailcfg.NetPortRange + + for _, dest := range otherDests { + ips, err := dest.Resolve(pol, users, nodes) + if err != nil { + log.Trace().Err(err).Msgf("resolving destination ips") + continue + } + + if ips == nil { + log.Debug().Msgf("destination resolved to nil ips: %v", dest) + continue + } + + prefixes := ips.Prefixes() + + for _, pref := range prefixes { + for _, port := range dest.Ports { + pr := tailcfg.NetPortRange{ + IP: pref.String(), + Ports: port, + } + destPorts = append(destPorts, pr) + } + } + } + + if len(destPorts) > 0 { + rules = append(rules, &tailcfg.FilterRule{ + SrcIPs: ipSetToPrefixStringList(srcSet), + DstPorts: destPorts, + IPProto: protocols, + }) + } + } } - return &tailcfg.FilterRule{ - SrcIPs: ipSetToPrefixStringList(srcSet), - DstPorts: destPorts, - IPProto: protocols, - }, nil + return rules, nil } func sshAction(accept bool, duration time.Duration) tailcfg.SSHAction { @@ -260,46 +296,30 @@ func (pol *Policy) compileSSHPolicy( var rules []*tailcfg.SSHRule for index, rule := range pol.SSHs { - // Check if any destination uses autogroup:self - hasAutogroupSelfInDst := false + // Separate destinations into autogroup:self and others + // This is needed because autogroup:self requires filtering sources to same-user only, + // while other destinations should use all resolved sources + var autogroupSelfDests []Alias + var otherDests []Alias + for _, dst := range rule.Destinations { if ag, ok := dst.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { - hasAutogroupSelfInDst = true - break - } - } - - // If autogroup:self is used, skip tagged nodes - if hasAutogroupSelfInDst && node.IsTagged() { - continue - } - - var dest netipx.IPSetBuilder - for _, src := range rule.Destinations { - // Handle autogroup:self specially - if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { - // For autogroup:self, only include the target user's untagged devices - for _, n := range nodes.All() { - if n.User().ID == node.User().ID && !n.IsTagged() { - n.AppendToIPSet(&dest) - } - } + autogroupSelfDests = append(autogroupSelfDests, dst) } else { - ips, err := src.Resolve(pol, users, nodes) - if err != nil { - log.Trace().Caller().Err(err).Msgf("resolving destination ips") - continue - } - dest.AddSet(ips) + otherDests = append(otherDests, dst) } } - destSet, err := dest.IPSet() + // Note: Tagged nodes can't match autogroup:self destinations, but can still match other destinations + + // Resolve sources once - we'll use them differently for each destination type + srcIPs, err := rule.Sources.Resolve(pol, users, nodes) if err != nil { - return nil, err + log.Trace().Caller().Err(err).Msgf("SSH policy compilation failed resolving source ips for rule %+v", rule) + continue // Skip this rule if we can't resolve sources } - if !node.InIPSet(destSet) { + if srcIPs == nil || len(srcIPs.Prefixes()) == 0 { continue } @@ -313,50 +333,9 @@ func (pol *Policy) compileSSHPolicy( return nil, fmt.Errorf("parsing SSH policy, unknown action %q, index: %d: %w", rule.Action, index, err) } - var principals []*tailcfg.SSHPrincipal - srcIPs, err := rule.Sources.Resolve(pol, users, nodes) - if err != nil { - log.Trace().Caller().Err(err).Msgf("SSH policy compilation failed resolving source ips for rule %+v", rule) - continue // Skip this rule if we can't resolve sources - } - - // If autogroup:self is in destinations, filter sources to same user only - if hasAutogroupSelfInDst { - var filteredSrcIPs netipx.IPSetBuilder - // Instead of iterating all addresses, check each node's IPs - for _, n := range nodes.All() { - if n.User().ID == node.User().ID && !n.IsTagged() { - // Check if any of this node's IPs are in the source set - for _, nodeIP := range n.IPs() { - if srcIPs.Contains(nodeIP) { - n.AppendToIPSet(&filteredSrcIPs) - break // Found this node, move to next - } - } - } - } - - srcIPs, err = filteredSrcIPs.IPSet() - if err != nil { - return nil, err - } - - if srcIPs == nil || len(srcIPs.Prefixes()) == 0 { - // No valid sources after filtering, skip this rule - continue - } - } - - for addr := range util.IPSetAddrIter(srcIPs) { - principals = append(principals, &tailcfg.SSHPrincipal{ - NodeIP: addr.String(), - }) - } - userMap := make(map[string]string, len(rule.Users)) if rule.Users.ContainsNonRoot() { userMap["*"] = "=" - // by default, we do not allow root unless explicitly stated userMap["root"] = "" } @@ -366,11 +345,108 @@ func (pol *Policy) compileSSHPolicy( for _, u := range rule.Users.NormalUsers() { userMap[u.String()] = u.String() } - rules = append(rules, &tailcfg.SSHRule{ - Principals: principals, - SSHUsers: userMap, - Action: &action, - }) + + // Handle autogroup:self destinations (if any) + // Note: Tagged nodes can't match autogroup:self, so skip this block for tagged nodes + if len(autogroupSelfDests) > 0 && !node.IsTagged() { + // Build destination set for autogroup:self (same-user untagged devices only) + var dest netipx.IPSetBuilder + for _, n := range nodes.All() { + if n.User().ID == node.User().ID && !n.IsTagged() { + n.AppendToIPSet(&dest) + } + } + + destSet, err := dest.IPSet() + if err != nil { + return nil, err + } + + // Only create rule if this node is in the destination set + if node.InIPSet(destSet) { + // Filter sources to only same-user untagged devices + // Pre-filter to same-user untagged devices for efficiency + sameUserNodes := make([]types.NodeView, 0) + for _, n := range nodes.All() { + if n.User().ID == node.User().ID && !n.IsTagged() { + sameUserNodes = append(sameUserNodes, n) + } + } + + var filteredSrcIPs netipx.IPSetBuilder + for _, n := range sameUserNodes { + // Check if any of this node's IPs are in the source set + for _, nodeIP := range n.IPs() { + if srcIPs.Contains(nodeIP) { + n.AppendToIPSet(&filteredSrcIPs) + break // Found this node, move to next + } + } + } + + filteredSrcSet, err := filteredSrcIPs.IPSet() + if err != nil { + return nil, err + } + + if filteredSrcSet != nil && len(filteredSrcSet.Prefixes()) > 0 { + var principals []*tailcfg.SSHPrincipal + for addr := range util.IPSetAddrIter(filteredSrcSet) { + principals = append(principals, &tailcfg.SSHPrincipal{ + NodeIP: addr.String(), + }) + } + + if len(principals) > 0 { + rules = append(rules, &tailcfg.SSHRule{ + Principals: principals, + SSHUsers: userMap, + Action: &action, + }) + } + } + } + } + + // Handle other destinations (if any) + if len(otherDests) > 0 { + // Build destination set for other destinations + var dest netipx.IPSetBuilder + for _, dst := range otherDests { + ips, err := dst.Resolve(pol, users, nodes) + if err != nil { + log.Trace().Caller().Err(err).Msgf("resolving destination ips") + continue + } + if ips != nil { + dest.AddSet(ips) + } + } + + destSet, err := dest.IPSet() + if err != nil { + return nil, err + } + + // Only create rule if this node is in the destination set + if node.InIPSet(destSet) { + // For non-autogroup:self destinations, use all resolved sources (no filtering) + var principals []*tailcfg.SSHPrincipal + for addr := range util.IPSetAddrIter(srcIPs) { + principals = append(principals, &tailcfg.SSHPrincipal{ + NodeIP: addr.String(), + }) + } + + if len(principals) > 0 { + rules = append(rules, &tailcfg.SSHRule{ + Principals: principals, + SSHUsers: userMap, + Action: &action, + }) + } + } + } } return &tailcfg.SSHPolicy{ diff --git a/hscontrol/policy/v2/filter_test.go b/hscontrol/policy/v2/filter_test.go index 9f2845ac..37ff8730 100644 --- a/hscontrol/policy/v2/filter_test.go +++ b/hscontrol/policy/v2/filter_test.go @@ -1339,3 +1339,70 @@ func TestSSHWithAutogroupSelfExcludesTaggedDevices(t *testing.T) { assert.Empty(t, sshPolicy2.Rules, "tagged node should get no SSH rules with autogroup:self") } } + +// TestSSHWithAutogroupSelfAndMixedDestinations tests that SSH rules can have both +// autogroup:self and other destinations (like tag:router) in the same rule, and that +// autogroup:self filtering only applies to autogroup:self destinations, not others. +func TestSSHWithAutogroupSelfAndMixedDestinations(t *testing.T) { + users := types.Users{ + {Model: gorm.Model{ID: 1}, Name: "user1"}, + {Model: gorm.Model{ID: 2}, Name: "user2"}, + } + + nodes := types.Nodes{ + {User: users[0], IPv4: ap("100.64.0.1"), Hostname: "user1-device"}, + {User: users[0], IPv4: ap("100.64.0.2"), Hostname: "user1-device2"}, + {User: users[1], IPv4: ap("100.64.0.3"), Hostname: "user2-device"}, + {User: users[1], IPv4: ap("100.64.0.4"), Hostname: "user2-router", ForcedTags: []string{"tag:router"}}, + } + + policy := &Policy{ + TagOwners: TagOwners{ + Tag("tag:router"): Owners{up("user2@")}, + }, + SSHs: []SSH{ + { + Action: "accept", + Sources: SSHSrcAliases{agp("autogroup:member")}, + Destinations: SSHDstAliases{agp("autogroup:self"), tp("tag:router")}, + Users: []SSHUser{"admin"}, + }, + }, + } + + err := policy.validate() + require.NoError(t, err) + + // Test 1: Compile for user1's device (should only match autogroup:self destination) + node1 := nodes[0].View() + sshPolicy1, err := policy.compileSSHPolicy(users, node1, nodes.ViewSlice()) + require.NoError(t, err) + require.NotNil(t, sshPolicy1) + require.Len(t, sshPolicy1.Rules, 1, "user1's device should have 1 SSH rule (autogroup:self)") + + // Verify autogroup:self rule has filtered sources (only same-user devices) + selfRule := sshPolicy1.Rules[0] + require.Len(t, selfRule.Principals, 2, "autogroup:self rule should only have user1's devices") + selfPrincipals := make([]string, len(selfRule.Principals)) + for i, p := range selfRule.Principals { + selfPrincipals[i] = p.NodeIP + } + require.ElementsMatch(t, []string{"100.64.0.1", "100.64.0.2"}, selfPrincipals, + "autogroup:self rule should only include same-user untagged devices") + + // Test 2: Compile for router (should only match tag:router destination) + routerNode := nodes[3].View() // user2-router + sshPolicyRouter, err := policy.compileSSHPolicy(users, routerNode, nodes.ViewSlice()) + require.NoError(t, err) + require.NotNil(t, sshPolicyRouter) + require.Len(t, sshPolicyRouter.Rules, 1, "router should have 1 SSH rule (tag:router)") + + routerRule := sshPolicyRouter.Rules[0] + routerPrincipals := make([]string, len(routerRule.Principals)) + for i, p := range routerRule.Principals { + routerPrincipals[i] = p.NodeIP + } + require.Contains(t, routerPrincipals, "100.64.0.1", "router rule should include user1's device (unfiltered sources)") + require.Contains(t, routerPrincipals, "100.64.0.2", "router rule should include user1's other device (unfiltered sources)") + require.Contains(t, routerPrincipals, "100.64.0.3", "router rule should include user2's device (unfiltered sources)") +} diff --git a/hscontrol/policy/v2/policy_test.go b/hscontrol/policy/v2/policy_test.go index 5191368a..bbde136e 100644 --- a/hscontrol/policy/v2/policy_test.go +++ b/hscontrol/policy/v2/policy_test.go @@ -2,6 +2,7 @@ package v2 import ( "net/netip" + "slices" "testing" "github.com/google/go-cmp/cmp" @@ -439,3 +440,82 @@ func TestAutogroupSelfReducedVsUnreducedRules(t *testing.T) { require.Empty(t, peerMap[node1.ID], "node1 should have no peers (can only reach itself)") require.Empty(t, peerMap[node2.ID], "node2 should have no peers") } + +// When separate ACL rules exist (one with autogroup:self, one with tag:router), +// the autogroup:self rule should not prevent the tag:router rule from working. +// This ensures that autogroup:self doesn't interfere with other ACL rules. +func TestAutogroupSelfWithOtherRules(t *testing.T) { + users := types.Users{ + {Model: gorm.Model{ID: 1}, Name: "test-1", Email: "test-1@example.com"}, + {Model: gorm.Model{ID: 2}, Name: "test-2", Email: "test-2@example.com"}, + } + + // test-1 has a regular device + test1Node := &types.Node{ + ID: 1, + Hostname: "test-1-device", + IPv4: ap("100.64.0.1"), + IPv6: ap("fd7a:115c:a1e0::1"), + User: users[0], + UserID: users[0].ID, + Hostinfo: &tailcfg.Hostinfo{}, + } + + // test-2 has a router device with tag:node-router + test2RouterNode := &types.Node{ + ID: 2, + Hostname: "test-2-router", + IPv4: ap("100.64.0.2"), + IPv6: ap("fd7a:115c:a1e0::2"), + User: users[1], + UserID: users[1].ID, + ForcedTags: []string{"tag:node-router"}, + Hostinfo: &tailcfg.Hostinfo{}, + } + + nodes := types.Nodes{test1Node, test2RouterNode} + + // This matches the exact policy from issue #2838: + // - First rule: autogroup:member -> autogroup:self (allows users to see their own devices) + // - Second rule: group:home -> tag:node-router (should allow group members to see router) + policy := `{ + "groups": { + "group:home": ["test-1@example.com", "test-2@example.com"] + }, + "tagOwners": { + "tag:node-router": ["group:home"] + }, + "acls": [ + { + "action": "accept", + "src": ["autogroup:member"], + "dst": ["autogroup:self:*"] + }, + { + "action": "accept", + "src": ["group:home"], + "dst": ["tag:node-router:*"] + } + ] + }` + + pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice()) + require.NoError(t, err) + + peerMap := pm.BuildPeerMap(nodes.ViewSlice()) + + // test-1 (in group:home) should see: + // 1. Their own node (from autogroup:self rule) + // 2. The router node (from group:home -> tag:node-router rule) + test1Peers := peerMap[test1Node.ID] + + // Verify test-1 can see the router (group:home -> tag:node-router rule) + require.True(t, slices.ContainsFunc(test1Peers, func(n types.NodeView) bool { + return n.ID() == test2RouterNode.ID + }), "test-1 should see test-2's router via group:home -> tag:node-router rule, even when autogroup:self rule exists (issue #2838)") + + // Verify that test-1 has filter rules (including autogroup:self and tag:node-router access) + rules, err := pm.FilterForNode(test1Node.View()) + require.NoError(t, err) + require.NotEmpty(t, rules, "test-1 should have filter rules from both ACL rules") +} diff --git a/integration/acl_test.go b/integration/acl_test.go index 122eeea7..50924891 100644 --- a/integration/acl_test.go +++ b/integration/acl_test.go @@ -1611,37 +1611,170 @@ func TestACLAutogroupTagged(t *testing.T) { } // Test that only devices owned by the same user can access each other and cannot access devices of other users +// Test structure: +// - user1: 2 regular nodes (tests autogroup:self for same-user access) +// - user2: 2 regular nodes (tests autogroup:self for same-user access and cross-user isolation) +// - user-router: 1 node with tag:router-node (tests that autogroup:self doesn't interfere with other rules) func TestACLAutogroupSelf(t *testing.T) { IntegrationSkip(t) - scenario := aclScenario(t, - &policyv2.Policy{ - ACLs: []policyv2.ACL{ - { - Action: "accept", - Sources: []policyv2.Alias{ptr.To(policyv2.AutoGroupMember)}, - Destinations: []policyv2.AliasWithPorts{ - aliasWithPorts(ptr.To(policyv2.AutoGroupSelf), tailcfg.PortRangeAny), - }, + // Policy with TWO separate ACL rules: + // 1. autogroup:member -> autogroup:self (same-user access) + // 2. group:home -> tag:router-node (router access) + // This tests that autogroup:self doesn't prevent other rules from working + policy := &policyv2.Policy{ + Groups: policyv2.Groups{ + policyv2.Group("group:home"): []policyv2.Username{ + policyv2.Username("user1@"), + policyv2.Username("user2@"), + }, + }, + TagOwners: policyv2.TagOwners{ + policyv2.Tag("tag:router-node"): policyv2.Owners{ + usernameOwner("user-router@"), + }, + }, + ACLs: []policyv2.ACL{ + { + Action: "accept", + Sources: []policyv2.Alias{ptr.To(policyv2.AutoGroupMember)}, + Destinations: []policyv2.AliasWithPorts{ + aliasWithPorts(ptr.To(policyv2.AutoGroupSelf), tailcfg.PortRangeAny), + }, + }, + { + Action: "accept", + Sources: []policyv2.Alias{groupp("group:home")}, + Destinations: []policyv2.AliasWithPorts{ + aliasWithPorts(tagp("tag:router-node"), tailcfg.PortRangeAny), + }, + }, + { + Action: "accept", + Sources: []policyv2.Alias{tagp("tag:router-node")}, + Destinations: []policyv2.AliasWithPorts{ + aliasWithPorts(groupp("group:home"), tailcfg.PortRangeAny), }, }, }, - 2, - ) + } + + // Create custom scenario: user1 and user2 with regular nodes, plus user-router with tagged node + spec := ScenarioSpec{ + NodesPerUser: 2, + Users: []string{"user1", "user2"}, + } + + scenario, err := NewScenario(spec) + require.NoError(t, err) defer scenario.ShutdownAssertNoPanics(t) - err := scenario.WaitForTailscaleSyncWithPeerCount(1, integrationutil.PeerSyncTimeout(), integrationutil.PeerSyncRetryInterval()) + err = scenario.CreateHeadscaleEnv( + []tsic.Option{ + tsic.WithNetfilter("off"), + tsic.WithDockerEntrypoint([]string{ + "/bin/sh", + "-c", + "/bin/sleep 3 ; apk add python3 curl ; update-ca-certificates ; python3 -m http.server --bind :: 80 & tailscaled --tun=tsdev", + }), + tsic.WithDockerWorkdir("/"), + }, + hsic.WithACLPolicy(policy), + hsic.WithTestName("acl-autogroup-self"), + hsic.WithEmbeddedDERPServerOnly(), + hsic.WithTLS(), + ) require.NoError(t, err) + // Add router node for user-router (single shared router node) + networks := scenario.Networks() + var network *dockertest.Network + if len(networks) > 0 { + network = networks[0] + } + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + routerUser, err := scenario.CreateUser("user-router") + require.NoError(t, err) + + authKey, err := scenario.CreatePreAuthKey(routerUser.GetId(), true, false) + require.NoError(t, err) + + // Create router node (tagged with tag:router-node) + routerClient, err := tsic.New( + scenario.Pool(), + "unstable", + tsic.WithCACert(headscale.GetCert()), + tsic.WithHeadscaleName(headscale.GetHostname()), + tsic.WithNetwork(network), + tsic.WithTags([]string{"tag:router-node"}), + tsic.WithNetfilter("off"), + tsic.WithDockerEntrypoint([]string{ + "/bin/sh", + "-c", + "/bin/sleep 3 ; apk add python3 curl ; update-ca-certificates ; python3 -m http.server --bind :: 80 & tailscaled --tun=tsdev", + }), + tsic.WithDockerWorkdir("/"), + ) + require.NoError(t, err) + + err = routerClient.WaitForNeedsLogin(integrationutil.PeerSyncTimeout()) + require.NoError(t, err) + + err = routerClient.Login(headscale.GetEndpoint(), authKey.GetKey()) + require.NoError(t, err) + + err = routerClient.WaitForRunning(integrationutil.PeerSyncTimeout()) + require.NoError(t, err) + + userRouterObj := scenario.GetOrCreateUser("user-router") + userRouterObj.Clients[routerClient.Hostname()] = routerClient + user1Clients, err := scenario.GetClients("user1") require.NoError(t, err) - user2Clients, err := scenario.GetClients("user2") require.NoError(t, err) - // Test that user1's devices can access each other + var user1Regular, user2Regular []TailscaleClient for _, client := range user1Clients { - for _, peer := range user1Clients { + status, err := client.Status() + require.NoError(t, err) + if status.Self != nil && (status.Self.Tags == nil || status.Self.Tags.Len() == 0) { + user1Regular = append(user1Regular, client) + } + } + for _, client := range user2Clients { + status, err := client.Status() + require.NoError(t, err) + if status.Self != nil && (status.Self.Tags == nil || status.Self.Tags.Len() == 0) { + user2Regular = append(user2Regular, client) + } + } + + require.NotEmpty(t, user1Regular, "user1 should have regular (untagged) devices") + require.NotEmpty(t, user2Regular, "user2 should have regular (untagged) devices") + require.NotNil(t, routerClient, "router node should exist") + + // Wait for all nodes to sync with their expected peer counts + // With our ACL policy: + // - Regular nodes (user1/user2): 1 same-user regular peer + 1 router-node = 2 peers + // - Router node: 2 user1 regular + 2 user2 regular = 4 peers + for _, client := range user1Regular { + err := client.WaitForPeers(2, integrationutil.PeerSyncTimeout(), integrationutil.PeerSyncRetryInterval()) + require.NoError(t, err, "user1 regular device %s should see 2 peers (1 same-user peer + 1 router)", client.Hostname()) + } + for _, client := range user2Regular { + err := client.WaitForPeers(2, integrationutil.PeerSyncTimeout(), integrationutil.PeerSyncRetryInterval()) + require.NoError(t, err, "user2 regular device %s should see 2 peers (1 same-user peer + 1 router)", client.Hostname()) + } + err = routerClient.WaitForPeers(4, integrationutil.PeerSyncTimeout(), integrationutil.PeerSyncRetryInterval()) + require.NoError(t, err, "router should see 4 peers (all group:home regular nodes)") + + // Test that user1's regular devices can access each other + for _, client := range user1Regular { + for _, peer := range user1Regular { if client.Hostname() == peer.Hostname() { continue } @@ -1656,13 +1789,13 @@ func TestACLAutogroupSelf(t *testing.T) { result, err := client.Curl(url) assert.NoError(c, err) assert.Len(c, result, 13) - }, 10*time.Second, 200*time.Millisecond, "user1 device should reach other user1 device") + }, 10*time.Second, 200*time.Millisecond, "user1 device should reach other user1 device via autogroup:self") } } - // Test that user2's devices can access each other - for _, client := range user2Clients { - for _, peer := range user2Clients { + // Test that user2's regular devices can access each other + for _, client := range user2Regular { + for _, peer := range user2Regular { if client.Hostname() == peer.Hostname() { continue } @@ -1677,36 +1810,64 @@ func TestACLAutogroupSelf(t *testing.T) { result, err := client.Curl(url) assert.NoError(c, err) assert.Len(c, result, 13) - }, 10*time.Second, 200*time.Millisecond, "user2 device should reach other user2 device") + }, 10*time.Second, 200*time.Millisecond, "user2 device should reach other user2 device via autogroup:self") } } - // Test that devices from different users cannot access each other - for _, client := range user1Clients { - for _, peer := range user2Clients { + // Test that user1's regular devices can access router-node + for _, client := range user1Regular { + fqdn, err := routerClient.FQDN() + require.NoError(t, err) + url := fmt.Sprintf("http://%s/etc/hostname", fqdn) + t.Logf("url from %s (user1) to %s (router-node) - should SUCCEED", client.Hostname(), fqdn) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := client.Curl(url) + assert.NoError(c, err) + assert.NotEmpty(c, result, "user1 should be able to access router-node via group:home -> tag:router-node rule") + }, 10*time.Second, 200*time.Millisecond, "user1 device should reach router-node (proves autogroup:self doesn't interfere)") + } + + // Test that user2's regular devices can access router-node + for _, client := range user2Regular { + fqdn, err := routerClient.FQDN() + require.NoError(t, err) + url := fmt.Sprintf("http://%s/etc/hostname", fqdn) + t.Logf("url from %s (user2) to %s (router-node) - should SUCCEED", client.Hostname(), fqdn) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := client.Curl(url) + assert.NoError(c, err) + assert.NotEmpty(c, result, "user2 should be able to access router-node via group:home -> tag:router-node rule") + }, 10*time.Second, 200*time.Millisecond, "user2 device should reach router-node (proves autogroup:self doesn't interfere)") + } + + // Test that devices from different users cannot access each other's regular devices + for _, client := range user1Regular { + for _, peer := range user2Regular { fqdn, err := peer.FQDN() require.NoError(t, err) url := fmt.Sprintf("http://%s/etc/hostname", fqdn) - t.Logf("url from %s (user1) to %s (user2) - should FAIL", client.Hostname(), fqdn) + t.Logf("url from %s (user1) to %s (user2 regular) - should FAIL", client.Hostname(), fqdn) result, err := client.Curl(url) - assert.Empty(t, result, "user1 should not be able to access user2's devices with autogroup:self") - assert.Error(t, err, "connection from user1 to user2 should fail") + assert.Empty(t, result, "user1 should not be able to access user2's regular devices (autogroup:self isolation)") + assert.Error(t, err, "connection from user1 to user2 regular device should fail") } } - for _, client := range user2Clients { - for _, peer := range user1Clients { + for _, client := range user2Regular { + for _, peer := range user1Regular { fqdn, err := peer.FQDN() require.NoError(t, err) url := fmt.Sprintf("http://%s/etc/hostname", fqdn) - t.Logf("url from %s (user2) to %s (user1) - should FAIL", client.Hostname(), fqdn) + t.Logf("url from %s (user2) to %s (user1 regular) - should FAIL", client.Hostname(), fqdn) result, err := client.Curl(url) - assert.Empty(t, result, "user2 should not be able to access user1's devices with autogroup:self") - assert.Error(t, err, "connection from user2 to user1 should fail") + assert.Empty(t, result, "user2 should not be able to access user1's regular devices (autogroup:self isolation)") + assert.Error(t, err, "connection from user2 to user1 regular device should fail") } } } From c649c89e00851e39b102cc3d6fd8816618d86565 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 1 Nov 2025 14:28:50 +0100 Subject: [PATCH 011/178] policy: Reproduce exit node visibility issues Reproduces #2784 and #2788 Signed-off-by: Kristoffer Dalby --- hscontrol/policy/policy_test.go | 278 +++++++++++++++++++++++++++++++- 1 file changed, 277 insertions(+), 1 deletion(-) diff --git a/hscontrol/policy/policy_test.go b/hscontrol/policy/policy_test.go index b849d470..c016fa58 100644 --- a/hscontrol/policy/policy_test.go +++ b/hscontrol/policy/policy_test.go @@ -10,6 +10,7 @@ import ( "github.com/juanfont/headscale/hscontrol/policy/matcher" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" "tailscale.com/tailcfg" @@ -782,12 +783,287 @@ func TestReduceNodes(t *testing.T) { got = append(got, v.AsStruct()) } if diff := cmp.Diff(tt.want, got, util.Comparers...); diff != "" { - t.Errorf("FilterNodesByACL() unexpected result (-want +got):\n%s", diff) + t.Errorf("ReduceNodes() unexpected result (-want +got):\n%s", diff) + t.Log("Matchers: ") + for _, m := range matchers { + t.Log("\t+", m.DebugString()) + } } }) } } +func TestReduceNodesFromPolicy(t *testing.T) { + n := func(id types.NodeID, ip, hostname, username string, routess ...string) *types.Node { + var routes []netip.Prefix + for _, route := range routess { + routes = append(routes, netip.MustParsePrefix(route)) + } + + return &types.Node{ + ID: id, + IPv4: ap(ip), + Hostname: hostname, + User: types.User{Name: username}, + Hostinfo: &tailcfg.Hostinfo{ + RoutableIPs: routes, + }, + ApprovedRoutes: routes, + } + } + + type args struct { + } + tests := []struct { + name string + nodes types.Nodes + policy string + node *types.Node + want types.Nodes + wantMatchers int + }{ + { + name: "2788-exit-node-too-visible", + nodes: types.Nodes{ + n(1, "100.64.0.1", "mobile", "mobile"), + n(2, "100.64.0.2", "server", "server"), + n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"), + }, + policy: ` +{ + "hosts": { + "mobile": "100.64.0.1/32", + "server": "100.64.0.2/32", + "exit": "100.64.0.3/32" + }, + + "acls": [ + { + "action": "accept", + "src": [ + "mobile" + ], + "dst": [ + "server:80" + ] + } + ] +}`, + node: n(1, "100.64.0.1", "mobile", "mobile"), + want: types.Nodes{ + n(2, "100.64.0.2", "server", "server"), + }, + wantMatchers: 1, + }, + { + name: "2788-exit-node-autogroup:internet", + nodes: types.Nodes{ + n(1, "100.64.0.1", "mobile", "mobile"), + n(2, "100.64.0.2", "server", "server"), + n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"), + }, + policy: ` +{ + "hosts": { + "mobile": "100.64.0.1/32", + "server": "100.64.0.2/32", + "exit": "100.64.0.3/32" + }, + + "acls": [ + { + "action": "accept", + "src": [ + "mobile" + ], + "dst": [ + "server:80" + ] + }, + { + "action": "accept", + "src": [ + "mobile" + ], + "dst": [ + "autogroup:internet:*" + ] + } + ] +}`, + node: n(1, "100.64.0.1", "mobile", "mobile"), + want: types.Nodes{ + n(2, "100.64.0.2", "server", "server"), + n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"), + }, + wantMatchers: 2, + }, + { + name: "2788-exit-node-0000-route", + nodes: types.Nodes{ + n(1, "100.64.0.1", "mobile", "mobile"), + n(2, "100.64.0.2", "server", "server"), + n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"), + }, + policy: ` +{ + "hosts": { + "mobile": "100.64.0.1/32", + "server": "100.64.0.2/32", + "exit": "100.64.0.3/32" + }, + + "acls": [ + { + "action": "accept", + "src": [ + "mobile" + ], + "dst": [ + "server:80" + ] + }, + { + "action": "accept", + "src": [ + "mobile" + ], + "dst": [ + "0.0.0.0/0:*" + ] + } + ] +}`, + node: n(1, "100.64.0.1", "mobile", "mobile"), + want: types.Nodes{ + n(2, "100.64.0.2", "server", "server"), + n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"), + }, + wantMatchers: 2, + }, + { + name: "2788-exit-node-::0-route", + nodes: types.Nodes{ + n(1, "100.64.0.1", "mobile", "mobile"), + n(2, "100.64.0.2", "server", "server"), + n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"), + }, + policy: ` +{ + "hosts": { + "mobile": "100.64.0.1/32", + "server": "100.64.0.2/32", + "exit": "100.64.0.3/32" + }, + + "acls": [ + { + "action": "accept", + "src": [ + "mobile" + ], + "dst": [ + "server:80" + ] + }, + { + "action": "accept", + "src": [ + "mobile" + ], + "dst": [ + "::0/0:*" + ] + } + ] +}`, + node: n(1, "100.64.0.1", "mobile", "mobile"), + want: types.Nodes{ + n(2, "100.64.0.2", "server", "server"), + n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"), + }, + wantMatchers: 2, + }, + { + name: "2784-split-exit-node-access", + nodes: types.Nodes{ + n(1, "100.64.0.1", "user", "user"), + n(2, "100.64.0.2", "exit1", "exit", "0.0.0.0/0", "::/0"), + n(3, "100.64.0.3", "exit2", "exit", "0.0.0.0/0", "::/0"), + n(4, "100.64.0.4", "otheruser", "otheruser"), + }, + policy: ` +{ + "hosts": { + "user": "100.64.0.1/32", + "exit1": "100.64.0.2/32", + "exit2": "100.64.0.3/32", + "otheruser": "100.64.0.4/32", + }, + + "acls": [ + { + "action": "accept", + "src": [ + "user" + ], + "dst": [ + "exit1:*" + ] + }, + { + "action": "accept", + "src": [ + "otheruser" + ], + "dst": [ + "exit2:*" + ] + } + ] +}`, + node: n(1, "100.64.0.1", "user", "user"), + want: types.Nodes{ + n(2, "100.64.0.2", "exit1", "exit", "0.0.0.0/0", "::/0"), + }, + wantMatchers: 2, + }, + } + + for _, tt := range tests { + for idx, pmf := range PolicyManagerFuncsForTest([]byte(tt.policy)) { + t.Run(fmt.Sprintf("%s-index%d", tt.name, idx), func(t *testing.T) { + var pm PolicyManager + var err error + pm, err = pmf(nil, tt.nodes.ViewSlice()) + require.NoError(t, err) + + matchers, err := pm.MatchersForNode(tt.node.View()) + require.NoError(t, err) + assert.Len(t, matchers, tt.wantMatchers) + + gotViews := ReduceNodes( + tt.node.View(), + tt.nodes.ViewSlice(), + matchers, + ) + // Convert views back to nodes for comparison in tests + var got types.Nodes + for _, v := range gotViews.All() { + got = append(got, v.AsStruct()) + } + if diff := cmp.Diff(tt.want, got, util.Comparers...); diff != "" { + t.Errorf("TestReduceNodesFromPolicy() unexpected result (-want +got):\n%s", diff) + t.Log("Matchers: ") + for _, m := range matchers { + t.Log("\t+", m.DebugString()) + } + } + }) + } + } +} + func TestSSHPolicyRules(t *testing.T) { users := []types.User{ {Name: "user1", Model: gorm.Model{ID: 1}}, From 1c0bb0338d20d3c91ad83685cb4530fd084d0a03 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 1 Nov 2025 14:25:07 +0100 Subject: [PATCH 012/178] types: split SubnetRoutes and ExitRoutes There are situations where the subnet routes and exit nodes must be treated differently. This splits it so SubnetRoutes only returns routes that are not exit routes. It adds `IsExitRoutes` and `AllApprovedRoutes` for convenience. Signed-off-by: Kristoffer Dalby --- hscontrol/types/node.go | 50 ++++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 8cf40ced..bf42dcd1 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -269,11 +269,19 @@ func (node *Node) Prefixes() []netip.Prefix { // node has any exit routes enabled. // If none are enabled, it will return nil. func (node *Node) ExitRoutes() []netip.Prefix { - if slices.ContainsFunc(node.SubnetRoutes(), tsaddr.IsExitRoute) { - return tsaddr.ExitRoutes() + var routes []netip.Prefix + + for _, route := range node.AnnouncedRoutes() { + if tsaddr.IsExitRoute(route) && slices.Contains(node.ApprovedRoutes, route) { + routes = append(routes, route) + } } - return nil + return routes +} + +func (node *Node) IsExitNode() bool { + return len(node.ExitRoutes()) > 0 } func (node *Node) IPsAsString() []string { @@ -440,16 +448,22 @@ func (node *Node) AnnouncedRoutes() []netip.Prefix { return node.Hostinfo.RoutableIPs } -// SubnetRoutes returns the list of routes that the node announces and are approved. +// SubnetRoutes returns the list of routes (excluding exit routes) that the node +// announces and are approved. // -// IMPORTANT: This method is used for internal data structures and should NOT be used -// for the gRPC Proto conversion. For Proto, SubnetRoutes must be populated manually -// with PrimaryRoutes to ensure it includes only routes actively served by the node. -// See the comment in Proto() method and the implementation in grpcv1.go/nodesToProto. +// IMPORTANT: This method is used for internal data structures and should NOT be +// used for the gRPC Proto conversion. For Proto, SubnetRoutes must be populated +// manually with PrimaryRoutes to ensure it includes only routes actively served +// by the node. See the comment in Proto() method and the implementation in +// grpcv1.go/nodesToProto. func (node *Node) SubnetRoutes() []netip.Prefix { var routes []netip.Prefix for _, route := range node.AnnouncedRoutes() { + if tsaddr.IsExitRoute(route) { + continue + } + if slices.Contains(node.ApprovedRoutes, route) { routes = append(routes, route) } @@ -463,6 +477,11 @@ func (node *Node) IsSubnetRouter() bool { return len(node.SubnetRoutes()) > 0 } +// AllApprovedRoutes returns the combination of SubnetRoutes and ExitRoutes +func (node *Node) AllApprovedRoutes() []netip.Prefix { + return append(node.SubnetRoutes(), node.ExitRoutes()...) +} + func (node *Node) String() string { return node.Hostname } @@ -653,6 +672,7 @@ func (node Node) DebugString() string { fmt.Fprintf(&sb, "\tApprovedRoutes: %v\n", node.ApprovedRoutes) fmt.Fprintf(&sb, "\tAnnouncedRoutes: %v\n", node.AnnouncedRoutes()) fmt.Fprintf(&sb, "\tSubnetRoutes: %v\n", node.SubnetRoutes()) + fmt.Fprintf(&sb, "\tExitRoutes: %v\n", node.ExitRoutes()) sb.WriteString("\n") return sb.String() @@ -730,6 +750,13 @@ func (v NodeView) IsSubnetRouter() bool { return v.ж.IsSubnetRouter() } +func (v NodeView) AllApprovedRoutes() []netip.Prefix { + if !v.Valid() { + return nil + } + return v.ж.AllApprovedRoutes() +} + func (v NodeView) AppendToIPSet(build *netipx.IPSetBuilder) { if !v.Valid() { return @@ -808,6 +835,13 @@ func (v NodeView) ExitRoutes() []netip.Prefix { return v.ж.ExitRoutes() } +func (v NodeView) IsExitNode() bool { + if !v.Valid() { + return false + } + return v.ж.IsExitNode() +} + // RequestTags returns the ACL tags that the node is requesting. func (v NodeView) RequestTags() []string { if !v.Valid() || !v.Hostinfo().Valid() { From d7a43a7cf11d8bfe72b3fcf38ebf974e2a040c4d Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 1 Nov 2025 14:28:32 +0100 Subject: [PATCH 013/178] state: use AllApprovedRoutes instead of SubnetRoutes Signed-off-by: Kristoffer Dalby --- hscontrol/db/node.go | 12 ++++++++++++ hscontrol/db/node_test.go | 6 +++--- hscontrol/mapper/tail_test.go | 4 +++- hscontrol/state/state.go | 14 +++++++------- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go index 4e4533be..70d3afaf 100644 --- a/hscontrol/db/node.go +++ b/hscontrol/db/node.go @@ -18,6 +18,7 @@ import ( "github.com/juanfont/headscale/hscontrol/util" "github.com/rs/zerolog/log" "gorm.io/gorm" + "tailscale.com/net/tsaddr" "tailscale.com/types/key" "tailscale.com/types/ptr" ) @@ -232,6 +233,17 @@ func SetApprovedRoutes( return nil } + // When approving exit routes, ensure both IPv4 and IPv6 are included + // If either 0.0.0.0/0 or ::/0 is being approved, both should be approved + hasIPv4Exit := slices.Contains(routes, tsaddr.AllIPv4()) + hasIPv6Exit := slices.Contains(routes, tsaddr.AllIPv6()) + + if hasIPv4Exit && !hasIPv6Exit { + routes = append(routes, tsaddr.AllIPv6()) + } else if hasIPv6Exit && !hasIPv4Exit { + routes = append(routes, tsaddr.AllIPv4()) + } + b, err := json.Marshal(routes) if err != nil { return err diff --git a/hscontrol/db/node_test.go b/hscontrol/db/node_test.go index b51dba1c..0efd0e8b 100644 --- a/hscontrol/db/node_test.go +++ b/hscontrol/db/node_test.go @@ -476,7 +476,7 @@ func TestAutoApproveRoutes(t *testing.T) { require.NoError(t, err) } - newRoutes2, changed2 := policy.ApproveRoutesWithPolicy(pm, nodeTagged.View(), node.ApprovedRoutes, tt.routes) + newRoutes2, changed2 := policy.ApproveRoutesWithPolicy(pm, nodeTagged.View(), nodeTagged.ApprovedRoutes, tt.routes) if changed2 { err = SetApprovedRoutes(adb.DB, nodeTagged.ID, newRoutes2) require.NoError(t, err) @@ -490,7 +490,7 @@ func TestAutoApproveRoutes(t *testing.T) { if len(expectedRoutes1) == 0 { expectedRoutes1 = nil } - if diff := cmp.Diff(expectedRoutes1, node1ByID.SubnetRoutes(), util.Comparers...); diff != "" { + if diff := cmp.Diff(expectedRoutes1, node1ByID.AllApprovedRoutes(), util.Comparers...); diff != "" { t.Errorf("unexpected enabled routes (-want +got):\n%s", diff) } @@ -501,7 +501,7 @@ func TestAutoApproveRoutes(t *testing.T) { if len(expectedRoutes2) == 0 { expectedRoutes2 = nil } - if diff := cmp.Diff(expectedRoutes2, node2ByID.SubnetRoutes(), util.Comparers...); diff != "" { + if diff := cmp.Diff(expectedRoutes2, node2ByID.AllApprovedRoutes(), util.Comparers...); diff != "" { t.Errorf("unexpected enabled routes (-want +got):\n%s", diff) } }) diff --git a/hscontrol/mapper/tail_test.go b/hscontrol/mapper/tail_test.go index ac96028e..3a3b39d1 100644 --- a/hscontrol/mapper/tail_test.go +++ b/hscontrol/mapper/tail_test.go @@ -108,11 +108,12 @@ func TestTailNode(t *testing.T) { Hostinfo: &tailcfg.Hostinfo{ RoutableIPs: []netip.Prefix{ tsaddr.AllIPv4(), + tsaddr.AllIPv6(), netip.MustParsePrefix("192.168.0.0/24"), netip.MustParsePrefix("172.0.0.0/10"), }, }, - ApprovedRoutes: []netip.Prefix{tsaddr.AllIPv4(), netip.MustParsePrefix("192.168.0.0/24")}, + ApprovedRoutes: []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6(), netip.MustParsePrefix("192.168.0.0/24")}, CreatedAt: created, }, dnsConfig: &tailcfg.DNSConfig{}, @@ -150,6 +151,7 @@ func TestTailNode(t *testing.T) { Hostinfo: hiview(tailcfg.Hostinfo{ RoutableIPs: []netip.Prefix{ tsaddr.AllIPv4(), + tsaddr.AllIPv6(), netip.MustParsePrefix("192.168.0.0/24"), netip.MustParsePrefix("172.0.0.0/10"), }, diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 1d450cb6..c340adc2 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -456,9 +456,9 @@ func (s *State) Connect(id types.NodeID) []change.ChangeSet { log.Info().Uint64("node.id", id.Uint64()).Str("node.name", node.Hostname()).Msg("Node connected") // Use the node's current routes for primary route update - // SubnetRoutes() returns only the intersection of announced AND approved routes - // We MUST use SubnetRoutes() to maintain the security model - routeChange := s.primaryRoutes.SetRoutes(id, node.SubnetRoutes()...) + // AllApprovedRoutes() returns only the intersection of announced AND approved routes + // We MUST use AllApprovedRoutes() to maintain the security model + routeChange := s.primaryRoutes.SetRoutes(id, node.AllApprovedRoutes()...) if routeChange { c = append(c, change.NodeAdded(id)) @@ -656,7 +656,7 @@ func (s *State) SetApprovedRoutes(nodeID types.NodeID, routes []netip.Prefix) (t // Update primary routes table based on SubnetRoutes (intersection of announced and approved). // The primary routes table is what the mapper uses to generate network maps, so updating it // here ensures that route changes are distributed to peers. - routeChange := s.primaryRoutes.SetRoutes(nodeID, nodeView.SubnetRoutes()...) + routeChange := s.primaryRoutes.SetRoutes(nodeID, nodeView.AllApprovedRoutes()...) // If routes changed or the changeset isn't already a full update, trigger a policy change // to ensure all nodes get updated network maps @@ -1711,7 +1711,7 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest } if needsRouteUpdate { - // SetNodeRoutes sets the active/distributed routes, so we must use SubnetRoutes() + // SetNodeRoutes sets the active/distributed routes, so we must use AllApprovedRoutes() // which returns only the intersection of announced AND approved routes. // Using AnnouncedRoutes() would bypass the security model and auto-approve everything. log.Debug(). @@ -1719,9 +1719,9 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest Uint64("node.id", id.Uint64()). Strs("announcedRoutes", util.PrefixesToString(updatedNode.AnnouncedRoutes())). Strs("approvedRoutes", util.PrefixesToString(updatedNode.ApprovedRoutes().AsSlice())). - Strs("subnetRoutes", util.PrefixesToString(updatedNode.SubnetRoutes())). + Strs("allApprovedRoutes", util.PrefixesToString(updatedNode.AllApprovedRoutes())). Msg("updating node routes for distribution") - nodeRouteChange = s.SetNodeRoutes(id, updatedNode.SubnetRoutes()...) + nodeRouteChange = s.SetNodeRoutes(id, updatedNode.AllApprovedRoutes()...) } _, policyChange, err := s.persistNodeToDB(updatedNode) From bd9cf42b96dd11c9483cadc3018b23a1adf671bf Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 1 Nov 2025 14:27:13 +0100 Subject: [PATCH 014/178] types: NodeView CanAccess uses internal Signed-off-by: Kristoffer Dalby --- hscontrol/types/node.go | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index bf42dcd1..50b9b049 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -698,27 +698,11 @@ func (v NodeView) InIPSet(set *netipx.IPSet) bool { } func (v NodeView) CanAccess(matchers []matcher.Match, node2 NodeView) bool { - if !v.Valid() || !node2.Valid() { + if !v.Valid() { return false } - src := v.IPs() - allowedIPs := node2.IPs() - for _, matcher := range matchers { - if !matcher.SrcsContainsIPs(src...) { - continue - } - - if matcher.DestsContainsIP(allowedIPs...) { - return true - } - - if matcher.DestsOverlapsPrefixes(node2.SubnetRoutes()...) { - return true - } - } - - return false + return v.ж.CanAccess(matchers, node2.AsStruct()) } func (v NodeView) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool { From d9c3eaf8c8208a408be67695f48798b195b2109a Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 1 Nov 2025 14:27:59 +0100 Subject: [PATCH 015/178] matcher: Add func for comparing Dests and TheInternet Signed-off-by: Kristoffer Dalby --- hscontrol/policy/matcher/matcher.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/hscontrol/policy/matcher/matcher.go b/hscontrol/policy/matcher/matcher.go index aac5a5f3..afc3cf68 100644 --- a/hscontrol/policy/matcher/matcher.go +++ b/hscontrol/policy/matcher/matcher.go @@ -7,6 +7,7 @@ import ( "github.com/juanfont/headscale/hscontrol/util" "go4.org/netipx" + "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" ) @@ -91,3 +92,12 @@ func (m *Match) SrcsOverlapsPrefixes(prefixes ...netip.Prefix) bool { func (m *Match) DestsOverlapsPrefixes(prefixes ...netip.Prefix) bool { return slices.ContainsFunc(prefixes, m.dests.OverlapsPrefix) } + +// DestsIsTheInternet reports if the destination is equal to "the internet" +// which is a IPSet that represents "autogroup:internet" and is special +// cased for exit nodes. +func (m Match) DestsIsTheInternet() bool { + return m.dests.Equal(util.TheInternet()) || + m.dests.ContainsPrefix(tsaddr.AllIPv4()) || + m.dests.ContainsPrefix(tsaddr.AllIPv6()) +} From 2024219bd10adbb5c0d29f900ed0961ace8cc15c Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 1 Nov 2025 14:29:50 +0100 Subject: [PATCH 016/178] types: Distinguish subnet and exit node access When we fixed the issue of node visibility of nodes that only had access to eachother because of a subnet route, we gave all nodes access to all exit routes by accident. This commit splits exit nodes and subnet routes in the access. If a matcher indicates that the node should have access to any part of the subnet routes, we do not remove it from the node list. If a matcher destination is equal to the internet, and the target node is an exit node, we also do not remove the access. Fixes #2784 Fixes #2788 Signed-off-by: Kristoffer Dalby --- hscontrol/types/node.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 50b9b049..c6429669 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -319,9 +319,16 @@ func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool { return true } + // Check if the node has access to routes that might be part of a + // smaller subnet that is served from node2 as a subnet router. if matcher.DestsOverlapsPrefixes(node2.SubnetRoutes()...) { return true } + + // If the dst is "the internet" and node2 is an exit node, allow access. + if matcher.DestsIsTheInternet() && node2.IsExitNode() { + return true + } } return false From 5cd15c36568acc562ca6639176b38969c64308a7 Mon Sep 17 00:00:00 2001 From: Andrey Bobelev Date: Tue, 4 Nov 2025 07:18:51 +0200 Subject: [PATCH 017/178] fix: make state cookies valid when client uses multiple login URLs On Windows, if the user clicks the Tailscale icon in the system tray, it opens a login URL in the browser. When the login URL is opened, `state/nonce` cookies are set for that particular URL. If the user clicks the icon again, a new login URL is opened in the browser, and new cookies are set. If the user proceeds with auth in the first tab, the redirect results in a "state did not match" error. This patch ensures that each opened login URL sets an individual cookie that remains valid on the `/oidc/callback` page. `TestOIDCMultipleOpenedLoginUrls` illustrates and tests this behavior. --- .github/workflows/test-integration.yaml | 1 + hscontrol/oidc.go | 17 ++- integration/auth_oidc_test.go | 113 +++++++++++++++ integration/scenario.go | 182 +++++++++++++++++++++--- 4 files changed, 287 insertions(+), 26 deletions(-) diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 735c50bf..fe934aab 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -38,6 +38,7 @@ jobs: - TestOIDCAuthenticationWithPKCE - TestOIDCReloginSameNodeNewUser - TestOIDCFollowUpUrl + - TestOIDCMultipleOpenedLoginUrls - TestOIDCReloginSameNodeSameUser - TestAuthWebFlowAuthenticationPingAll - TestAuthWebFlowLogoutAndReloginSameUser diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index 84d00712..7c7895c6 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -213,7 +213,8 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler( return } - cookieState, err := req.Cookie("state") + stateCookieName := getCookieName("state", state) + cookieState, err := req.Cookie(stateCookieName) if err != nil { httpError(writer, NewHTTPError(http.StatusBadRequest, "state not found", err)) return @@ -235,8 +236,13 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler( httpError(writer, err) return } + if idToken.Nonce == "" { + httpError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found in IDToken", err)) + return + } - nonce, err := req.Cookie("nonce") + nonceCookieName := getCookieName("nonce", idToken.Nonce) + nonce, err := req.Cookie(nonceCookieName) if err != nil { httpError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found", err)) return @@ -584,6 +590,11 @@ func renderOIDCCallbackTemplate( return &content, nil } +// getCookieName generates a unique cookie name based on a cookie value. +func getCookieName(baseName, value string) string { + return fmt.Sprintf("%s_%s", baseName, value[:6]) +} + func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string, error) { val, err := util.GenerateRandomStringURLSafe(64) if err != nil { @@ -592,7 +603,7 @@ func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string, c := &http.Cookie{ Path: "/oidc/callback", - Name: name, + Name: getCookieName(name, val), Value: val, MaxAge: int(time.Hour.Seconds()), Secure: r.TLS != nil, diff --git a/integration/auth_oidc_test.go b/integration/auth_oidc_test.go index 0a0b5b95..eebb8165 100644 --- a/integration/auth_oidc_test.go +++ b/integration/auth_oidc_test.go @@ -953,6 +953,119 @@ func TestOIDCFollowUpUrl(t *testing.T) { }, 10*time.Second, 200*time.Millisecond, "Waiting for expected node list after OIDC login") } +// TestOIDCMultipleOpenedLoginUrls tests the scenario: +// - client (mostly Windows) opens multiple browser tabs with different login URLs +// - client performs auth on the first opened browser tab +// +// This test makes sure that cookies are still valid for the first browser tab. +func TestOIDCMultipleOpenedLoginUrls(t *testing.T) { + IntegrationSkip(t) + + scenario, err := NewScenario( + ScenarioSpec{ + OIDCUsers: []mockoidc.MockUser{ + oidcMockUser("user1", true), + }, + }, + ) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + oidcMap := map[string]string{ + "HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(), + "HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(), + "CREDENTIALS_DIRECTORY_TEST": "/tmp", + "HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret", + } + + err = scenario.CreateHeadscaleEnvWithLoginURL( + nil, + hsic.WithTestName("oidcauthrelog"), + hsic.WithConfigEnv(oidcMap), + hsic.WithTLS(), + hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())), + hsic.WithEmbeddedDERPServerOnly(), + ) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + listUsers, err := headscale.ListUsers() + require.NoError(t, err) + assert.Empty(t, listUsers) + + ts, err := scenario.CreateTailscaleNode( + "unstable", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + ) + require.NoError(t, err) + + u1, err := ts.LoginWithURL(headscale.GetEndpoint()) + require.NoError(t, err) + + u2, err := ts.LoginWithURL(headscale.GetEndpoint()) + require.NoError(t, err) + + // make sure login URLs are different + require.NotEqual(t, u1.String(), u2.String()) + + loginClient, err := newLoginHTTPClient(ts.Hostname()) + require.NoError(t, err) + + // open the first login URL "in browser" + _, redirect1, err := doLoginURLWithClient(ts.Hostname(), u1, loginClient, false) + require.NoError(t, err) + // open the second login URL "in browser" + _, redirect2, err := doLoginURLWithClient(ts.Hostname(), u2, loginClient, false) + require.NoError(t, err) + + // two valid redirects with different state/nonce params + require.NotEqual(t, redirect1.String(), redirect2.String()) + + // complete auth with the first opened "browser tab" + _, redirect1, err = doLoginURLWithClient(ts.Hostname(), redirect1, loginClient, true) + require.NoError(t, err) + + listUsers, err = headscale.ListUsers() + require.NoError(t, err) + assert.Len(t, listUsers, 1) + + wantUsers := []*v1.User{ + { + Id: 1, + Name: "user1", + Email: "user1@headscale.net", + Provider: "oidc", + ProviderId: scenario.mockOIDC.Issuer() + "/user1", + }, + } + + sort.Slice( + listUsers, func(i, j int) bool { + return listUsers[i].GetId() < listUsers[j].GetId() + }, + ) + + if diff := cmp.Diff( + wantUsers, + listUsers, + cmpopts.IgnoreUnexported(v1.User{}), + cmpopts.IgnoreFields(v1.User{}, "CreatedAt"), + ); diff != "" { + t.Fatalf("unexpected users: %s", diff) + } + + assert.EventuallyWithT( + t, func(c *assert.CollectT) { + listNodes, err := headscale.ListNodes() + assert.NoError(c, err) + assert.Len(c, listNodes, 1) + }, 10*time.Second, 200*time.Millisecond, "Waiting for expected node list after OIDC login", + ) +} + // TestOIDCReloginSameNodeSameUser tests the scenario where a single Tailscale client // authenticates using OIDC (OpenID Connect), logs out, and then logs back in as the same user. // diff --git a/integration/scenario.go b/integration/scenario.go index aa844a7e..c3b5549c 100644 --- a/integration/scenario.go +++ b/integration/scenario.go @@ -860,47 +860,183 @@ func (s *Scenario) RunTailscaleUpWithURL(userStr, loginServer string) error { return fmt.Errorf("failed to up tailscale node: %w", errNoUserAvailable) } -// doLoginURL visits the given login URL and returns the body as a -// string. -func doLoginURL(hostname string, loginURL *url.URL) (string, error) { - log.Printf("%s login url: %s\n", hostname, loginURL.String()) +type debugJar struct { + inner *cookiejar.Jar + mu sync.RWMutex + store map[string]map[string]map[string]*http.Cookie // domain -> path -> name -> cookie +} - var err error +func newDebugJar() (*debugJar, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + return &debugJar{ + inner: jar, + store: make(map[string]map[string]map[string]*http.Cookie), + }, nil +} + +func (j *debugJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.inner.SetCookies(u, cookies) + + j.mu.Lock() + defer j.mu.Unlock() + + for _, c := range cookies { + if c == nil || c.Name == "" { + continue + } + domain := c.Domain + if domain == "" { + domain = u.Hostname() + } + path := c.Path + if path == "" { + path = "/" + } + if _, ok := j.store[domain]; !ok { + j.store[domain] = make(map[string]map[string]*http.Cookie) + } + if _, ok := j.store[domain][path]; !ok { + j.store[domain][path] = make(map[string]*http.Cookie) + } + j.store[domain][path][c.Name] = copyCookie(c) + } +} + +func (j *debugJar) Cookies(u *url.URL) []*http.Cookie { + return j.inner.Cookies(u) +} + +func (j *debugJar) Dump(w io.Writer) { + j.mu.RLock() + defer j.mu.RUnlock() + + for domain, paths := range j.store { + fmt.Fprintf(w, "Domain: %s\n", domain) + for path, byName := range paths { + fmt.Fprintf(w, " Path: %s\n", path) + for _, c := range byName { + fmt.Fprintf( + w, " %s=%s; Expires=%v; Secure=%v; HttpOnly=%v; SameSite=%v\n", + c.Name, c.Value, c.Expires, c.Secure, c.HttpOnly, c.SameSite, + ) + } + } + } +} + +func copyCookie(c *http.Cookie) *http.Cookie { + cc := *c + return &cc +} + +func newLoginHTTPClient(hostname string) (*http.Client, error) { hc := &http.Client{ Transport: LoggingRoundTripper{Hostname: hostname}, } - hc.Jar, err = cookiejar.New(nil) + + jar, err := newDebugJar() if err != nil { - return "", fmt.Errorf("%s failed to create cookiejar : %w", hostname, err) + return nil, fmt.Errorf("%s failed to create cookiejar: %w", hostname, err) + } + + hc.Jar = jar + + return hc, nil +} + +// doLoginURL visits the given login URL and returns the body as a string. +func doLoginURL(hostname string, loginURL *url.URL) (string, error) { + log.Printf("%s login url: %s\n", hostname, loginURL.String()) + + hc, err := newLoginHTTPClient(hostname) + if err != nil { + return "", err + } + + body, _, err := doLoginURLWithClient(hostname, loginURL, hc, true) + if err != nil { + return "", err + } + + return body, nil +} + +// doLoginURLWithClient performs the login request using the provided HTTP client. +// When followRedirects is false, it will return the first redirect without following it. +func doLoginURLWithClient(hostname string, loginURL *url.URL, hc *http.Client, followRedirects bool) ( + string, + *url.URL, + error, +) { + if hc == nil { + return "", nil, fmt.Errorf("%s http client is nil", hostname) + } + + if loginURL == nil { + return "", nil, fmt.Errorf("%s login url is nil", hostname) } log.Printf("%s logging in with url: %s", hostname, loginURL.String()) ctx := context.Background() - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, loginURL.String(), nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, loginURL.String(), nil) + if err != nil { + return "", nil, fmt.Errorf("%s failed to create http request: %w", hostname, err) + } + + originalRedirect := hc.CheckRedirect + if !followRedirects { + hc.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + } + defer func() { + hc.CheckRedirect = originalRedirect + }() + resp, err := hc.Do(req) if err != nil { - return "", fmt.Errorf("%s failed to send http request: %w", hostname, err) + return "", nil, fmt.Errorf("%s failed to send http request: %w", hostname, err) } - - log.Printf("cookies: %+v", hc.Jar.Cookies(loginURL)) - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - log.Printf("body: %s", body) - - return "", fmt.Errorf("%s response code of login request was %w", hostname, err) - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + bodyBytes, err := io.ReadAll(resp.Body) if err != nil { - log.Printf("%s failed to read response body: %s", hostname, err) + return "", nil, fmt.Errorf("%s failed to read response body: %w", hostname, err) + } + body := string(bodyBytes) - return "", fmt.Errorf("%s failed to read response body: %w", hostname, err) + var redirectURL *url.URL + if resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest { + redirectURL, err = resp.Location() + if err != nil { + return body, nil, fmt.Errorf("%s failed to resolve redirect location: %w", hostname, err) + } } - return string(body), nil + if followRedirects && resp.StatusCode != http.StatusOK { + log.Printf("body: %s", body) + + return body, redirectURL, fmt.Errorf("%s unexpected status code %d", hostname, resp.StatusCode) + } + + if resp.StatusCode >= http.StatusBadRequest { + log.Printf("body: %s", body) + + return body, redirectURL, fmt.Errorf("%s unexpected status code %d", hostname, resp.StatusCode) + } + + if hc.Jar != nil { + if jar, ok := hc.Jar.(*debugJar); ok { + jar.Dump(os.Stdout) + } else { + log.Printf("cookies: %+v", hc.Jar.Cookies(loginURL)) + } + } + + return body, redirectURL, nil } var errParseAuthPage = errors.New("failed to parse auth page") From 5a2ee0c391eef946a2fa8afa9d09a913ea490cf5 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 10 Nov 2025 16:19:00 +0100 Subject: [PATCH 018/178] db: add comment about removing migrations Signed-off-by: Kristoffer Dalby --- hscontrol/db/db.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index 04c6cc0a..2035ec41 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -952,6 +952,13 @@ AND auth_key_id NOT IN ( return nil }, }, + + // Migrations **above** this points will be REMOVED in version **0.29.0** + // This is to clean up a lot of old migrations that is seldom used + // and carries a lot of technical debt. + // Any new migrations should be added after the comment below and follow + // the rules it sets out. + // From this point, the following rules must be followed: // - NEVER use gorm.AutoMigrate, write the exact migration steps needed // - AutoMigrate depends on the struct staying exactly the same, which it won't over time. From 28faf8cd712657394e889bc02255af8ee8000230 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 10 Nov 2025 16:12:41 +0100 Subject: [PATCH 019/178] db: add defensive removal of old indicies Signed-off-by: Kristoffer Dalby --- hscontrol/db/db.go | 28 +++++++++++++++++++ ...ump_schema-to-0.27.0-old-table-cleanup.sql | 11 ++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index 2035ec41..4eefee91 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -952,6 +952,34 @@ AND auth_key_id NOT IN ( return nil }, }, + { + // Drop all indices that are no longer in use and has existed. + // They potentially still present from broken migrations in the past. + // They should all be cleaned up by the db engine, but we are a bit + // conservative to ensure all our previous mess is cleaned up. + ID: "202511101554-drop-old-idx", + Migrate: func(tx *gorm.DB) error { + for _, oldIdx := range []struct{ name, table string }{ + {"idx_namespaces_deleted_at", "namespaces"}, + {"idx_routes_deleted_at", "routes"}, + {"idx_shared_machines_deleted_at", "shared_machines"}, + } { + err := tx.Migrator().DropIndex(oldIdx.table, oldIdx.name) + if err != nil { + log.Trace(). + Str("index", oldIdx.name). + Str("table", oldIdx.table). + Err(err). + Msg("Error dropping old index, continuing...") + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, + }, // Migrations **above** this points will be REMOVED in version **0.29.0** // This is to clean up a lot of old migrations that is seldom used diff --git a/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql b/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql index 388fefbc..d911e960 100644 --- a/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql +++ b/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql @@ -31,10 +31,15 @@ CREATE UNIQUE INDEX idx_name_provider_identifier ON users (name,provider_identif CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users (name) WHERE provider_identifier IS NULL; -- Create all the old tables we have had and ensure they are clean up. -CREATE TABLE `namespaces` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `namespaces` (`id` text,`deleted_at` datetime,PRIMARY KEY (`id`)); CREATE TABLE `machines` (`id` text,PRIMARY KEY (`id`)); CREATE TABLE `kvs` (`id` text,PRIMARY KEY (`id`)); -CREATE TABLE `shared_machines` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `shared_machines` (`id` text,`deleted_at` datetime,PRIMARY KEY (`id`)); CREATE TABLE `pre_auth_key_acl_tags` (`id` text,PRIMARY KEY (`id`)); -CREATE TABLE `routes` (`id` text,PRIMARY KEY (`id`)); +CREATE TABLE `routes` (`id` text,`deleted_at` datetime,PRIMARY KEY (`id`)); + +CREATE INDEX `idx_routes_deleted_at` ON `routes`(`deleted_at`); +CREATE INDEX `idx_namespaces_deleted_at` ON `namespaces`(`deleted_at`); +CREATE INDEX `idx_shared_machines_deleted_at` ON `shared_machines`(`deleted_at`); + COMMIT; From a28d9bed6d42c486201949d6eee140ab9af876d5 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 10 Nov 2025 16:57:07 +0100 Subject: [PATCH 020/178] policy: reproduce 2863 in test reproduce that if a user does not exist, the ssh policy ends up empty Updates #2863 Signed-off-by: Kristoffer Dalby --- hscontrol/policy/policy_test.go | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/hscontrol/policy/policy_test.go b/hscontrol/policy/policy_test.go index c016fa58..10f6bf0a 100644 --- a/hscontrol/policy/policy_test.go +++ b/hscontrol/policy/policy_test.go @@ -1353,6 +1353,55 @@ func TestSSHPolicyRules(t *testing.T) { }, }}, }, + { + name: "2863-allow-predefined-missing-users", + targetNode: taggedClient, + peers: types.Nodes{&nodeUser2}, + policy: `{ + "groups": { + "group:example-infra": [ + "user2@", + "not-created-yet@", + ], + }, + "tagOwners": { + "tag:client": [ + "user2@" + ], + }, + "ssh": [ + // Allow infra to ssh to tag:example-infra server as debian + { + "action": "accept", + "src": [ + "group:example-infra" + ], + "dst": [ + "tag:client", + ], + "users": [ + "debian", + ], + }, + ], +}`, + wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ + { + Principals: []*tailcfg.SSHPrincipal{ + {NodeIP: "100.64.0.2"}, + }, + SSHUsers: map[string]string{ + "debian": "debian", + }, + Action: &tailcfg.SSHAction{ + Accept: true, + AllowAgentForwarding: true, + AllowLocalPortForwarding: true, + AllowRemotePortForwarding: true, + }, + }, + }}, + }, } for _, tt := range tests { From 21e3f2598de6d0fc4d79230ca1e0e1f9e2d6a2b2 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 10 Nov 2025 17:00:03 +0100 Subject: [PATCH 021/178] policy: fix issue where non existent user results in empty ssh pol When we encounter a source we cannot resolve, we skipped the whole rule, even if some of the srcs could be resolved. In this case, if we had one user that exists and one that does not. In the regular policy, we log this, and still let a rule be created from what does exist, while in the SSH policy we did not. This commit fixes it so the behaviour is the same. Fixes #2863 Signed-off-by: Kristoffer Dalby --- hscontrol/policy/v2/filter.go | 1 - 1 file changed, 1 deletion(-) diff --git a/hscontrol/policy/v2/filter.go b/hscontrol/policy/v2/filter.go index bb7d089a..dd8e70c5 100644 --- a/hscontrol/policy/v2/filter.go +++ b/hscontrol/policy/v2/filter.go @@ -316,7 +316,6 @@ func (pol *Policy) compileSSHPolicy( srcIPs, err := rule.Sources.Resolve(pol, users, nodes) if err != nil { log.Trace().Caller().Err(err).Msgf("SSH policy compilation failed resolving source ips for rule %+v", rule) - continue // Skip this rule if we can't resolve sources } if srcIPs == nil || len(srcIPs.Prefixes()) == 0 { From abed5346289cf6984363f495d8c073868522796d Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Wed, 5 Nov 2025 20:40:31 +0100 Subject: [PATCH 022/178] Document how to restrict access to exit nodes per user/group Updates: #2855 Ref: #2784 --- docs/ref/routes.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/ref/routes.md b/docs/ref/routes.md index 9f32d9bc..a1c438b7 100644 --- a/docs/ref/routes.md +++ b/docs/ref/routes.md @@ -216,6 +216,39 @@ nodes. } ``` +### Restrict access to exit nodes per user or group + +A user can use _any_ of the available exit nodes with `autogroup:internet`. Alternatively, the ACL snippet below assigns +each user a specific exit node while hiding all other exit nodes. The user `alice` can only use exit node `exit1` while +user `bob` can only use exit node `exit2`. + +```json title="Assign each user a dedicated exit node" +{ + "hosts": { + "exit1": "100.64.0.1/32", + "exit2": "100.64.0.2/32" + }, + "acls": [ + { + "action": "accept", + "src": ["alice@"], + "dst": ["exit1:*"] + }, + { + "action": "accept", + "src": ["bob@"], + "dst": ["exit2:*"] + } + ] +} +``` + +!!! warning + + - The above implementation is Headscale specific and will likely be removed once [support for + `via`](https://github.com/juanfont/headscale/issues/2409) is available. + - Beware that a user can also connect to any port of the exit node itself. + ### Automatically approve an exit node with auto approvers The initial setup of an exit node usually requires manual approval on the control server before it can be used by a node From 4728a2ba9ea664205d07a84584a86aef8caf5a1a Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 3 Nov 2025 15:29:39 +0100 Subject: [PATCH 023/178] hscontrol/state: allow expired auth keys for node re-registration Skip auth key validation for existing nodes re-registering with the same NodeKey. Pre-auth keys are only required for initial authentication. NodeKey rotation still requires a valid auth key as it is a security-sensitive operation that changes the node's cryptographic identity. Fixes #2830 --- hscontrol/auth_test.go | 293 +++++++++++++++++++++++++++++++++++ hscontrol/state/state.go | 46 +++++- integration/auth_key_test.go | 2 + integration/tailscale.go | 1 + integration/tsic/tsic.go | 33 ++++ 5 files changed, 369 insertions(+), 6 deletions(-) diff --git a/hscontrol/auth_test.go b/hscontrol/auth_test.go index 1727be1a..bf6da356 100644 --- a/hscontrol/auth_test.go +++ b/hscontrol/auth_test.go @@ -3004,3 +3004,296 @@ func createTestApp(t *testing.T) *Headscale { return app } + +// TestGitHubIssue2830_NodeRestartWithUsedPreAuthKey tests the scenario reported in +// https://github.com/juanfont/headscale/issues/2830 +// +// Scenario: +// 1. Node registers successfully with a single-use pre-auth key +// 2. Node is running fine +// 3. Node restarts (e.g., after headscale upgrade or tailscale container restart) +// 4. Node sends RegisterRequest with the same pre-auth key +// 5. BUG: Headscale rejects the request with "authkey expired" or "authkey already used" +// +// Expected behavior: +// When an existing node (identified by matching NodeKey + MachineKey) re-registers +// with a pre-auth key that it previously used, the registration should succeed. +// The node is not creating a new registration - it's re-authenticating the same device. +func TestGitHubIssue2830_NodeRestartWithUsedPreAuthKey(t *testing.T) { + t.Parallel() + + app := createTestApp(t) + + // Create user and single-use pre-auth key + user := app.state.CreateUserForTest("test-user") + pak, err := app.state.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil) // reusable=false + require.NoError(t, err) + require.False(t, pak.Reusable, "key should be single-use for this test") + + machineKey := key.NewMachine() + nodeKey := key.NewNode() + + // STEP 1: Initial registration with pre-auth key (simulates fresh node joining) + initialReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{ + AuthKey: pak.Key, + }, + NodeKey: nodeKey.Public(), + Hostinfo: &tailcfg.Hostinfo{ + Hostname: "test-node", + }, + Expiry: time.Now().Add(24 * time.Hour), + } + + t.Log("Step 1: Initial registration with pre-auth key") + initialResp, err := app.handleRegister(context.Background(), initialReq, machineKey.Public()) + require.NoError(t, err, "initial registration should succeed") + require.NotNil(t, initialResp) + assert.True(t, initialResp.MachineAuthorized, "node should be authorized") + assert.False(t, initialResp.NodeKeyExpired, "node key should not be expired") + + // Verify node was created in database + node, found := app.state.GetNodeByNodeKey(nodeKey.Public()) + require.True(t, found, "node should exist after initial registration") + assert.Equal(t, "test-node", node.Hostname()) + assert.Equal(t, nodeKey.Public(), node.NodeKey()) + assert.Equal(t, machineKey.Public(), node.MachineKey()) + + // Verify pre-auth key is now marked as used + usedPak, err := app.state.GetPreAuthKey(pak.Key) + require.NoError(t, err) + assert.True(t, usedPak.Used, "pre-auth key should be marked as used after initial registration") + + // STEP 2: Simulate node restart - node sends RegisterRequest again with same pre-auth key + // This happens when: + // - Tailscale container restarts + // - Tailscaled service restarts + // - System reboots + // The Tailscale client persists the pre-auth key in its state and sends it on every registration + t.Log("Step 2: Node restart - re-registration with same (now used) pre-auth key") + restartReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{ + AuthKey: pak.Key, // Same key, now marked as Used=true + }, + NodeKey: nodeKey.Public(), // Same node key + Hostinfo: &tailcfg.Hostinfo{ + Hostname: "test-node", + }, + Expiry: time.Now().Add(24 * time.Hour), + } + + // BUG: This fails with "authkey already used" or "authkey expired" + // EXPECTED: Should succeed because it's the same node re-registering + restartResp, err := app.handleRegister(context.Background(), restartReq, machineKey.Public()) + + // This is the assertion that currently FAILS in v0.27.0 + assert.NoError(t, err, "BUG: existing node re-registration with its own used pre-auth key should succeed") + if err != nil { + t.Logf("Error received (this is the bug): %v", err) + t.Logf("Expected behavior: Node should be able to re-register with the same pre-auth key it used initially") + return // Stop here to show the bug clearly + } + + require.NotNil(t, restartResp) + assert.True(t, restartResp.MachineAuthorized, "node should remain authorized after restart") + assert.False(t, restartResp.NodeKeyExpired, "node key should not be expired after restart") + + // Verify it's the same node (not a duplicate) + nodeAfterRestart, found := app.state.GetNodeByNodeKey(nodeKey.Public()) + require.True(t, found, "node should still exist after restart") + assert.Equal(t, node.ID(), nodeAfterRestart.ID(), "should be the same node, not a new one") + assert.Equal(t, "test-node", nodeAfterRestart.Hostname()) +} + +// TestNodeReregistrationWithReusablePreAuthKey tests that reusable keys work correctly +// for node re-registration. +func TestNodeReregistrationWithReusablePreAuthKey(t *testing.T) { + t.Parallel() + + app := createTestApp(t) + + user := app.state.CreateUserForTest("test-user") + pak, err := app.state.CreatePreAuthKey(types.UserID(user.ID), true, false, nil, nil) // reusable=true + require.NoError(t, err) + require.True(t, pak.Reusable) + + machineKey := key.NewMachine() + nodeKey := key.NewNode() + + // Initial registration + initialReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{ + AuthKey: pak.Key, + }, + NodeKey: nodeKey.Public(), + Hostinfo: &tailcfg.Hostinfo{ + Hostname: "reusable-test-node", + }, + Expiry: time.Now().Add(24 * time.Hour), + } + + initialResp, err := app.handleRegister(context.Background(), initialReq, machineKey.Public()) + require.NoError(t, err) + require.NotNil(t, initialResp) + assert.True(t, initialResp.MachineAuthorized) + + // Node restart - re-registration with reusable key + restartReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{ + AuthKey: pak.Key, // Reusable key + }, + NodeKey: nodeKey.Public(), + Hostinfo: &tailcfg.Hostinfo{ + Hostname: "reusable-test-node", + }, + Expiry: time.Now().Add(24 * time.Hour), + } + + restartResp, err := app.handleRegister(context.Background(), restartReq, machineKey.Public()) + require.NoError(t, err, "reusable key should allow re-registration") + require.NotNil(t, restartResp) + assert.True(t, restartResp.MachineAuthorized) + assert.False(t, restartResp.NodeKeyExpired) +} + +// TestNodeReregistrationWithExpiredPreAuthKey tests that truly expired keys +// are still rejected even for existing nodes. +func TestNodeReregistrationWithExpiredPreAuthKey(t *testing.T) { + t.Parallel() + + app := createTestApp(t) + + user := app.state.CreateUserForTest("test-user") + expiry := time.Now().Add(-1 * time.Hour) // Already expired + pak, err := app.state.CreatePreAuthKey(types.UserID(user.ID), true, false, &expiry, nil) + require.NoError(t, err) + + machineKey := key.NewMachine() + nodeKey := key.NewNode() + + // Try to register with expired key + req := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{ + AuthKey: pak.Key, + }, + NodeKey: nodeKey.Public(), + Hostinfo: &tailcfg.Hostinfo{ + Hostname: "expired-key-node", + }, + Expiry: time.Now().Add(24 * time.Hour), + } + + _, err = app.handleRegister(context.Background(), req, machineKey.Public()) + assert.Error(t, err, "expired pre-auth key should be rejected") + assert.Contains(t, err.Error(), "authkey expired", "error should mention key expiration") +} +// TestGitHubIssue2830_ExistingNodeCanReregisterWithUsedPreAuthKey tests that an existing node +// can re-register using a pre-auth key that's already marked as Used=true, as long as: +// 1. The node is re-registering with the same MachineKey it originally used +// 2. The node is using the same pre-auth key it was originally registered with (AuthKeyID matches) +// +// This is the fix for GitHub issue #2830: https://github.com/juanfont/headscale/issues/2830 +// +// Background: When Docker/Kubernetes containers restart, they keep their persistent state +// (including the MachineKey), but container entrypoints unconditionally run: +// tailscale up --authkey=$TS_AUTHKEY +// +// This caused nodes to be rejected after restart because the pre-auth key was already +// marked as Used=true from the initial registration. The fix allows re-registration of +// existing nodes with their own used keys. +func TestGitHubIssue2830_ExistingNodeCanReregisterWithUsedPreAuthKey(t *testing.T) { + app := createTestApp(t) + + // Create a user + user := app.state.CreateUserForTest("testuser") + + // Create a SINGLE-USE pre-auth key (reusable=false) + // This is the type of key that triggers the bug in issue #2830 + preAuthKey, err := app.state.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil) + require.NoError(t, err) + require.False(t, preAuthKey.Reusable, "Pre-auth key must be single-use to test issue #2830") + require.False(t, preAuthKey.Used, "Pre-auth key should not be used yet") + + // Generate node keys for the client + machineKey := key.NewMachine() + nodeKey := key.NewNode() + + // Step 1: Initial registration with the pre-auth key + // This simulates the first time the container starts and runs 'tailscale up --authkey=...' + initialReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{ + AuthKey: preAuthKey.Key, + }, + NodeKey: nodeKey.Public(), + Hostinfo: &tailcfg.Hostinfo{ + Hostname: "issue-2830-test-node", + }, + Expiry: time.Now().Add(24 * time.Hour), + } + + initialResp, err := app.handleRegisterWithAuthKey(initialReq, machineKey.Public()) + require.NoError(t, err, "Initial registration should succeed") + require.True(t, initialResp.MachineAuthorized, "Node should be authorized after initial registration") + require.NotNil(t, initialResp.User, "User should be set in response") + require.Equal(t, "testuser", initialResp.User.DisplayName, "User should match the pre-auth key's user") + + // Verify the pre-auth key is now marked as Used + updatedKey, err := app.state.GetPreAuthKey(preAuthKey.Key) + require.NoError(t, err) + require.True(t, updatedKey.Used, "Pre-auth key should be marked as Used after initial registration") + + // Step 2: Container restart scenario + // The container keeps its MachineKey (persistent state), but the entrypoint script + // unconditionally runs 'tailscale up --authkey=$TS_AUTHKEY' again + // + // WITHOUT THE FIX: This would fail with "authkey already used" error + // WITH THE FIX: This succeeds because it's the same node re-registering with its own key + + // Simulate sending the same RegisterRequest again (same MachineKey, same AuthKey) + // This is exactly what happens when a container restarts + reregisterReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{ + AuthKey: preAuthKey.Key, // Same key, now marked as Used=true + }, + NodeKey: nodeKey.Public(), // Same NodeKey + Hostinfo: &tailcfg.Hostinfo{ + Hostname: "issue-2830-test-node", + }, + Expiry: time.Now().Add(24 * time.Hour), + } + + reregisterResp, err := app.handleRegisterWithAuthKey(reregisterReq, machineKey.Public()) // Same MachineKey + require.NoError(t, err, "Re-registration with same MachineKey and used pre-auth key should succeed (fixes #2830)") + require.True(t, reregisterResp.MachineAuthorized, "Node should remain authorized after re-registration") + require.NotNil(t, reregisterResp.User, "User should be set in re-registration response") + require.Equal(t, "testuser", reregisterResp.User.DisplayName, "User should remain the same") + + // Verify that only ONE node was created (not a duplicate) + nodes := app.state.ListNodesByUser(types.UserID(user.ID)) + require.Equal(t, 1, nodes.Len(), "Should have exactly one node (no duplicates created)") + require.Equal(t, "issue-2830-test-node", nodes.At(0).Hostname(), "Node hostname should match") + + // Step 3: Verify that a DIFFERENT machine cannot use the same used key + // This ensures we didn't break the security model - only the original node can re-register + differentMachineKey := key.NewMachine() + differentNodeKey := key.NewNode() + + attackReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{ + AuthKey: preAuthKey.Key, // Try to use the same key + }, + NodeKey: differentNodeKey.Public(), + Hostinfo: &tailcfg.Hostinfo{ + Hostname: "attacker-node", + }, + Expiry: time.Now().Add(24 * time.Hour), + } + + _, err = app.handleRegisterWithAuthKey(attackReq, differentMachineKey.Public()) + require.Error(t, err, "Different machine should NOT be able to use the same used pre-auth key") + require.Contains(t, err.Error(), "already used", "Error should indicate key is already used") + + // Verify still only one node (the original one) + nodesAfterAttack := app.state.ListNodesByUser(types.UserID(user.ID)) + require.Equal(t, 1, nodesAfterAttack.Len(), "Should still have exactly one node (attack prevented)") +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index c340adc2..6e1d08e0 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -1294,9 +1294,46 @@ func (s *State) HandleNodeFromPreAuthKey( return types.NodeView{}, change.EmptySet, err } - err = pak.Validate() - if err != nil { - return types.NodeView{}, change.EmptySet, err + // Check if node exists with same machine key before validating the key. + // For #2830: container restarts send the same pre-auth key which may be used/expired. + // Skip validation for existing nodes re-registering with the same NodeKey, as the + // key was only needed for initial authentication. NodeKey rotation requires validation. + existingNodeSameUser, existsSameUser := s.nodeStore.GetNodeByMachineKey(machineKey, types.UserID(pak.User.ID)) + + // Skip validation only if both the AuthKeyID and NodeKey match (not a rotation). + isExistingNodeReregistering := existsSameUser && existingNodeSameUser.Valid() && + existingNodeSameUser.AuthKey().Valid() && + existingNodeSameUser.AuthKeyID().Valid() && + existingNodeSameUser.AuthKeyID().Get() == pak.ID + + // Check if this is a NodeKey rotation (different NodeKey) + isNodeKeyRotation := existsSameUser && existingNodeSameUser.Valid() && + existingNodeSameUser.NodeKey() != regReq.NodeKey + + if isExistingNodeReregistering && !isNodeKeyRotation { + // Existing node re-registering with same NodeKey: skip validation. + // Pre-auth keys are only needed for initial authentication. Critical for + // containers that run "tailscale up --authkey=KEY" on every restart. + log.Debug(). + Caller(). + Uint64("node.id", existingNodeSameUser.ID().Uint64()). + Str("node.name", existingNodeSameUser.Hostname()). + Str("machine.key", machineKey.ShortString()). + Str("node.key.existing", existingNodeSameUser.NodeKey().ShortString()). + Str("node.key.request", regReq.NodeKey.ShortString()). + Uint64("authkey.id", pak.ID). + Bool("authkey.used", pak.Used). + Bool("authkey.expired", pak.Expiration != nil && pak.Expiration.Before(time.Now())). + Bool("authkey.reusable", pak.Reusable). + Bool("nodekey.rotation", isNodeKeyRotation). + Msg("Existing node re-registering with same NodeKey and auth key, skipping validation") + + } else { + // New node or NodeKey rotation: require valid auth key. + err = pak.Validate() + if err != nil { + return types.NodeView{}, change.EmptySet, err + } } // Ensure we have a valid hostname - handle nil/empty cases @@ -1328,9 +1365,6 @@ func (s *State) HandleNodeFromPreAuthKey( var finalNode types.NodeView - // Check if node already exists with same machine key for this user - existingNodeSameUser, existsSameUser := s.nodeStore.GetNodeByMachineKey(machineKey, types.UserID(pak.User.ID)) - // If this node exists for this user, update the node in place. if existsSameUser && existingNodeSameUser.Valid() { log.Trace(). diff --git a/integration/auth_key_test.go b/integration/auth_key_test.go index c6a4f4cf..75106dc5 100644 --- a/integration/auth_key_test.go +++ b/integration/auth_key_test.go @@ -223,6 +223,7 @@ func TestAuthKeyLogoutAndReloginNewUser(t *testing.T) { scenario, err := NewScenario(spec) require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) err = scenario.CreateHeadscaleEnv([]tsic.Option{}, @@ -454,3 +455,4 @@ func TestAuthKeyLogoutAndReloginSameUserExpiredKey(t *testing.T) { }) } } + diff --git a/integration/tailscale.go b/integration/tailscale.go index 414d08bc..f397133e 100644 --- a/integration/tailscale.go +++ b/integration/tailscale.go @@ -29,6 +29,7 @@ type TailscaleClient interface { Login(loginServer, authKey string) error LoginWithURL(loginServer string) (*url.URL, error) Logout() error + Restart() error Up() error Down() error IPs() ([]netip.Addr, error) diff --git a/integration/tsic/tsic.go b/integration/tsic/tsic.go index f6d8baef..462c3ea3 100644 --- a/integration/tsic/tsic.go +++ b/integration/tsic/tsic.go @@ -555,6 +555,39 @@ func (t *TailscaleInContainer) Logout() error { return t.waitForBackendState("NeedsLogin", integrationutil.PeerSyncTimeout()) } +// Restart restarts the Tailscale container using Docker API. +// This simulates a container restart (e.g., docker restart or Kubernetes pod restart). +// The container's entrypoint will re-execute, which typically includes running +// "tailscale up" with any auth keys stored in environment variables. +func (t *TailscaleInContainer) Restart() error { + if t.container == nil { + return fmt.Errorf("container not initialized") + } + + // Use Docker API to restart the container + err := t.pool.Client.RestartContainer(t.container.Container.ID, 30) + if err != nil { + return fmt.Errorf("failed to restart container %s: %w", t.hostname, err) + } + + // Wait for the container to be back up and tailscaled to be ready + // We use exponential backoff to poll until we can successfully execute a command + _, err = backoff.Retry(context.Background(), func() (struct{}, error) { + // Try to execute a simple command to verify the container is responsive + _, _, err := t.Execute([]string{"tailscale", "version"}, dockertestutil.ExecuteCommandTimeout(5*time.Second)) + if err != nil { + return struct{}{}, fmt.Errorf("container not ready: %w", err) + } + return struct{}{}, nil + }, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxElapsedTime(30*time.Second)) + + if err != nil { + return fmt.Errorf("timeout waiting for container %s to restart and become ready: %w", t.hostname, err) + } + + return nil +} + // Helper that runs `tailscale up` with no arguments. func (t *TailscaleInContainer) Up() error { command := []string{ From 773a46a9688b8c7117f8e59e9dee9d4cf915754b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 11 Nov 2025 17:36:27 +0100 Subject: [PATCH 024/178] integration: add test to replicate #2862 Signed-off-by: Kristoffer Dalby --- .github/workflows/test-integration.yaml | 1 + integration/auth_oidc_test.go | 128 ++++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index fe934aab..f5ec43a1 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -40,6 +40,7 @@ jobs: - TestOIDCFollowUpUrl - TestOIDCMultipleOpenedLoginUrls - TestOIDCReloginSameNodeSameUser + - TestOIDCExpiryAfterRestart - TestAuthWebFlowAuthenticationPingAll - TestAuthWebFlowLogoutAndReloginSameUser - TestAuthWebFlowLogoutAndReloginNewUser diff --git a/integration/auth_oidc_test.go b/integration/auth_oidc_test.go index eebb8165..9040e5fd 100644 --- a/integration/auth_oidc_test.go +++ b/integration/auth_oidc_test.go @@ -1294,3 +1294,131 @@ func TestOIDCReloginSameNodeSameUser(t *testing.T) { } }, 60*time.Second, 2*time.Second, "validating user1 node is online after same-user OIDC relogin") } + +// TestOIDCExpiryAfterRestart validates that node expiry is preserved +// when a tailscaled client restarts and reconnects to headscale. +// +// This test reproduces the bug reported in https://github.com/juanfont/headscale/issues/2862 +// where OIDC expiry was reset to 0001-01-01 00:00:00 after tailscaled restart. +// +// Test flow: +// 1. Node logs in with OIDC (gets 72h expiry) +// 2. Verify expiry is set correctly in headscale +// 3. Restart tailscaled container (simulates daemon restart) +// 4. Wait for reconnection +// 5. Verify expiry is still set correctly (not zero). +func TestOIDCExpiryAfterRestart(t *testing.T) { + IntegrationSkip(t) + + scenario, err := NewScenario(ScenarioSpec{ + OIDCUsers: []mockoidc.MockUser{ + oidcMockUser("user1", true), + }, + }) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + oidcMap := map[string]string{ + "HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(), + "HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(), + "CREDENTIALS_DIRECTORY_TEST": "/tmp", + "HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret", + "HEADSCALE_OIDC_EXPIRY": "72h", + } + + err = scenario.CreateHeadscaleEnvWithLoginURL( + nil, + hsic.WithTestName("oidcexpiry"), + hsic.WithConfigEnv(oidcMap), + hsic.WithTLS(), + hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())), + hsic.WithEmbeddedDERPServerOnly(), + hsic.WithDERPAsIP(), + ) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + // Create and login tailscale client + ts, err := scenario.CreateTailscaleNode("unstable", tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork])) + require.NoError(t, err) + + u, err := ts.LoginWithURL(headscale.GetEndpoint()) + require.NoError(t, err) + + _, err = doLoginURL(ts.Hostname(), u) + require.NoError(t, err) + + t.Logf("Validating initial login and expiry at %s", time.Now().Format(TimestampFormat)) + + // Verify initial expiry is set + var initialExpiry time.Time + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + nodes, err := headscale.ListNodes() + assert.NoError(ct, err) + assert.Len(ct, nodes, 1) + + node := nodes[0] + assert.NotNil(ct, node.GetExpiry(), "Expiry should be set after OIDC login") + + if node.GetExpiry() != nil { + expiryTime := node.GetExpiry().AsTime() + assert.False(ct, expiryTime.IsZero(), "Expiry should not be zero time") + + initialExpiry = expiryTime + t.Logf("Initial expiry set to: %v (expires in %v)", expiryTime, time.Until(expiryTime)) + } + }, 30*time.Second, 1*time.Second, "validating initial expiry after OIDC login") + + // Now restart the tailscaled container + t.Logf("Restarting tailscaled container at %s", time.Now().Format(TimestampFormat)) + + err = ts.Restart() + require.NoError(t, err, "Failed to restart tailscaled container") + + t.Logf("Tailscaled restarted, waiting for reconnection at %s", time.Now().Format(TimestampFormat)) + + // Wait for the node to come back online + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + status, err := ts.Status() + if !assert.NoError(ct, err) { + return + } + + if !assert.NotNil(ct, status) { + return + } + + assert.Equal(ct, "Running", status.BackendState) + }, 60*time.Second, 2*time.Second, "waiting for tailscale to reconnect after restart") + + // THE CRITICAL TEST: Verify expiry is still set correctly after restart + t.Logf("Validating expiry preservation after restart at %s", time.Now().Format(TimestampFormat)) + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + nodes, err := headscale.ListNodes() + assert.NoError(ct, err) + assert.Len(ct, nodes, 1, "Should still have exactly 1 node after restart") + + node := nodes[0] + assert.NotNil(ct, node.GetExpiry(), "Expiry should NOT be nil after restart") + + if node.GetExpiry() != nil { + expiryTime := node.GetExpiry().AsTime() + + // This is the bug check - expiry should NOT be zero time + assert.False(ct, expiryTime.IsZero(), + "BUG: Expiry was reset to zero time after tailscaled restart! This is issue #2862") + + // Expiry should be exactly the same as before restart + assert.Equal(ct, initialExpiry, expiryTime, + "Expiry should be exactly the same after restart, got %v, expected %v", + expiryTime, initialExpiry) + + t.Logf("SUCCESS: Expiry preserved after restart: %v (expires in %v)", + expiryTime, time.Until(expiryTime)) + } + }, 30*time.Second, 1*time.Second, "validating expiry preservation after restart") +} From 4a8dc2d445fefe745bc5564cf1a751b4b38d2e04 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 10 Nov 2025 18:36:11 +0100 Subject: [PATCH 025/178] hscontrol/state,db: preserve node expiry on MapRequest updates Fixes a regression introduced in v0.27.0 where node expiry times were being reset to zero when tailscaled restarts and sends a MapRequest. The issue was caused by using GORM's Save() method in persistNodeToDB(), which overwrites ALL fields including zero values. When a MapRequest updates a node (without including expiry information), Save() would overwrite the database expiry field with a zero value. Changed to use Updates() which only updates non-zero values, preserving existing database values when struct pointer fields are nil. In BackfillNodeIPs, we need to explicitly update IPv4/IPv6 fields even when nil (to remove IPs), so we use Select() to specify those fields. Added regression test that validates expiry is preserved after MapRequest. Fixes #2862 --- hscontrol/db/ip.go | 6 +++++- hscontrol/state/state.go | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/hscontrol/db/ip.go b/hscontrol/db/ip.go index 3fddcfd2..244bb3db 100644 --- a/hscontrol/db/ip.go +++ b/hscontrol/db/ip.go @@ -325,7 +325,11 @@ func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) { } if changed { - err := tx.Save(node).Error + // Use Updates() with Select() to only update IP fields, avoiding overwriting + // other fields like Expiry. We need Select() because Updates() alone skips + // zero values, but we DO want to update IPv4/IPv6 to nil when removing them. + // See issue #2862. + err := tx.Model(node).Select("ipv4", "ipv6").Updates(node).Error if err != nil { return fmt.Errorf("saving node(%d) after adding IPs: %w", node.ID, err) } diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 6e1d08e0..ff876024 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -386,7 +386,11 @@ func (s *State) persistNodeToDB(node types.NodeView) (types.NodeView, change.Cha nodePtr := node.AsStruct() - if err := s.db.DB.Save(nodePtr).Error; err != nil { + // Use Omit("expiry") to prevent overwriting expiry during MapRequest updates. + // Expiry should only be updated through explicit SetNodeExpiry calls or re-registration. + // See: https://github.com/juanfont/headscale/issues/2862 + err := s.db.DB.Omit("expiry").Updates(nodePtr).Error + if err != nil { return types.NodeView{}, change.EmptySet, fmt.Errorf("saving node: %w", err) } From ddd31ba774a78eaae845c52eae0260692d8e31c4 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 10 Nov 2025 19:15:05 +0100 Subject: [PATCH 026/178] hscontrol: use Updates() instead of Save() for partial updates Changed UpdateUser and re-registration flows to use Updates() which only writes modified fields, preventing unintended overwrites of unchanged fields. Also updated UsePreAuthKey to use Model().Update() for single field updates and removed unused NodeSave wrapper. --- hscontrol/db/node.go | 7 -- hscontrol/db/preauth_keys.go | 5 +- hscontrol/db/user_update_test.go | 134 +++++++++++++++++++++++++++++++ hscontrol/state/state.go | 14 ++-- 4 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 hscontrol/db/user_update_test.go diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go index 70d3afaf..060196a9 100644 --- a/hscontrol/db/node.go +++ b/hscontrol/db/node.go @@ -452,13 +452,6 @@ func NodeSetMachineKey( }).Error } -// NodeSave saves a node object to the database, prefer to use a specific save method rather -// than this. It is intended to be used when we are changing or. -// TODO(kradalby): Remove this func, just use Save. -func NodeSave(tx *gorm.DB, node *types.Node) error { - return tx.Save(node).Error -} - func generateGivenName(suppliedName string, randomSuffix bool) (string, error) { // Strip invalid DNS characters for givenName suppliedName = strings.ToLower(suppliedName) diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index a36c1f13..94575269 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -145,11 +145,12 @@ func (hsdb *HSDatabase) ExpirePreAuthKey(k *types.PreAuthKey) error { // UsePreAuthKey marks a PreAuthKey as used. func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error { - k.Used = true - if err := tx.Save(k).Error; err != nil { + err := tx.Model(k).Update("used", true).Error + if err != nil { return fmt.Errorf("failed to update key used status in the database: %w", err) } + k.Used = true return nil } diff --git a/hscontrol/db/user_update_test.go b/hscontrol/db/user_update_test.go new file mode 100644 index 00000000..180481e7 --- /dev/null +++ b/hscontrol/db/user_update_test.go @@ -0,0 +1,134 @@ +package db + +import ( + "database/sql" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +// TestUserUpdatePreservesUnchangedFields verifies that updating a user +// preserves fields that aren't modified. This test validates the fix +// for using Updates() instead of Save() in UpdateUser-like operations. +func TestUserUpdatePreservesUnchangedFields(t *testing.T) { + database := dbForTest(t) + + // Create a user with all fields set + initialUser := types.User{ + Name: "testuser", + DisplayName: "Test User Display", + Email: "test@example.com", + ProviderIdentifier: sql.NullString{ + String: "provider-123", + Valid: true, + }, + } + + createdUser, err := database.CreateUser(initialUser) + require.NoError(t, err) + require.NotNil(t, createdUser) + + // Verify initial state + assert.Equal(t, "testuser", createdUser.Name) + assert.Equal(t, "Test User Display", createdUser.DisplayName) + assert.Equal(t, "test@example.com", createdUser.Email) + assert.True(t, createdUser.ProviderIdentifier.Valid) + assert.Equal(t, "provider-123", createdUser.ProviderIdentifier.String) + + // Simulate what UpdateUser does: load user, modify one field, save + _, err = Write(database.DB, func(tx *gorm.DB) (*types.User, error) { + user, err := GetUserByID(tx, types.UserID(createdUser.ID)) + if err != nil { + return nil, err + } + + // Modify ONLY DisplayName + user.DisplayName = "Updated Display Name" + + // This is the line being tested - currently uses Save() which writes ALL fields, potentially overwriting unchanged ones + err = tx.Save(user).Error + if err != nil { + return nil, err + } + + return user, nil + }) + require.NoError(t, err) + + // Read user back from database + updatedUser, err := Read(database.DB, func(rx *gorm.DB) (*types.User, error) { + return GetUserByID(rx, types.UserID(createdUser.ID)) + }) + require.NoError(t, err) + + // Verify that DisplayName was updated + assert.Equal(t, "Updated Display Name", updatedUser.DisplayName) + + // CRITICAL: Verify that other fields were NOT overwritten + // With Save(), these assertions should pass because the user object + // was loaded from DB and has all fields populated. + // But if Updates() is used, these will also pass (and it's safer). + assert.Equal(t, "testuser", updatedUser.Name, "Name should be preserved") + assert.Equal(t, "test@example.com", updatedUser.Email, "Email should be preserved") + assert.True(t, updatedUser.ProviderIdentifier.Valid, "ProviderIdentifier should be preserved") + assert.Equal(t, "provider-123", updatedUser.ProviderIdentifier.String, "ProviderIdentifier value should be preserved") +} + +// TestUserUpdateWithUpdatesMethod tests that using Updates() instead of Save() +// works correctly and only updates modified fields. +func TestUserUpdateWithUpdatesMethod(t *testing.T) { + database := dbForTest(t) + + // Create a user + initialUser := types.User{ + Name: "testuser", + DisplayName: "Original Display", + Email: "original@example.com", + ProviderIdentifier: sql.NullString{ + String: "provider-abc", + Valid: true, + }, + } + + createdUser, err := database.CreateUser(initialUser) + require.NoError(t, err) + + // Update using Updates() method + _, err = Write(database.DB, func(tx *gorm.DB) (*types.User, error) { + user, err := GetUserByID(tx, types.UserID(createdUser.ID)) + if err != nil { + return nil, err + } + + // Modify multiple fields + user.DisplayName = "New Display" + user.Email = "new@example.com" + + // Use Updates() instead of Save() + err = tx.Updates(user).Error + if err != nil { + return nil, err + } + + return user, nil + }) + require.NoError(t, err) + + // Verify changes + updatedUser, err := Read(database.DB, func(rx *gorm.DB) (*types.User, error) { + return GetUserByID(rx, types.UserID(createdUser.ID)) + }) + require.NoError(t, err) + + // Verify updated fields + assert.Equal(t, "New Display", updatedUser.DisplayName) + assert.Equal(t, "new@example.com", updatedUser.Email) + + // Verify preserved fields + assert.Equal(t, "testuser", updatedUser.Name) + assert.True(t, updatedUser.ProviderIdentifier.Valid) + assert.Equal(t, "provider-abc", updatedUser.ProviderIdentifier.String) +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index ff876024..297004fc 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -300,7 +300,9 @@ func (s *State) UpdateUser(userID types.UserID, updateFn func(*types.User) error return nil, err } - if err := tx.Save(user).Error; err != nil { + // Use Updates() to only update modified fields, preserving unchanged values. + err = tx.Updates(user).Error + if err != nil { return nil, fmt.Errorf("updating user: %w", err) } @@ -1191,9 +1193,10 @@ func (s *State) HandleNodeFromAuthPath( return types.NodeView{}, change.EmptySet, fmt.Errorf("node not found in NodeStore: %d", existingNodeSameUser.ID()) } - // Use the node from UpdateNode to save to database _, err = hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.Node, error) { - if err := tx.Save(updatedNodeView.AsStruct()).Error; err != nil { + // Use Updates() to preserve fields not modified by UpdateNode. + err := tx.Updates(updatedNodeView.AsStruct()).Error + if err != nil { return nil, fmt.Errorf("failed to save node: %w", err) } return nil, nil @@ -1410,9 +1413,10 @@ func (s *State) HandleNodeFromPreAuthKey( return types.NodeView{}, change.EmptySet, fmt.Errorf("node not found in NodeStore: %d", existingNodeSameUser.ID()) } - // Use the node from UpdateNode to save to database _, err = hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.Node, error) { - if err := tx.Save(updatedNodeView.AsStruct()).Error; err != nil { + // Use Updates() to preserve fields not modified by UpdateNode. + err := tx.Updates(updatedNodeView.AsStruct()).Error + if err != nil { return nil, fmt.Errorf("failed to save node: %w", err) } From 3455d1cb59d10c86150182c13fb203a68f68125a Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 10 Nov 2025 19:17:55 +0100 Subject: [PATCH 027/178] hscontrol/db: fix RenameUser to use Updates() RenameUser only modifies Name field, should use Updates() not Save(). --- hscontrol/db/users.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hscontrol/db/users.go b/hscontrol/db/users.go index 08ed048c..039933c7 100644 --- a/hscontrol/db/users.go +++ b/hscontrol/db/users.go @@ -102,7 +102,8 @@ func RenameUser(tx *gorm.DB, uid types.UserID, newName string) error { oldUser.Name = newName - if err := tx.Save(&oldUser).Error; err != nil { + err = tx.Updates(&oldUser).Error + if err != nil { return err } From 3bd4ecd9cd8ae0e349e3e3d728a9e066642931c1 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 11 Nov 2025 17:42:07 +0100 Subject: [PATCH 028/178] fix: preserve node expiry when tailscaled restarts When tailscaled restarts, it sends RegisterRequest with Auth=nil and Expiry=zero. Previously this was treated as a logout because time.Time{}.Before(time.Now()) returns true. Add early return in handleRegister() to detect this case and preserve the existing node state without modification. Fixes #2862 --- hscontrol/auth.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/hscontrol/auth.go b/hscontrol/auth.go index e4a0d089..447035da 100644 --- a/hscontrol/auth.go +++ b/hscontrol/auth.go @@ -71,6 +71,13 @@ func (h *Headscale) handleRegister( // We do not look up nodes by [key.MachinePublic] as it might belong to multiple // nodes, separated by users and this path is handling expiring/logout paths. if node, ok := h.state.GetNodeByNodeKey(req.NodeKey); ok { + // When tailscaled restarts, it sends RegisterRequest with Auth=nil and Expiry=zero. + // Return the current node state without modification. + // See: https://github.com/juanfont/headscale/issues/2862 + if req.Expiry.IsZero() && node.Expiry().Valid() && !node.IsExpired() { + return nodeToRegisterResponse(node), nil + } + resp, err := h.handleLogout(node, req, machineKey) if err != nil { return nil, fmt.Errorf("handling existing node: %w", err) @@ -173,6 +180,7 @@ func (h *Headscale) handleLogout( } // If the request expiry is in the past, we consider it a logout. + // Zero expiry is handled in handleRegister() before calling this function. if req.Expiry.Before(time.Now()) { log.Debug(). Uint64("node.id", node.ID().Uint64()). From 785168a7b862c6b41c61cc61d6220395eb4fe6a2 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 11 Nov 2025 20:06:52 +0100 Subject: [PATCH 029/178] changelog: prepare for 0.27.1 Signed-off-by: Kristoffer Dalby --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02986867..7669dfcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,37 @@ ### Changes +## 0.27.1 (2025-11-11) + +**Minimum supported Tailscale client version: v1.64.0** + +### Changes + - Expire nodes with a custom timestamp [#2828](https://github.com/juanfont/headscale/pull/2828) +- Fix issue where node expiry was reset when tailscaled restarts + [#2875](https://github.com/juanfont/headscale/pull/2875) +- Fix OIDC authentication when multiple login URLs are opened + [#2861](https://github.com/juanfont/headscale/pull/2861) +- Fix node re-registration failing with expired auth keys + [#2859](https://github.com/juanfont/headscale/pull/2859) +- Remove old unused database tables and indices + [#2844](https://github.com/juanfont/headscale/pull/2844) + [#2872](https://github.com/juanfont/headscale/pull/2872) +- Ignore litestream tables during database validation + [#2843](https://github.com/juanfont/headscale/pull/2843) +- Fix exit node visibility to respect ACL rules + [#2855](https://github.com/juanfont/headscale/pull/2855) +- Fix SSH policy becoming empty when unknown user is referenced + [#2874](https://github.com/juanfont/headscale/pull/2874) +- Fix policy validation when using bypass-grpc mode + [#2854](https://github.com/juanfont/headscale/pull/2854) +- Fix autogroup:self interaction with other ACL rules + [#2842](https://github.com/juanfont/headscale/pull/2842) +- Fix flaky DERP map shuffle test + [#2848](https://github.com/juanfont/headscale/pull/2848) +- Use current stable base images for Debian and Alpine containers + [#2827](https://github.com/juanfont/headscale/pull/2827) ## 0.27.0 (2025-10-27) @@ -89,7 +118,8 @@ the code base over time and make it more correct and efficient. [#2692](https://github.com/juanfont/headscale/pull/2692) - Policy: Zero or empty destination port is no longer allowed [#2606](https://github.com/juanfont/headscale/pull/2606) -- Stricter hostname validation [#2383](https://github.com/juanfont/headscale/pull/2383) +- Stricter hostname validation + [#2383](https://github.com/juanfont/headscale/pull/2383) - Hostnames must be valid DNS labels (2-63 characters, alphanumeric and hyphens only, cannot start/end with hyphen) - **Client Registration (New Nodes)**: Invalid hostnames are automatically @@ -144,7 +174,8 @@ the code base over time and make it more correct and efficient. [#2776](https://github.com/juanfont/headscale/pull/2776) - EXPERIMENTAL: Add support for `autogroup:self` [#2789](https://github.com/juanfont/headscale/pull/2789) -- Add healthcheck command [#2659](https://github.com/juanfont/headscale/pull/2659) +- Add healthcheck command + [#2659](https://github.com/juanfont/headscale/pull/2659) ## 0.26.1 (2025-06-06) From f658a8eacd4d86edc65424b50635afed46ca4b2a Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 11 Nov 2025 20:12:46 +0100 Subject: [PATCH 030/178] mkdocs: 0.27.1 Signed-off-by: Kristoffer Dalby --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 56dbbea1..45634ece 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -104,7 +104,7 @@ extra: - icon: fontawesome/brands/discord link: https://discord.gg/c84AZQhmpx headscale: - version: 0.27.0 + version: 0.27.1 # Extensions markdown_extensions: From 6d24afba1ce773ac935f4acc1c35d6521725ac26 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 11 Nov 2025 13:35:23 -0600 Subject: [PATCH 031/178] add pre-commit hooks, move claude to agents. (#2877) --- .../agents/headscale-integration-tester.md | 20 +- .golangci-lint-hook.sh | 27 + .mcp.json | 24 +- .pre-commit-config.yaml | 75 ++ AGENTS.md | 699 ++++++++++++++++++ CLAUDE.md | 532 +------------ Makefile | 4 +- README.md | 2 + derp-example.yaml | 2 +- docs/logo/headscale3-dots.svg | 2 +- docs/logo/headscale3_header_stacked_left.svg | 2 +- flake.nix | 427 +++++------ integration/auth_key_test.go | 1 - 13 files changed, 1041 insertions(+), 776 deletions(-) create mode 100755 .golangci-lint-hook.sh create mode 100644 .pre-commit-config.yaml create mode 100644 AGENTS.md diff --git a/.claude/agents/headscale-integration-tester.md b/.claude/agents/headscale-integration-tester.md index 2b25977d..54474ce9 100644 --- a/.claude/agents/headscale-integration-tester.md +++ b/.claude/agents/headscale-integration-tester.md @@ -52,7 +52,7 @@ go test ./integration -timeout 45m **Timeout Guidelines by Test Type**: - **Basic functionality tests**: `--timeout=900s` (15 minutes minimum) - **Route/ACL tests**: `--timeout=1200s` (20 minutes) -- **HA/failover tests**: `--timeout=1800s` (30 minutes) +- **HA/failover tests**: `--timeout=1800s` (30 minutes) - **Long-running tests**: `--timeout=2100s` (35 minutes) - **Full test suite**: `-timeout 45m` (45 minutes) @@ -433,7 +433,7 @@ When you understand a test's purpose through debugging, always add comprehensive // // The test verifies: // - Route announcements are received and tracked -// - ACL policies control route approval correctly +// - ACL policies control route approval correctly // - Only approved routes appear in peer network maps // - Route state persists correctly in the database func TestSubnetRoutes(t *testing.T) { @@ -535,7 +535,7 @@ var nodeKey key.NodePublic assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err := headscale.ListNodes() assert.NoError(c, err) - + for _, node := range nodes { if node.GetName() == "router" { routeNode = node @@ -550,7 +550,7 @@ assert.EventuallyWithT(t, func(c *assert.CollectT) { assert.EventuallyWithT(t, func(c *assert.CollectT) { status, err := client.Status() assert.NoError(c, err) - + peerStatus, ok := status.Peer[nodeKey] assert.True(c, ok, "peer should exist in status") requirePeerSubnetRoutesWithCollect(c, peerStatus, expectedPrefixes) @@ -566,7 +566,7 @@ assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err := headscale.ListNodes() assert.NoError(c, err) assert.Len(c, nodes, 2) - + // Second unrelated external call - WRONG! status, err := client.Status() assert.NoError(c, err) @@ -577,7 +577,7 @@ assert.EventuallyWithT(t, func(c *assert.CollectT) { assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err := headscale.ListNodes() assert.NoError(c, err) - + // NEVER do this! assert.EventuallyWithT(t, func(c2 *assert.CollectT) { status, _ := client.Status() @@ -666,11 +666,11 @@ When working within EventuallyWithT blocks where you need to prevent panics: assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err := headscale.ListNodes() assert.NoError(c, err) - + // For array bounds - use require with t to prevent panic assert.Len(c, nodes, 6) // Test expectation require.GreaterOrEqual(t, len(nodes), 3, "need at least 3 nodes to avoid panic") - + // For nil pointer access - use require with t before dereferencing assert.NotNil(c, srs1PeerStatus.PrimaryRoutes) // Test expectation require.NotNil(t, srs1PeerStatus.PrimaryRoutes, "primary routes must be set to avoid panic") @@ -681,7 +681,7 @@ assert.EventuallyWithT(t, func(c *assert.CollectT) { }, 5*time.Second, 200*time.Millisecond, "checking route state") ``` -**Key Principle**: +**Key Principle**: - Use `assert` with `c` (*assert.CollectT) for test expectations that can be retried - Use `require` with `t` (*testing.T) for MUST conditions that prevent panics - Within EventuallyWithT, both are available - choose based on whether failure would cause a panic @@ -704,7 +704,7 @@ assert.EventuallyWithT(t, func(c *assert.CollectT) { assert.EventuallyWithT(t, func(c *assert.CollectT) { status, err := client.Status() assert.NoError(c, err) - + // Check all peers have expected routes for _, peerKey := range status.Peers() { peerStatus := status.Peer[peerKey] diff --git a/.golangci-lint-hook.sh b/.golangci-lint-hook.sh new file mode 100755 index 00000000..ba62e432 --- /dev/null +++ b/.golangci-lint-hook.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Wrapper script for golangci-lint pre-commit hook +# Finds where the current branch diverged from the main branch + +set -euo pipefail + +# Try to find the main branch reference in order of preference: +# 1. upstream/main (common in forks) +# 2. origin/main (common in direct clones) +# 3. main (local branch) +for ref in upstream/main origin/main main; do + if git rev-parse --verify "$ref" >/dev/null 2>&1; then + MAIN_REF="$ref" + break + fi +done + +# If we couldn't find any main branch, just check the last commit +if [ -z "${MAIN_REF:-}" ]; then + MAIN_REF="HEAD~1" +fi + +# Find where current branch diverged from main +MERGE_BASE=$(git merge-base HEAD "$MAIN_REF" 2>/dev/null || echo "HEAD~1") + +# Run golangci-lint only on changes since branch point +exec golangci-lint run --new-from-rev="$MERGE_BASE" --timeout=5m --fix diff --git a/.mcp.json b/.mcp.json index 1303afda..71554002 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,45 +3,31 @@ "claude-code-mcp": { "type": "stdio", "command": "npx", - "args": [ - "-y", - "@steipete/claude-code-mcp@latest" - ], + "args": ["-y", "@steipete/claude-code-mcp@latest"], "env": {} }, "sequential-thinking": { "type": "stdio", "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-sequential-thinking" - ], + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"], "env": {} }, "nixos": { "type": "stdio", "command": "uvx", - "args": [ - "mcp-nixos" - ], + "args": ["mcp-nixos"], "env": {} }, "context7": { "type": "stdio", "command": "npx", - "args": [ - "-y", - "@upstash/context7-mcp" - ], + "args": ["-y", "@upstash/context7-mcp"], "env": {} }, "git": { "type": "stdio", "command": "npx", - "args": [ - "-y", - "@cyanheads/git-mcp-server" - ], + "args": ["-y", "@cyanheads/git-mcp-server"], "env": {} } } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..4d98d4d3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,75 @@ +# prek/pre-commit configuration for headscale +# See: https://prek.j178.dev/quickstart/ +# See: https://prek.j178.dev/builtin/ + +# Global exclusions - ignore docs and generated code +exclude: ^(docs/|gen/) + +repos: + # Built-in hooks from pre-commit/pre-commit-hooks + # prek will use fast-path optimized versions automatically + # See: https://prek.j178.dev/builtin/ + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-json + - id: check-merge-conflict + - id: check-symlinks + - id: check-toml + - id: check-xml + - id: check-yaml + - id: detect-private-key + - id: end-of-file-fixer + - id: fix-byte-order-marker + - id: mixed-line-ending + - id: trailing-whitespace + + # Local hooks for project-specific tooling + - repo: local + hooks: + # nixpkgs-fmt for Nix files + - id: nixpkgs-fmt + name: nixpkgs-fmt + entry: nixpkgs-fmt + language: system + files: \.nix$ + + # Prettier for formatting + - id: prettier + name: prettier + entry: prettier --write --list-different + language: system + types_or: + [ + javascript, + jsx, + ts, + tsx, + yaml, + json, + toml, + html, + css, + scss, + sass, + markdown, + ] + exclude: ^CHANGELOG\.md$ + + # Prettier for CHANGELOG.md with special formatting + - id: prettier-changelog + name: prettier-changelog + entry: prettier --write --print-width 80 --prose-wrap always + language: system + files: ^CHANGELOG\.md$ + + # golangci-lint for Go code quality + - id: golangci-lint + name: golangci-lint + entry: .golangci-lint-hook.sh + language: system + types: [go] + pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e5dd1b01 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,699 @@ +# AGENTS.md + +This file provides guidance to AI agents when working with code in this repository. + +## Overview + +Headscale is an open-source implementation of the Tailscale control server written in Go. It provides self-hosted coordination for Tailscale networks (tailnets), managing node registration, IP allocation, policy enforcement, and DERP routing. + +## Development Commands + +### Quick Setup + +```bash +# Recommended: Use Nix for dependency management +nix develop + +# Full development workflow +make dev # runs fmt + lint + test + build +``` + +### Essential Commands + +```bash +# Build headscale binary +make build + +# Run tests +make test +go test ./... # All unit tests +go test -race ./... # With race detection + +# Run specific integration test +go run ./cmd/hi run "TestName" --postgres + +# Code formatting and linting +make fmt # Format all code (Go, docs, proto) +make lint # Lint all code (Go, proto) +make fmt-go # Format Go code only +make lint-go # Lint Go code only + +# Protocol buffer generation (after modifying proto/) +make generate + +# Clean build artifacts +make clean +``` + +### Integration Testing + +```bash +# Use the hi (Headscale Integration) test runner +go run ./cmd/hi doctor # Check system requirements +go run ./cmd/hi run "TestPattern" # Run specific test +go run ./cmd/hi run "TestPattern" --postgres # With PostgreSQL backend + +# Test artifacts are saved to control_logs/ with logs and debug data +``` + +## Pre-Commit Quality Checks + +### **MANDATORY: Automated Pre-Commit Hooks with prek** + +**CRITICAL REQUIREMENT**: This repository uses [prek](https://prek.j178.dev/) for automated pre-commit hooks. All commits are automatically validated for code quality, formatting, and common issues. + +### Initial Setup + +When you first clone the repository or enter the nix shell, install the git hooks: + +```bash +# Enter nix development environment +nix develop + +# Install prek git hooks (one-time setup) +prek install +``` + +This installs the pre-commit hook at `.git/hooks/pre-commit` which automatically runs all configured checks before each commit. + +### Configured Hooks + +The repository uses `.pre-commit-config.yaml` with the following hooks: + +**Built-in Checks** (optimized fast-path execution): + +- `check-added-large-files` - Prevents accidentally committing large files +- `check-case-conflict` - Checks for files that would conflict in case-insensitive filesystems +- `check-executables-have-shebangs` - Ensures executables have proper shebangs +- `check-json` - Validates JSON syntax +- `check-merge-conflict` - Prevents committing files with merge conflict markers +- `check-symlinks` - Checks for broken symlinks +- `check-toml` - Validates TOML syntax +- `check-xml` - Validates XML syntax +- `check-yaml` - Validates YAML syntax +- `detect-private-key` - Detects accidentally committed private keys +- `end-of-file-fixer` - Ensures files end with a newline +- `fix-byte-order-marker` - Removes UTF-8 byte order markers +- `mixed-line-ending` - Prevents mixed line endings +- `trailing-whitespace` - Removes trailing whitespace + +**Project-Specific Hooks**: + +- `nixpkgs-fmt` - Formats Nix files +- `prettier` - Formats markdown, YAML, JSON, and TOML files +- `golangci-lint` - Runs Go linter with auto-fix on changed files only + +### Manual Hook Execution + +Run hooks manually without making a commit: + +```bash +# Run hooks on staged files only +prek run + +# Run hooks on all files in the repository +prek run --all-files + +# Run a specific hook +prek run golangci-lint + +# Run hooks on specific files +prek run --files path/to/file1.go path/to/file2.go +``` + +### Workflow Pattern + +With prek installed, your normal workflow becomes: + +```bash +# 1. Make your code changes +vim hscontrol/state/state.go + +# 2. Stage your changes +git add . + +# 3. Commit - hooks run automatically +git commit -m "feat: add new feature" + +# If hooks fail, they will show which checks failed +# Fix the issues and try committing again +``` + +### Manual golangci-lint (Optional) + +While golangci-lint runs automatically via prek, you can also run it manually: + +```bash +# Use the same logic as the pre-commit hook (recommended) +./.golangci-lint-hook.sh + +# Or manually specify a base reference +golangci-lint run --new-from-rev=upstream/main --timeout=5m --fix +``` + +The `.golangci-lint-hook.sh` script automatically finds where your branch diverged from the main branch by checking `upstream/main`, `origin/main`, or `main` in that order. + +### Skipping Hooks (Not Recommended) + +In rare cases where you need to skip hooks (e.g., work-in-progress commits), use: + +```bash +git commit --no-verify -m "WIP: work in progress" +``` + +**WARNING**: Only use `--no-verify` for temporary WIP commits on feature branches. All commits to main must pass all hooks. + +### Troubleshooting + +**Hook installation issues**: + +```bash +# Check if hooks are installed +ls -la .git/hooks/pre-commit + +# Reinstall hooks +prek install +``` + +**Hooks running slow**: + +```bash +# prek uses optimized fast-path for built-in hooks +# If running slow, check which hook is taking time with verbose output +prek run -v +``` + +**Update hook configuration**: + +```bash +# After modifying .pre-commit-config.yaml, hooks will automatically use new config +# No reinstallation needed +``` + +## Project Structure & Architecture + +### Top-Level Organization + +``` +headscale/ +├── cmd/ # Command-line applications +│ ├── headscale/ # Main headscale server binary +│ └── hi/ # Headscale Integration test runner +├── hscontrol/ # Core control plane logic +├── integration/ # End-to-end Docker-based tests +├── proto/ # Protocol buffer definitions +├── gen/ # Generated code (protobuf) +├── docs/ # Documentation +└── packaging/ # Distribution packaging +``` + +### Core Packages (`hscontrol/`) + +**Main Server (`hscontrol/`)** + +- `app.go`: Application setup, dependency injection, server lifecycle +- `handlers.go`: HTTP/gRPC API endpoints for management operations +- `grpcv1.go`: gRPC service implementation for headscale API +- `poll.go`: **Critical** - Handles Tailscale MapRequest/MapResponse protocol +- `noise.go`: Noise protocol implementation for secure client communication +- `auth.go`: Authentication flows (web, OIDC, command-line) +- `oidc.go`: OpenID Connect integration for user authentication + +**State Management (`hscontrol/state/`)** + +- `state.go`: Central coordinator for all subsystems (database, policy, IP allocation, DERP) +- `node_store.go`: **Performance-critical** - In-memory cache with copy-on-write semantics +- Thread-safe operations with deadlock detection +- Coordinates between database persistence and real-time operations + +**Database Layer (`hscontrol/db/`)** + +- `db.go`: Database abstraction, GORM setup, migration management +- `node.go`: Node lifecycle, registration, expiration, IP assignment +- `users.go`: User management, namespace isolation +- `api_key.go`: API authentication tokens +- `preauth_keys.go`: Pre-authentication keys for automated node registration +- `ip.go`: IP address allocation and management +- `policy.go`: Policy storage and retrieval +- Schema migrations in `schema.sql` with extensive test data coverage + +**Policy Engine (`hscontrol/policy/`)** + +- `policy.go`: Core ACL evaluation logic, HuJSON parsing +- `v2/`: Next-generation policy system with improved filtering +- `matcher/`: ACL rule matching and evaluation engine +- Determines peer visibility, route approval, and network access rules +- Supports both file-based and database-stored policies + +**Network Management (`hscontrol/`)** + +- `derp/`: DERP (Designated Encrypted Relay for Packets) server implementation + - NAT traversal when direct connections fail + - Fallback relay for firewall-restricted environments +- `mapper/`: Converts internal Headscale state to Tailscale's wire protocol format + - `tail.go`: Tailscale-specific data structure generation +- `routes/`: Subnet route management and primary route selection +- `dns/`: DNS record management and MagicDNS implementation + +**Utilities & Support (`hscontrol/`)** + +- `types/`: Core data structures, configuration, validation +- `util/`: Helper functions for networking, DNS, key management +- `templates/`: Client configuration templates (Apple, Windows, etc.) +- `notifier/`: Event notification system for real-time updates +- `metrics.go`: Prometheus metrics collection +- `capver/`: Tailscale capability version management + +### Key Subsystem Interactions + +**Node Registration Flow** + +1. **Client Connection**: `noise.go` handles secure protocol handshake +2. **Authentication**: `auth.go` validates credentials (web/OIDC/preauth) +3. **State Creation**: `state.go` coordinates IP allocation via `db/ip.go` +4. **Storage**: `db/node.go` persists node, `NodeStore` caches in memory +5. **Network Setup**: `mapper/` generates initial Tailscale network map + +**Ongoing Operations** + +1. **Poll Requests**: `poll.go` receives periodic client updates +2. **State Updates**: `NodeStore` maintains real-time node information +3. **Policy Application**: `policy/` evaluates ACL rules for peer relationships +4. **Map Distribution**: `mapper/` sends network topology to all affected clients + +**Route Management** + +1. **Advertisement**: Clients announce routes via `poll.go` Hostinfo updates +2. **Storage**: `db/` persists routes, `NodeStore` caches for performance +3. **Approval**: `policy/` auto-approves routes based on ACL rules +4. **Distribution**: `routes/` selects primary routes, `mapper/` distributes to peers + +### Command-Line Tools (`cmd/`) + +**Main Server (`cmd/headscale/`)** + +- `headscale.go`: CLI parsing, configuration loading, server startup +- Supports daemon mode, CLI operations (user/node management), database operations + +**Integration Test Runner (`cmd/hi/`)** + +- `main.go`: Test execution framework with Docker orchestration +- `run.go`: Individual test execution with artifact collection +- `doctor.go`: System requirements validation +- `docker.go`: Container lifecycle management +- Essential for validating changes against real Tailscale clients + +### Generated & External Code + +**Protocol Buffers (`proto/` → `gen/`)** + +- Defines gRPC API for headscale management operations +- Client libraries can generate from these definitions +- Run `make generate` after modifying `.proto` files + +**Integration Testing (`integration/`)** + +- `scenario.go`: Docker test environment setup +- `tailscale.go`: Tailscale client container management +- Individual test files for specific functionality areas +- Real end-to-end validation with network isolation + +### Critical Performance Paths + +**High-Frequency Operations** + +1. **MapRequest Processing** (`poll.go`): Every 15-60 seconds per client +2. **NodeStore Reads** (`node_store.go`): Every operation requiring node data +3. **Policy Evaluation** (`policy/`): On every peer relationship calculation +4. **Route Lookups** (`routes/`): During network map generation + +**Database Write Patterns** + +- **Frequent**: Node heartbeats, endpoint updates, route changes +- **Moderate**: User operations, policy updates, API key management +- **Rare**: Schema migrations, bulk operations + +### Configuration & Deployment + +**Configuration** (`hscontrol/types/config.go`)\*\* + +- Database connection settings (SQLite/PostgreSQL) +- Network configuration (IP ranges, DNS settings) +- Policy mode (file vs database) +- DERP relay configuration +- OIDC provider settings + +**Key Dependencies** + +- **GORM**: Database ORM with migration support +- **Tailscale Libraries**: Core networking and protocol code +- **Zerolog**: Structured logging throughout the application +- **Buf**: Protocol buffer toolchain for code generation + +### Development Workflow Integration + +The architecture supports incremental development: + +- **Unit Tests**: Focus on individual packages (`*_test.go` files) +- **Integration Tests**: Validate cross-component interactions +- **Database Tests**: Extensive migration and data integrity validation +- **Policy Tests**: ACL rule evaluation and edge cases +- **Performance Tests**: NodeStore and high-frequency operation validation + +## Integration Testing System + +### Overview + +Headscale uses Docker-based integration tests with real Tailscale clients to validate end-to-end functionality. The integration test system is complex and requires specialized knowledge for effective execution and debugging. + +### **MANDATORY: Use the headscale-integration-tester Agent** + +**CRITICAL REQUIREMENT**: For ANY integration test execution, analysis, troubleshooting, or validation, you MUST use the `headscale-integration-tester` agent. This agent contains specialized knowledge about: + +- Test execution strategies and timing requirements +- Infrastructure vs code issue distinction (99% vs 1% failure patterns) +- Security-critical debugging rules and forbidden practices +- Comprehensive artifact analysis workflows +- Real-world failure patterns from HA debugging experiences + +### Quick Reference Commands + +```bash +# Check system requirements (always run first) +go run ./cmd/hi doctor + +# Run single test (recommended for development) +go run ./cmd/hi run "TestName" + +# Use PostgreSQL for database-heavy tests +go run ./cmd/hi run "TestName" --postgres + +# Pattern matching for related tests +go run ./cmd/hi run "TestPattern*" +``` + +**Critical Notes**: + +- Only ONE test can run at a time (Docker port conflicts) +- Tests generate ~100MB of logs per run in `control_logs/` +- Clean environment before each test: `rm -rf control_logs/202507* && docker system prune -f` + +### Test Artifacts Location + +All test runs save comprehensive debugging artifacts to `control_logs/TIMESTAMP-ID/` including server logs, client logs, database dumps, MapResponse protocol data, and Prometheus metrics. + +**For all integration test work, use the headscale-integration-tester agent - it contains the complete knowledge needed for effective testing and debugging.** + +## NodeStore Implementation Details + +**Key Insight from Recent Work**: The NodeStore is a critical performance optimization that caches node data in memory while ensuring consistency with the database. When working with route advertisements or node state changes: + +1. **Timing Considerations**: Route advertisements need time to propagate from clients to server. Use `require.EventuallyWithT()` patterns in tests instead of immediate assertions. + +2. **Synchronization Points**: NodeStore updates happen at specific points like `poll.go:420` after Hostinfo changes. Ensure these are maintained when modifying the polling logic. + +3. **Peer Visibility**: The NodeStore's `peersFunc` determines which nodes are visible to each other. Policy-based filtering is separate from monitoring visibility - expired nodes should remain visible for debugging but marked as expired. + +## Testing Guidelines + +### Integration Test Patterns + +#### **CRITICAL: EventuallyWithT Pattern for External Calls** + +**All external calls in integration tests MUST be wrapped in EventuallyWithT blocks** to handle eventual consistency in distributed systems. External calls include: + +- `client.Status()` - Getting Tailscale client status +- `client.Curl()` - Making HTTP requests through clients +- `client.Traceroute()` - Running network diagnostics +- `headscale.ListNodes()` - Querying headscale server state +- Any other calls that interact with external systems or network operations + +**Key Rules**: + +1. **Never use bare `require.NoError(t, err)` with external calls** - Always wrap in EventuallyWithT +2. **Keep related assertions together** - If multiple assertions depend on the same external call, keep them in the same EventuallyWithT block +3. **Split unrelated external calls** - Different external calls should be in separate EventuallyWithT blocks +4. **Never nest EventuallyWithT calls** - Each EventuallyWithT should be at the same level +5. **Declare shared variables at function scope** - Variables used across multiple EventuallyWithT blocks must be declared before first use + +**Examples**: + +```go +// CORRECT: External call wrapped in EventuallyWithT +assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := client.Status() + assert.NoError(c, err) + + // Related assertions using the same status call + for _, peerKey := range status.Peers() { + peerStatus := status.Peer[peerKey] + assert.NotNil(c, peerStatus.PrimaryRoutes) + requirePeerSubnetRoutesWithCollect(c, peerStatus, expectedRoutes) + } +}, 5*time.Second, 200*time.Millisecond, "Verifying client status and routes") + +// INCORRECT: Bare external call without EventuallyWithT +status, err := client.Status() // ❌ Will fail intermittently +require.NoError(t, err) + +// CORRECT: Separate EventuallyWithT for different external calls +// First external call - headscale.ListNodes() +assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes() + assert.NoError(c, err) + assert.Len(c, nodes, 2) + requireNodeRouteCountWithCollect(c, nodes[0], 2, 2, 2) +}, 10*time.Second, 500*time.Millisecond, "route state changes should propagate to nodes") + +// Second external call - client.Status() +assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := client.Status() + assert.NoError(c, err) + + for _, peerKey := range status.Peers() { + peerStatus := status.Peer[peerKey] + requirePeerSubnetRoutesWithCollect(c, peerStatus, []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}) + } +}, 10*time.Second, 500*time.Millisecond, "routes should be visible to client") + +// INCORRECT: Multiple unrelated external calls in same EventuallyWithT +assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes() // ❌ First external call + assert.NoError(c, err) + + status, err := client.Status() // ❌ Different external call - should be separate + assert.NoError(c, err) +}, 10*time.Second, 500*time.Millisecond, "mixed calls") + +// CORRECT: Variable scoping for shared data +var ( + srs1, srs2, srs3 *ipnstate.Status + clientStatus *ipnstate.Status + srs1PeerStatus *ipnstate.PeerStatus +) + +assert.EventuallyWithT(t, func(c *assert.CollectT) { + srs1 = subRouter1.MustStatus() // = not := + srs2 = subRouter2.MustStatus() + clientStatus = client.MustStatus() + + srs1PeerStatus = clientStatus.Peer[srs1.Self.PublicKey] + // assertions... +}, 5*time.Second, 200*time.Millisecond, "checking router status") + +// CORRECT: Wrapping client operations +assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := client.Curl(weburl) + assert.NoError(c, err) + assert.Len(c, result, 13) +}, 5*time.Second, 200*time.Millisecond, "Verifying HTTP connectivity") + +assert.EventuallyWithT(t, func(c *assert.CollectT) { + tr, err := client.Traceroute(webip) + assert.NoError(c, err) + assertTracerouteViaIPWithCollect(c, tr, expectedRouter.MustIPv4()) +}, 5*time.Second, 200*time.Millisecond, "Verifying network path") +``` + +**Helper Functions**: + +- Use `requirePeerSubnetRoutesWithCollect` instead of `requirePeerSubnetRoutes` inside EventuallyWithT +- Use `requireNodeRouteCountWithCollect` instead of `requireNodeRouteCount` inside EventuallyWithT +- Use `assertTracerouteViaIPWithCollect` instead of `assertTracerouteViaIP` inside EventuallyWithT + +```go +// Node route checking by actual node properties, not array position +var routeNode *v1.Node +for _, node := range nodes { + if nodeIDStr := fmt.Sprintf("%d", node.GetId()); expectedRoutes[nodeIDStr] != "" { + routeNode = node + break + } +} +``` + +### Running Problematic Tests + +- Some tests require significant time (e.g., `TestNodeOnlineStatus` runs for 12 minutes) +- Infrastructure issues like disk space can cause test failures unrelated to code changes +- Use `--postgres` flag when testing database-heavy scenarios + +## Quality Assurance and Testing Requirements + +### **MANDATORY: Always Use Specialized Testing Agents** + +**CRITICAL REQUIREMENT**: For ANY task involving testing, quality assurance, review, or validation, you MUST use the appropriate specialized agent at the END of your task list. This ensures comprehensive quality validation and prevents regressions. + +**Required Agents for Different Task Types**: + +1. **Integration Testing**: Use `headscale-integration-tester` agent for: + - Running integration tests with `cmd/hi` + - Analyzing test failures and artifacts + - Troubleshooting Docker-based test infrastructure + - Validating end-to-end functionality changes + +2. **Quality Control**: Use `quality-control-enforcer` agent for: + - Code review and validation + - Ensuring best practices compliance + - Preventing common pitfalls and anti-patterns + - Validating architectural decisions + +**Agent Usage Pattern**: Always add the appropriate agent as the FINAL step in any task list to ensure quality validation occurs after all work is complete. + +### Integration Test Debugging Reference + +Test artifacts are preserved in `control_logs/TIMESTAMP-ID/` including: + +- Headscale server logs (stderr/stdout) +- Tailscale client logs and status +- Database dumps and network captures +- MapResponse JSON files for protocol debugging + +**For integration test issues, ALWAYS use the headscale-integration-tester agent - do not attempt manual debugging.** + +## EventuallyWithT Pattern for Integration Tests + +### Overview + +EventuallyWithT is a testing pattern used to handle eventual consistency in distributed systems. In Headscale integration tests, many operations are asynchronous - clients advertise routes, the server processes them, updates propagate through the network. EventuallyWithT allows tests to wait for these operations to complete while making assertions. + +### External Calls That Must Be Wrapped + +The following operations are **external calls** that interact with the headscale server or tailscale clients and MUST be wrapped in EventuallyWithT: + +- `headscale.ListNodes()` - Queries server state +- `client.Status()` - Gets client network status +- `client.Curl()` - Makes HTTP requests through the network +- `client.Traceroute()` - Performs network diagnostics +- `client.Execute()` when running commands that query state +- Any operation that reads from the headscale server or tailscale client + +### Operations That Must NOT Be Wrapped + +The following are **blocking operations** that modify state and should NOT be wrapped in EventuallyWithT: + +- `tailscale set` commands (e.g., `--advertise-routes`, `--exit-node`) +- Any command that changes configuration or state +- Use `client.MustStatus()` instead of `client.Status()` when you just need the ID for a blocking operation + +### Five Key Rules for EventuallyWithT + +1. **One External Call Per EventuallyWithT Block** + - Each EventuallyWithT should make ONE external call (e.g., ListNodes OR Status) + - Related assertions based on that single call can be grouped together + - Unrelated external calls must be in separate EventuallyWithT blocks + +2. **Variable Scoping** + - Declare variables that need to be shared across EventuallyWithT blocks at function scope + - Use `=` for assignment inside EventuallyWithT, not `:=` (unless the variable is only used within that block) + - Variables declared with `:=` inside EventuallyWithT are not accessible outside + +3. **No Nested EventuallyWithT** + - NEVER put an EventuallyWithT inside another EventuallyWithT + - This is a critical anti-pattern that must be avoided + +4. **Use CollectT for Assertions** + - Inside EventuallyWithT, use `assert` methods with the CollectT parameter + - Helper functions called within EventuallyWithT must accept `*assert.CollectT` + +5. **Descriptive Messages** + - Always provide a descriptive message as the last parameter + - Message should explain what condition is being waited for + +### Correct Pattern Examples + +```go +// CORRECT: Blocking operation NOT wrapped +for _, client := range allClients { + status := client.MustStatus() + command := []string{ + "tailscale", + "set", + "--advertise-routes=" + expectedRoutes[string(status.Self.ID)], + } + _, _, err = client.Execute(command) + require.NoErrorf(t, err, "failed to advertise route: %s", err) +} + +// CORRECT: Single external call with related assertions +var nodes []*v1.Node +assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err = headscale.ListNodes() + assert.NoError(c, err) + assert.Len(c, nodes, 2) + requireNodeRouteCountWithCollect(c, nodes[0], 2, 2, 2) +}, 10*time.Second, 500*time.Millisecond, "nodes should have expected route counts") + +// CORRECT: Separate EventuallyWithT for different external call +assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := client.Status() + assert.NoError(c, err) + for _, peerKey := range status.Peers() { + peerStatus := status.Peer[peerKey] + requirePeerSubnetRoutesWithCollect(c, peerStatus, expectedPrefixes) + } +}, 10*time.Second, 500*time.Millisecond, "client should see expected routes") +``` + +### Incorrect Patterns to Avoid + +```go +// INCORRECT: Blocking operation wrapped in EventuallyWithT +assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := client.Status() + assert.NoError(c, err) + + // This is a blocking operation - should NOT be in EventuallyWithT! + command := []string{ + "tailscale", + "set", + "--advertise-routes=" + expectedRoutes[string(status.Self.ID)], + } + _, _, err = client.Execute(command) + assert.NoError(c, err) +}, 5*time.Second, 200*time.Millisecond, "wrong pattern") + +// INCORRECT: Multiple unrelated external calls in same EventuallyWithT +assert.EventuallyWithT(t, func(c *assert.CollectT) { + // First external call + nodes, err := headscale.ListNodes() + assert.NoError(c, err) + assert.Len(c, nodes, 2) + + // Second unrelated external call - WRONG! + status, err := client.Status() + assert.NoError(c, err) + assert.NotNil(c, status) +}, 10*time.Second, 500*time.Millisecond, "mixed operations") +``` + +## Important Notes + +- **Dependencies**: Use `nix develop` for consistent toolchain (Go, buf, protobuf tools, linting) +- **Protocol Buffers**: Changes to `proto/` require `make generate` and should be committed separately +- **Code Style**: Enforced via golangci-lint with golines (width 88) and gofumpt formatting +- **Linting**: ALL code must pass `golangci-lint run --new-from-rev=upstream/main --timeout=5m --fix` before commit +- **Database**: Supports both SQLite (development) and PostgreSQL (production/testing) +- **Integration Tests**: Require Docker and can consume significant disk space - use headscale-integration-tester agent +- **Performance**: NodeStore optimizations are critical for scale - be careful with changes to state management +- **Quality Assurance**: Always use appropriate specialized agents for testing and validation tasks diff --git a/CLAUDE.md b/CLAUDE.md index d4034367..43c994c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,531 +1 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Overview - -Headscale is an open-source implementation of the Tailscale control server written in Go. It provides self-hosted coordination for Tailscale networks (tailnets), managing node registration, IP allocation, policy enforcement, and DERP routing. - -## Development Commands - -### Quick Setup -```bash -# Recommended: Use Nix for dependency management -nix develop - -# Full development workflow -make dev # runs fmt + lint + test + build -``` - -### Essential Commands -```bash -# Build headscale binary -make build - -# Run tests -make test -go test ./... # All unit tests -go test -race ./... # With race detection - -# Run specific integration test -go run ./cmd/hi run "TestName" --postgres - -# Code formatting and linting -make fmt # Format all code (Go, docs, proto) -make lint # Lint all code (Go, proto) -make fmt-go # Format Go code only -make lint-go # Lint Go code only - -# Protocol buffer generation (after modifying proto/) -make generate - -# Clean build artifacts -make clean -``` - -### Integration Testing -```bash -# Use the hi (Headscale Integration) test runner -go run ./cmd/hi doctor # Check system requirements -go run ./cmd/hi run "TestPattern" # Run specific test -go run ./cmd/hi run "TestPattern" --postgres # With PostgreSQL backend - -# Test artifacts are saved to control_logs/ with logs and debug data -``` - -## Project Structure & Architecture - -### Top-Level Organization - -``` -headscale/ -├── cmd/ # Command-line applications -│ ├── headscale/ # Main headscale server binary -│ └── hi/ # Headscale Integration test runner -├── hscontrol/ # Core control plane logic -├── integration/ # End-to-end Docker-based tests -├── proto/ # Protocol buffer definitions -├── gen/ # Generated code (protobuf) -├── docs/ # Documentation -└── packaging/ # Distribution packaging -``` - -### Core Packages (`hscontrol/`) - -**Main Server (`hscontrol/`)** -- `app.go`: Application setup, dependency injection, server lifecycle -- `handlers.go`: HTTP/gRPC API endpoints for management operations -- `grpcv1.go`: gRPC service implementation for headscale API -- `poll.go`: **Critical** - Handles Tailscale MapRequest/MapResponse protocol -- `noise.go`: Noise protocol implementation for secure client communication -- `auth.go`: Authentication flows (web, OIDC, command-line) -- `oidc.go`: OpenID Connect integration for user authentication - -**State Management (`hscontrol/state/`)** -- `state.go`: Central coordinator for all subsystems (database, policy, IP allocation, DERP) -- `node_store.go`: **Performance-critical** - In-memory cache with copy-on-write semantics -- Thread-safe operations with deadlock detection -- Coordinates between database persistence and real-time operations - -**Database Layer (`hscontrol/db/`)** -- `db.go`: Database abstraction, GORM setup, migration management -- `node.go`: Node lifecycle, registration, expiration, IP assignment -- `users.go`: User management, namespace isolation -- `api_key.go`: API authentication tokens -- `preauth_keys.go`: Pre-authentication keys for automated node registration -- `ip.go`: IP address allocation and management -- `policy.go`: Policy storage and retrieval -- Schema migrations in `schema.sql` with extensive test data coverage - -**Policy Engine (`hscontrol/policy/`)** -- `policy.go`: Core ACL evaluation logic, HuJSON parsing -- `v2/`: Next-generation policy system with improved filtering -- `matcher/`: ACL rule matching and evaluation engine -- Determines peer visibility, route approval, and network access rules -- Supports both file-based and database-stored policies - -**Network Management (`hscontrol/`)** -- `derp/`: DERP (Designated Encrypted Relay for Packets) server implementation - - NAT traversal when direct connections fail - - Fallback relay for firewall-restricted environments -- `mapper/`: Converts internal Headscale state to Tailscale's wire protocol format - - `tail.go`: Tailscale-specific data structure generation -- `routes/`: Subnet route management and primary route selection -- `dns/`: DNS record management and MagicDNS implementation - -**Utilities & Support (`hscontrol/`)** -- `types/`: Core data structures, configuration, validation -- `util/`: Helper functions for networking, DNS, key management -- `templates/`: Client configuration templates (Apple, Windows, etc.) -- `notifier/`: Event notification system for real-time updates -- `metrics.go`: Prometheus metrics collection -- `capver/`: Tailscale capability version management - -### Key Subsystem Interactions - -**Node Registration Flow** -1. **Client Connection**: `noise.go` handles secure protocol handshake -2. **Authentication**: `auth.go` validates credentials (web/OIDC/preauth) -3. **State Creation**: `state.go` coordinates IP allocation via `db/ip.go` -4. **Storage**: `db/node.go` persists node, `NodeStore` caches in memory -5. **Network Setup**: `mapper/` generates initial Tailscale network map - -**Ongoing Operations** -1. **Poll Requests**: `poll.go` receives periodic client updates -2. **State Updates**: `NodeStore` maintains real-time node information -3. **Policy Application**: `policy/` evaluates ACL rules for peer relationships -4. **Map Distribution**: `mapper/` sends network topology to all affected clients - -**Route Management** -1. **Advertisement**: Clients announce routes via `poll.go` Hostinfo updates -2. **Storage**: `db/` persists routes, `NodeStore` caches for performance -3. **Approval**: `policy/` auto-approves routes based on ACL rules -4. **Distribution**: `routes/` selects primary routes, `mapper/` distributes to peers - -### Command-Line Tools (`cmd/`) - -**Main Server (`cmd/headscale/`)** -- `headscale.go`: CLI parsing, configuration loading, server startup -- Supports daemon mode, CLI operations (user/node management), database operations - -**Integration Test Runner (`cmd/hi/`)** -- `main.go`: Test execution framework with Docker orchestration -- `run.go`: Individual test execution with artifact collection -- `doctor.go`: System requirements validation -- `docker.go`: Container lifecycle management -- Essential for validating changes against real Tailscale clients - -### Generated & External Code - -**Protocol Buffers (`proto/` → `gen/`)** -- Defines gRPC API for headscale management operations -- Client libraries can generate from these definitions -- Run `make generate` after modifying `.proto` files - -**Integration Testing (`integration/`)** -- `scenario.go`: Docker test environment setup -- `tailscale.go`: Tailscale client container management -- Individual test files for specific functionality areas -- Real end-to-end validation with network isolation - -### Critical Performance Paths - -**High-Frequency Operations** -1. **MapRequest Processing** (`poll.go`): Every 15-60 seconds per client -2. **NodeStore Reads** (`node_store.go`): Every operation requiring node data -3. **Policy Evaluation** (`policy/`): On every peer relationship calculation -4. **Route Lookups** (`routes/`): During network map generation - -**Database Write Patterns** -- **Frequent**: Node heartbeats, endpoint updates, route changes -- **Moderate**: User operations, policy updates, API key management -- **Rare**: Schema migrations, bulk operations - -### Configuration & Deployment - -**Configuration** (`hscontrol/types/config.go`)** -- Database connection settings (SQLite/PostgreSQL) -- Network configuration (IP ranges, DNS settings) -- Policy mode (file vs database) -- DERP relay configuration -- OIDC provider settings - -**Key Dependencies** -- **GORM**: Database ORM with migration support -- **Tailscale Libraries**: Core networking and protocol code -- **Zerolog**: Structured logging throughout the application -- **Buf**: Protocol buffer toolchain for code generation - -### Development Workflow Integration - -The architecture supports incremental development: -- **Unit Tests**: Focus on individual packages (`*_test.go` files) -- **Integration Tests**: Validate cross-component interactions -- **Database Tests**: Extensive migration and data integrity validation -- **Policy Tests**: ACL rule evaluation and edge cases -- **Performance Tests**: NodeStore and high-frequency operation validation - -## Integration Testing System - -### Overview -Headscale uses Docker-based integration tests with real Tailscale clients to validate end-to-end functionality. The integration test system is complex and requires specialized knowledge for effective execution and debugging. - -### **MANDATORY: Use the headscale-integration-tester Agent** - -**CRITICAL REQUIREMENT**: For ANY integration test execution, analysis, troubleshooting, or validation, you MUST use the `headscale-integration-tester` agent. This agent contains specialized knowledge about: - -- Test execution strategies and timing requirements -- Infrastructure vs code issue distinction (99% vs 1% failure patterns) -- Security-critical debugging rules and forbidden practices -- Comprehensive artifact analysis workflows -- Real-world failure patterns from HA debugging experiences - -### Quick Reference Commands - -```bash -# Check system requirements (always run first) -go run ./cmd/hi doctor - -# Run single test (recommended for development) -go run ./cmd/hi run "TestName" - -# Use PostgreSQL for database-heavy tests -go run ./cmd/hi run "TestName" --postgres - -# Pattern matching for related tests -go run ./cmd/hi run "TestPattern*" -``` - -**Critical Notes**: -- Only ONE test can run at a time (Docker port conflicts) -- Tests generate ~100MB of logs per run in `control_logs/` -- Clean environment before each test: `rm -rf control_logs/202507* && docker system prune -f` - -### Test Artifacts Location -All test runs save comprehensive debugging artifacts to `control_logs/TIMESTAMP-ID/` including server logs, client logs, database dumps, MapResponse protocol data, and Prometheus metrics. - -**For all integration test work, use the headscale-integration-tester agent - it contains the complete knowledge needed for effective testing and debugging.** - -## NodeStore Implementation Details - -**Key Insight from Recent Work**: The NodeStore is a critical performance optimization that caches node data in memory while ensuring consistency with the database. When working with route advertisements or node state changes: - -1. **Timing Considerations**: Route advertisements need time to propagate from clients to server. Use `require.EventuallyWithT()` patterns in tests instead of immediate assertions. - -2. **Synchronization Points**: NodeStore updates happen at specific points like `poll.go:420` after Hostinfo changes. Ensure these are maintained when modifying the polling logic. - -3. **Peer Visibility**: The NodeStore's `peersFunc` determines which nodes are visible to each other. Policy-based filtering is separate from monitoring visibility - expired nodes should remain visible for debugging but marked as expired. - -## Testing Guidelines - -### Integration Test Patterns - -#### **CRITICAL: EventuallyWithT Pattern for External Calls** - -**All external calls in integration tests MUST be wrapped in EventuallyWithT blocks** to handle eventual consistency in distributed systems. External calls include: -- `client.Status()` - Getting Tailscale client status -- `client.Curl()` - Making HTTP requests through clients -- `client.Traceroute()` - Running network diagnostics -- `headscale.ListNodes()` - Querying headscale server state -- Any other calls that interact with external systems or network operations - -**Key Rules**: -1. **Never use bare `require.NoError(t, err)` with external calls** - Always wrap in EventuallyWithT -2. **Keep related assertions together** - If multiple assertions depend on the same external call, keep them in the same EventuallyWithT block -3. **Split unrelated external calls** - Different external calls should be in separate EventuallyWithT blocks -4. **Never nest EventuallyWithT calls** - Each EventuallyWithT should be at the same level -5. **Declare shared variables at function scope** - Variables used across multiple EventuallyWithT blocks must be declared before first use - -**Examples**: - -```go -// CORRECT: External call wrapped in EventuallyWithT -assert.EventuallyWithT(t, func(c *assert.CollectT) { - status, err := client.Status() - assert.NoError(c, err) - - // Related assertions using the same status call - for _, peerKey := range status.Peers() { - peerStatus := status.Peer[peerKey] - assert.NotNil(c, peerStatus.PrimaryRoutes) - requirePeerSubnetRoutesWithCollect(c, peerStatus, expectedRoutes) - } -}, 5*time.Second, 200*time.Millisecond, "Verifying client status and routes") - -// INCORRECT: Bare external call without EventuallyWithT -status, err := client.Status() // ❌ Will fail intermittently -require.NoError(t, err) - -// CORRECT: Separate EventuallyWithT for different external calls -// First external call - headscale.ListNodes() -assert.EventuallyWithT(t, func(c *assert.CollectT) { - nodes, err := headscale.ListNodes() - assert.NoError(c, err) - assert.Len(c, nodes, 2) - requireNodeRouteCountWithCollect(c, nodes[0], 2, 2, 2) -}, 10*time.Second, 500*time.Millisecond, "route state changes should propagate to nodes") - -// Second external call - client.Status() -assert.EventuallyWithT(t, func(c *assert.CollectT) { - status, err := client.Status() - assert.NoError(c, err) - - for _, peerKey := range status.Peers() { - peerStatus := status.Peer[peerKey] - requirePeerSubnetRoutesWithCollect(c, peerStatus, []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}) - } -}, 10*time.Second, 500*time.Millisecond, "routes should be visible to client") - -// INCORRECT: Multiple unrelated external calls in same EventuallyWithT -assert.EventuallyWithT(t, func(c *assert.CollectT) { - nodes, err := headscale.ListNodes() // ❌ First external call - assert.NoError(c, err) - - status, err := client.Status() // ❌ Different external call - should be separate - assert.NoError(c, err) -}, 10*time.Second, 500*time.Millisecond, "mixed calls") - -// CORRECT: Variable scoping for shared data -var ( - srs1, srs2, srs3 *ipnstate.Status - clientStatus *ipnstate.Status - srs1PeerStatus *ipnstate.PeerStatus -) - -assert.EventuallyWithT(t, func(c *assert.CollectT) { - srs1 = subRouter1.MustStatus() // = not := - srs2 = subRouter2.MustStatus() - clientStatus = client.MustStatus() - - srs1PeerStatus = clientStatus.Peer[srs1.Self.PublicKey] - // assertions... -}, 5*time.Second, 200*time.Millisecond, "checking router status") - -// CORRECT: Wrapping client operations -assert.EventuallyWithT(t, func(c *assert.CollectT) { - result, err := client.Curl(weburl) - assert.NoError(c, err) - assert.Len(c, result, 13) -}, 5*time.Second, 200*time.Millisecond, "Verifying HTTP connectivity") - -assert.EventuallyWithT(t, func(c *assert.CollectT) { - tr, err := client.Traceroute(webip) - assert.NoError(c, err) - assertTracerouteViaIPWithCollect(c, tr, expectedRouter.MustIPv4()) -}, 5*time.Second, 200*time.Millisecond, "Verifying network path") -``` - -**Helper Functions**: -- Use `requirePeerSubnetRoutesWithCollect` instead of `requirePeerSubnetRoutes` inside EventuallyWithT -- Use `requireNodeRouteCountWithCollect` instead of `requireNodeRouteCount` inside EventuallyWithT -- Use `assertTracerouteViaIPWithCollect` instead of `assertTracerouteViaIP` inside EventuallyWithT - -```go -// Node route checking by actual node properties, not array position -var routeNode *v1.Node -for _, node := range nodes { - if nodeIDStr := fmt.Sprintf("%d", node.GetId()); expectedRoutes[nodeIDStr] != "" { - routeNode = node - break - } -} -``` - -### Running Problematic Tests -- Some tests require significant time (e.g., `TestNodeOnlineStatus` runs for 12 minutes) -- Infrastructure issues like disk space can cause test failures unrelated to code changes -- Use `--postgres` flag when testing database-heavy scenarios - -## Quality Assurance and Testing Requirements - -### **MANDATORY: Always Use Specialized Testing Agents** - -**CRITICAL REQUIREMENT**: For ANY task involving testing, quality assurance, review, or validation, you MUST use the appropriate specialized agent at the END of your task list. This ensures comprehensive quality validation and prevents regressions. - -**Required Agents for Different Task Types**: - -1. **Integration Testing**: Use `headscale-integration-tester` agent for: - - Running integration tests with `cmd/hi` - - Analyzing test failures and artifacts - - Troubleshooting Docker-based test infrastructure - - Validating end-to-end functionality changes - -2. **Quality Control**: Use `quality-control-enforcer` agent for: - - Code review and validation - - Ensuring best practices compliance - - Preventing common pitfalls and anti-patterns - - Validating architectural decisions - -**Agent Usage Pattern**: Always add the appropriate agent as the FINAL step in any task list to ensure quality validation occurs after all work is complete. - -### Integration Test Debugging Reference - -Test artifacts are preserved in `control_logs/TIMESTAMP-ID/` including: -- Headscale server logs (stderr/stdout) -- Tailscale client logs and status -- Database dumps and network captures -- MapResponse JSON files for protocol debugging - -**For integration test issues, ALWAYS use the headscale-integration-tester agent - do not attempt manual debugging.** - -## EventuallyWithT Pattern for Integration Tests - -### Overview -EventuallyWithT is a testing pattern used to handle eventual consistency in distributed systems. In Headscale integration tests, many operations are asynchronous - clients advertise routes, the server processes them, updates propagate through the network. EventuallyWithT allows tests to wait for these operations to complete while making assertions. - -### External Calls That Must Be Wrapped -The following operations are **external calls** that interact with the headscale server or tailscale clients and MUST be wrapped in EventuallyWithT: -- `headscale.ListNodes()` - Queries server state -- `client.Status()` - Gets client network status -- `client.Curl()` - Makes HTTP requests through the network -- `client.Traceroute()` - Performs network diagnostics -- `client.Execute()` when running commands that query state -- Any operation that reads from the headscale server or tailscale client - -### Operations That Must NOT Be Wrapped -The following are **blocking operations** that modify state and should NOT be wrapped in EventuallyWithT: -- `tailscale set` commands (e.g., `--advertise-routes`, `--exit-node`) -- Any command that changes configuration or state -- Use `client.MustStatus()` instead of `client.Status()` when you just need the ID for a blocking operation - -### Five Key Rules for EventuallyWithT - -1. **One External Call Per EventuallyWithT Block** - - Each EventuallyWithT should make ONE external call (e.g., ListNodes OR Status) - - Related assertions based on that single call can be grouped together - - Unrelated external calls must be in separate EventuallyWithT blocks - -2. **Variable Scoping** - - Declare variables that need to be shared across EventuallyWithT blocks at function scope - - Use `=` for assignment inside EventuallyWithT, not `:=` (unless the variable is only used within that block) - - Variables declared with `:=` inside EventuallyWithT are not accessible outside - -3. **No Nested EventuallyWithT** - - NEVER put an EventuallyWithT inside another EventuallyWithT - - This is a critical anti-pattern that must be avoided - -4. **Use CollectT for Assertions** - - Inside EventuallyWithT, use `assert` methods with the CollectT parameter - - Helper functions called within EventuallyWithT must accept `*assert.CollectT` - -5. **Descriptive Messages** - - Always provide a descriptive message as the last parameter - - Message should explain what condition is being waited for - -### Correct Pattern Examples - -```go -// CORRECT: Blocking operation NOT wrapped -for _, client := range allClients { - status := client.MustStatus() - command := []string{ - "tailscale", - "set", - "--advertise-routes=" + expectedRoutes[string(status.Self.ID)], - } - _, _, err = client.Execute(command) - require.NoErrorf(t, err, "failed to advertise route: %s", err) -} - -// CORRECT: Single external call with related assertions -var nodes []*v1.Node -assert.EventuallyWithT(t, func(c *assert.CollectT) { - nodes, err = headscale.ListNodes() - assert.NoError(c, err) - assert.Len(c, nodes, 2) - requireNodeRouteCountWithCollect(c, nodes[0], 2, 2, 2) -}, 10*time.Second, 500*time.Millisecond, "nodes should have expected route counts") - -// CORRECT: Separate EventuallyWithT for different external call -assert.EventuallyWithT(t, func(c *assert.CollectT) { - status, err := client.Status() - assert.NoError(c, err) - for _, peerKey := range status.Peers() { - peerStatus := status.Peer[peerKey] - requirePeerSubnetRoutesWithCollect(c, peerStatus, expectedPrefixes) - } -}, 10*time.Second, 500*time.Millisecond, "client should see expected routes") -``` - -### Incorrect Patterns to Avoid - -```go -// INCORRECT: Blocking operation wrapped in EventuallyWithT -assert.EventuallyWithT(t, func(c *assert.CollectT) { - status, err := client.Status() - assert.NoError(c, err) - - // This is a blocking operation - should NOT be in EventuallyWithT! - command := []string{ - "tailscale", - "set", - "--advertise-routes=" + expectedRoutes[string(status.Self.ID)], - } - _, _, err = client.Execute(command) - assert.NoError(c, err) -}, 5*time.Second, 200*time.Millisecond, "wrong pattern") - -// INCORRECT: Multiple unrelated external calls in same EventuallyWithT -assert.EventuallyWithT(t, func(c *assert.CollectT) { - // First external call - nodes, err := headscale.ListNodes() - assert.NoError(c, err) - assert.Len(c, nodes, 2) - - // Second unrelated external call - WRONG! - status, err := client.Status() - assert.NoError(c, err) - assert.NotNil(c, status) -}, 10*time.Second, 500*time.Millisecond, "mixed operations") -``` - -## Important Notes - -- **Dependencies**: Use `nix develop` for consistent toolchain (Go, buf, protobuf tools, linting) -- **Protocol Buffers**: Changes to `proto/` require `make generate` and should be committed separately -- **Code Style**: Enforced via golangci-lint with golines (width 88) and gofumpt formatting -- **Database**: Supports both SQLite (development) and PostgreSQL (production/testing) -- **Integration Tests**: Require Docker and can consume significant disk space - use headscale-integration-tester agent -- **Performance**: NodeStore optimizations are critical for scale - be careful with changes to state management -- **Quality Assurance**: Always use appropriate specialized agents for testing and validation tasks -- **NEVER create gists in the user's name**: Do not use the `create_gist` tool - present information directly in the response instead +@AGENTS.md diff --git a/Makefile b/Makefile index d9b2c76b..9a5b8dfa 100644 --- a/Makefile +++ b/Makefile @@ -117,7 +117,7 @@ help: @echo "" @echo "Specific targets:" @echo " fmt-go - Format Go code only" - @echo " fmt-prettier - Format documentation only" + @echo " fmt-prettier - Format documentation only" @echo " fmt-proto - Format Protocol Buffer files only" @echo " lint-go - Lint Go code only" @echo " lint-proto - Lint Protocol Buffer files only" @@ -126,4 +126,4 @@ help: @echo " check-deps - Verify required tools are available" @echo "" @echo "Note: If not running in a nix shell, ensure dependencies are available:" - @echo " nix develop" \ No newline at end of file + @echo " nix develop" diff --git a/README.md b/README.md index 61a2c92c..dbde74d9 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ make build We recommend using Nix for dependency management to ensure you have all required tools. If you prefer to manage dependencies yourself, you can use Make directly: **With Nix (recommended):** + ```shell nix develop make test @@ -154,6 +155,7 @@ make build ``` **With your own dependencies:** + ```shell make test make build diff --git a/derp-example.yaml b/derp-example.yaml index 532475ef..ea93427c 100644 --- a/derp-example.yaml +++ b/derp-example.yaml @@ -1,6 +1,6 @@ # If you plan to somehow use headscale, please deploy your own DERP infra: https://tailscale.com/kb/1118/custom-derp-servers/ regions: - 1: null # Disable DERP region with ID 1 + 1: null # Disable DERP region with ID 1 900: regionid: 900 regioncode: custom diff --git a/docs/logo/headscale3-dots.svg b/docs/logo/headscale3-dots.svg index 6a20973c..f7120395 100644 --- a/docs/logo/headscale3-dots.svg +++ b/docs/logo/headscale3-dots.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/docs/logo/headscale3_header_stacked_left.svg b/docs/logo/headscale3_header_stacked_left.svg index d00af00e..0c3702c6 100644 --- a/docs/logo/headscale3_header_stacked_left.svg +++ b/docs/logo/headscale3_header_stacked_left.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/flake.nix b/flake.nix index f8eb6dd1..86f8b005 100644 --- a/flake.nix +++ b/flake.nix @@ -6,239 +6,246 @@ flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { - self, - nixpkgs, - flake-utils, - ... - }: let - headscaleVersion = self.shortRev or self.dirtyShortRev; - commitHash = self.rev or self.dirtyRev; - in + outputs = + { self + , nixpkgs + , flake-utils + , ... + }: + let + headscaleVersion = self.shortRev or self.dirtyShortRev; + commitHash = self.rev or self.dirtyRev; + in { - overlay = _: prev: let - pkgs = nixpkgs.legacyPackages.${prev.system}; - buildGo = pkgs.buildGo125Module; - vendorHash = "sha256-VOi4PGZ8I+2MiwtzxpKc/4smsL5KcH/pHVkjJfAFPJ0="; - in { - headscale = buildGo { - pname = "headscale"; - version = headscaleVersion; - src = pkgs.lib.cleanSource self; + overlay = _: prev: + let + pkgs = nixpkgs.legacyPackages.${prev.system}; + buildGo = pkgs.buildGo125Module; + vendorHash = "sha256-VOi4PGZ8I+2MiwtzxpKc/4smsL5KcH/pHVkjJfAFPJ0="; + in + { + headscale = buildGo { + pname = "headscale"; + version = headscaleVersion; + src = pkgs.lib.cleanSource self; - # Only run unit tests when testing a build - checkFlags = ["-short"]; + # Only run unit tests when testing a build + checkFlags = [ "-short" ]; - # When updating go.mod or go.sum, a new sha will need to be calculated, - # update this if you have a mismatch after doing a change to those files. - inherit vendorHash; + # When updating go.mod or go.sum, a new sha will need to be calculated, + # update this if you have a mismatch after doing a change to those files. + inherit vendorHash; - subPackages = ["cmd/headscale"]; + subPackages = [ "cmd/headscale" ]; - ldflags = [ - "-s" - "-w" - "-X github.com/juanfont/headscale/hscontrol/types.Version=${headscaleVersion}" - "-X github.com/juanfont/headscale/hscontrol/types.GitCommitHash=${commitHash}" - ]; - }; - - hi = buildGo { - pname = "hi"; - version = headscaleVersion; - src = pkgs.lib.cleanSource self; - - checkFlags = ["-short"]; - inherit vendorHash; - - subPackages = ["cmd/hi"]; - }; - - protoc-gen-grpc-gateway = buildGo rec { - pname = "grpc-gateway"; - version = "2.24.0"; - - src = pkgs.fetchFromGitHub { - owner = "grpc-ecosystem"; - repo = "grpc-gateway"; - rev = "v${version}"; - sha256 = "sha256-lUEoqXJF1k4/il9bdDTinkUV5L869njZNYqObG/mHyA="; + ldflags = [ + "-s" + "-w" + "-X github.com/juanfont/headscale/hscontrol/types.Version=${headscaleVersion}" + "-X github.com/juanfont/headscale/hscontrol/types.GitCommitHash=${commitHash}" + ]; }; - vendorHash = "sha256-Ttt7bPKU+TMKRg5550BS6fsPwYp0QJqcZ7NLrhttSdw="; + hi = buildGo { + pname = "hi"; + version = headscaleVersion; + src = pkgs.lib.cleanSource self; - nativeBuildInputs = [pkgs.installShellFiles]; + checkFlags = [ "-short" ]; + inherit vendorHash; - subPackages = ["protoc-gen-grpc-gateway" "protoc-gen-openapiv2"]; - }; - - protobuf-language-server = buildGo rec { - pname = "protobuf-language-server"; - version = "2546944"; - - src = pkgs.fetchFromGitHub { - owner = "lasorda"; - repo = "protobuf-language-server"; - rev = "${version}"; - sha256 = "sha256-Cbr3ktT86RnwUntOiDKRpNTClhdyrKLTQG2ZEd6fKDc="; + subPackages = [ "cmd/hi" ]; }; - vendorHash = "sha256-PfT90dhfzJZabzLTb1D69JCO+kOh2khrlpF5mCDeypk="; + protoc-gen-grpc-gateway = buildGo rec { + pname = "grpc-gateway"; + version = "2.24.0"; - subPackages = ["."]; + src = pkgs.fetchFromGitHub { + owner = "grpc-ecosystem"; + repo = "grpc-gateway"; + rev = "v${version}"; + sha256 = "sha256-lUEoqXJF1k4/il9bdDTinkUV5L869njZNYqObG/mHyA="; + }; + + vendorHash = "sha256-Ttt7bPKU+TMKRg5550BS6fsPwYp0QJqcZ7NLrhttSdw="; + + nativeBuildInputs = [ pkgs.installShellFiles ]; + + subPackages = [ "protoc-gen-grpc-gateway" "protoc-gen-openapiv2" ]; + }; + + protobuf-language-server = buildGo rec { + pname = "protobuf-language-server"; + version = "2546944"; + + src = pkgs.fetchFromGitHub { + owner = "lasorda"; + repo = "protobuf-language-server"; + rev = "${version}"; + sha256 = "sha256-Cbr3ktT86RnwUntOiDKRpNTClhdyrKLTQG2ZEd6fKDc="; + }; + + vendorHash = "sha256-PfT90dhfzJZabzLTb1D69JCO+kOh2khrlpF5mCDeypk="; + + subPackages = [ "." ]; + }; + + # Upstream does not override buildGoModule properly, + # importing a specific module, so comment out for now. + # golangci-lint = prev.golangci-lint.override { + # buildGoModule = buildGo; + # }; + # golangci-lint-langserver = prev.golangci-lint.override { + # buildGoModule = buildGo; + # }; + + # The package uses buildGo125Module, not the convention. + # goreleaser = prev.goreleaser.override { + # buildGoModule = buildGo; + # }; + + gotestsum = prev.gotestsum.override { + buildGoModule = buildGo; + }; + + gotests = prev.gotests.override { + buildGoModule = buildGo; + }; + + gofumpt = prev.gofumpt.override { + buildGoModule = buildGo; + }; + + # gopls = prev.gopls.override { + # buildGoModule = buildGo; + # }; }; - - # Upstream does not override buildGoModule properly, - # importing a specific module, so comment out for now. - # golangci-lint = prev.golangci-lint.override { - # buildGoModule = buildGo; - # }; - # golangci-lint-langserver = prev.golangci-lint.override { - # buildGoModule = buildGo; - # }; - - # The package uses buildGo125Module, not the convention. - # goreleaser = prev.goreleaser.override { - # buildGoModule = buildGo; - # }; - - gotestsum = prev.gotestsum.override { - buildGoModule = buildGo; - }; - - gotests = prev.gotests.override { - buildGoModule = buildGo; - }; - - gofumpt = prev.gofumpt.override { - buildGoModule = buildGo; - }; - - # gopls = prev.gopls.override { - # buildGoModule = buildGo; - # }; - }; } // flake-utils.lib.eachDefaultSystem - (system: let - pkgs = import nixpkgs { - overlays = [self.overlay]; - inherit system; - }; - buildDeps = with pkgs; [git go_1_25 gnumake]; - devDeps = with pkgs; - buildDeps - ++ [ - golangci-lint - golangci-lint-langserver - golines - nodePackages.prettier - goreleaser - nfpm - gotestsum - gotests - gofumpt - gopls - ksh - ko - yq-go - ripgrep - postgresql - - # 'dot' is needed for pprof graphs - # go tool pprof -http=: - graphviz - - # Protobuf dependencies - protobuf - protoc-gen-go - protoc-gen-go-grpc - protoc-gen-grpc-gateway - buf - clang-tools # clang-format - protobuf-language-server - - # Add hi to make it even easier to use ci runner. - hi - ] - ++ lib.optional pkgs.stdenv.isLinux [traceroute]; - - # Add entry to build a docker image with headscale - # caveat: only works on Linux - # - # Usage: - # nix build .#headscale-docker - # docker load < result - headscale-docker = pkgs.dockerTools.buildLayeredImage { - name = "headscale"; - tag = headscaleVersion; - contents = [pkgs.headscale]; - config.Entrypoint = [(pkgs.headscale + "/bin/headscale")]; - }; - in rec { - # `nix develop` - devShell = pkgs.mkShell { - buildInputs = - devDeps + (system: + let + pkgs = import nixpkgs { + overlays = [ self.overlay ]; + inherit system; + }; + buildDeps = with pkgs; [ git go_1_25 gnumake ]; + devDeps = with pkgs; + buildDeps ++ [ - (pkgs.writeShellScriptBin - "nix-vendor-sri" - '' - set -eu + golangci-lint + golangci-lint-langserver + golines + nodePackages.prettier + nixpkgs-fmt + goreleaser + nfpm + gotestsum + gotests + gofumpt + gopls + ksh + ko + yq-go + ripgrep + postgresql + prek - OUT=$(mktemp -d -t nar-hash-XXXXXX) - rm -rf "$OUT" + # 'dot' is needed for pprof graphs + # go tool pprof -http=: + graphviz - go mod vendor -o "$OUT" - go run tailscale.com/cmd/nardump --sri "$OUT" - rm -rf "$OUT" - '') + # Protobuf dependencies + protobuf + protoc-gen-go + protoc-gen-go-grpc + protoc-gen-grpc-gateway + buf + clang-tools # clang-format + protobuf-language-server - (pkgs.writeShellScriptBin - "go-mod-update-all" - '' - cat go.mod | ${pkgs.silver-searcher}/bin/ag "\t" | ${pkgs.silver-searcher}/bin/ag -v indirect | ${pkgs.gawk}/bin/awk '{print $1}' | ${pkgs.findutils}/bin/xargs go get -u - go mod tidy - '') - ]; + # Add hi to make it even easier to use ci runner. + hi + ] + ++ lib.optional pkgs.stdenv.isLinux [ traceroute ]; - shellHook = '' - export PATH="$PWD/result/bin:$PATH" - ''; - }; + # Add entry to build a docker image with headscale + # caveat: only works on Linux + # + # Usage: + # nix build .#headscale-docker + # docker load < result + headscale-docker = pkgs.dockerTools.buildLayeredImage { + name = "headscale"; + tag = headscaleVersion; + contents = [ pkgs.headscale ]; + config.Entrypoint = [ (pkgs.headscale + "/bin/headscale") ]; + }; + in + rec { + # `nix develop` + devShell = pkgs.mkShell { + buildInputs = + devDeps + ++ [ + (pkgs.writeShellScriptBin + "nix-vendor-sri" + '' + set -eu - # `nix build` - packages = with pkgs; { - inherit headscale; - inherit headscale-docker; - }; - defaultPackage = pkgs.headscale; + OUT=$(mktemp -d -t nar-hash-XXXXXX) + rm -rf "$OUT" - # `nix run` - apps.headscale = flake-utils.lib.mkApp { - drv = packages.headscale; - }; - apps.default = apps.headscale; + go mod vendor -o "$OUT" + go run tailscale.com/cmd/nardump --sri "$OUT" + rm -rf "$OUT" + '') - checks = { - format = - pkgs.runCommand "check-format" - { - buildInputs = with pkgs; [ - gnumake - nixpkgs-fmt - golangci-lint - nodePackages.prettier - golines - clang-tools + (pkgs.writeShellScriptBin + "go-mod-update-all" + '' + cat go.mod | ${pkgs.silver-searcher}/bin/ag "\t" | ${pkgs.silver-searcher}/bin/ag -v indirect | ${pkgs.gawk}/bin/awk '{print $1}' | ${pkgs.findutils}/bin/xargs go get -u + go mod tidy + '') ]; - } '' - ${pkgs.nixpkgs-fmt}/bin/nixpkgs-fmt ${./.} - ${pkgs.golangci-lint}/bin/golangci-lint run --fix --timeout 10m - ${pkgs.nodePackages.prettier}/bin/prettier --write '**/**.{ts,js,md,yaml,yml,sass,css,scss,html}' - ${pkgs.golines}/bin/golines --max-len=88 --base-formatter=gofumpt -w ${./.} - ${pkgs.clang-tools}/bin/clang-format -i ${./.} + + shellHook = '' + export PATH="$PWD/result/bin:$PATH" ''; - }; - }); + }; + + # `nix build` + packages = with pkgs; { + inherit headscale; + inherit headscale-docker; + }; + defaultPackage = pkgs.headscale; + + # `nix run` + apps.headscale = flake-utils.lib.mkApp { + drv = packages.headscale; + }; + apps.default = apps.headscale; + + checks = { + format = + pkgs.runCommand "check-format" + { + buildInputs = with pkgs; [ + gnumake + nixpkgs-fmt + golangci-lint + nodePackages.prettier + golines + clang-tools + ]; + } '' + ${pkgs.nixpkgs-fmt}/bin/nixpkgs-fmt ${./.} + ${pkgs.golangci-lint}/bin/golangci-lint run --fix --timeout 10m + ${pkgs.nodePackages.prettier}/bin/prettier --write '**/**.{ts,js,md,yaml,yml,sass,css,scss,html}' + ${pkgs.golines}/bin/golines --max-len=88 --base-formatter=gofumpt -w ${./.} + ${pkgs.clang-tools}/bin/clang-format -i ${./.} + ''; + }; + }); } diff --git a/integration/auth_key_test.go b/integration/auth_key_test.go index 75106dc5..12a5bf67 100644 --- a/integration/auth_key_test.go +++ b/integration/auth_key_test.go @@ -455,4 +455,3 @@ func TestAuthKeyLogoutAndReloginSameUserExpiredKey(t *testing.T) { }) } } - From 299cef4e996fbaced5408883b63a794133112e49 Mon Sep 17 00:00:00 2001 From: Andrey Bobelev Date: Tue, 28 Oct 2025 11:19:14 +0200 Subject: [PATCH 032/178] fix: free ips from usedIps ipset on DeleteNode --- hscontrol/db/ip.go | 9 +++++++++ hscontrol/state/state.go | 2 ++ 2 files changed, 11 insertions(+) diff --git a/hscontrol/db/ip.go b/hscontrol/db/ip.go index 244bb3db..972d8e72 100644 --- a/hscontrol/db/ip.go +++ b/hscontrol/db/ip.go @@ -341,3 +341,12 @@ func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) { return ret, err } + +func (i *IPAllocator) FreeIPs(ips []netip.Addr) { + i.mu.Lock() + defer i.mu.Unlock() + + for _, ip := range ips { + i.usedIPs.Remove(ip) + } +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 297004fc..6ef11f54 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -429,6 +429,8 @@ func (s *State) DeleteNode(node types.NodeView) (change.ChangeSet, error) { return change.EmptySet, err } + s.ipAlloc.FreeIPs(node.IPs()) + c := change.NodeRemoved(node.ID()) // Check if policy manager needs updating after node deletion From 1dcb04ce9b2f98f5aaa8fd361d4e3d9abd682027 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 11 Nov 2025 22:00:39 +0100 Subject: [PATCH 033/178] changelog: add changelog entry Signed-off-by: Kristoffer Dalby --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7669dfcb..b25c80ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Changes +- Reclaim IPs from the IP allocator when nodes are deleted + [#2831](https://github.com/juanfont/headscale/pull/2831) + ## 0.27.1 (2025-11-11) **Minimum supported Tailscale client version: v1.64.0** From 218a8db1b901cc6c7b777c5855c01b15154d932e Mon Sep 17 00:00:00 2001 From: Teej <107083710+TeejMcSteez@users.noreply.github.com> Date: Tue, 11 Nov 2025 22:46:57 -0500 Subject: [PATCH 034/178] add favicon to webpages (#2858) Co-authored-by: TeejMcSteez Co-authored-by: Kristoffer Dalby --- CHANGELOG.md | 3 ++- hscontrol/app.go | 4 ++-- hscontrol/assets/favicon.png | Bin 0 -> 22340 bytes hscontrol/handlers.go | 33 ++++++++++++++++++++++++++++++--- hscontrol/templates/general.go | 4 ++++ swagger.go | 5 ++++- 6 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 hscontrol/assets/favicon.png diff --git a/CHANGELOG.md b/CHANGELOG.md index b25c80ee..9129c526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Changes +- Add favicon to webpages + [#2858](https://github.com/juanfont/headscale/pull/2858) - Reclaim IPs from the IP allocator when nodes are deleted [#2831](https://github.com/juanfont/headscale/pull/2831) @@ -138,7 +140,6 @@ the code base over time and make it more correct and efficient. starting/ending with hyphen are rejected ### Changes - - **Database schema migration improvements for SQLite** [#2617](https://github.com/juanfont/headscale/pull/2617) - **IMPORTANT: Backup your SQLite database before upgrading** diff --git a/hscontrol/app.go b/hscontrol/app.go index eb5528ba..4ce98719 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -476,8 +476,8 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *mux.Router { apiRouter := router.PathPrefix("/api").Subrouter() apiRouter.Use(h.httpAuthenticationMiddleware) apiRouter.PathPrefix("/v1/").HandlerFunc(grpcMux.ServeHTTP) - - router.PathPrefix("/").HandlerFunc(notFoundHandler) + router.HandleFunc("/favicon.ico", FaviconHandler) + router.PathPrefix("/").HandlerFunc(BlankHandler) return router } diff --git a/hscontrol/assets/favicon.png b/hscontrol/assets/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..4989810faa546554d0e8a494ce8b980816c46b54 GIT binary patch literal 22340 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 H^o;*2r@Ea? literal 0 HcmV?d00001 diff --git a/hscontrol/handlers.go b/hscontrol/handlers.go index 0cc5bd36..d52b4d50 100644 --- a/hscontrol/handlers.go +++ b/hscontrol/handlers.go @@ -1,6 +1,8 @@ package hscontrol import ( + "bytes" + _ "embed" "encoding/json" "errors" "fmt" @@ -8,6 +10,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/chasefleming/elem-go/styles" "github.com/gorilla/mux" @@ -98,6 +101,7 @@ func (h *Headscale) handleVerifyRequest( // Check if any node has the requested NodeKey var nodeKeyFound bool + for _, node := range nodes.All() { if node.NodeKey() == derpAdmitClientRequest.NodePublic { nodeKeyFound = true @@ -128,6 +132,7 @@ func (h *Headscale) VerifyHandler( httpError(writer, err) return } + writer.Header().Set("Content-Type", "application/json") } @@ -149,6 +154,7 @@ func (h *Headscale) KeyHandler( resp := tailcfg.OverTLSPublicKeyResponse{ PublicKey: h.noisePrivateKey.Public(), } + writer.Header().Set("Content-Type", "application/json") json.NewEncoder(writer).Encode(resp) @@ -171,13 +177,14 @@ func (h *Headscale) HealthHandler( if err != nil { writer.WriteHeader(http.StatusInternalServerError) + res.Status = "fail" } json.NewEncoder(writer).Encode(res) } - - if err := h.state.PingDB(req.Context()); err != nil { + err := h.state.PingDB(req.Context()) + if err != nil { respond(err) return @@ -192,6 +199,7 @@ func (h *Headscale) RobotsHandler( ) { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusOK) + _, err := writer.Write([]byte("User-agent: *\nDisallow: /")) if err != nil { log.Error(). @@ -211,7 +219,8 @@ func (h *Headscale) VersionHandler( writer.WriteHeader(http.StatusOK) versionInfo := types.GetVersionInfo() - if err := json.NewEncoder(writer).Encode(versionInfo); err != nil { + err := json.NewEncoder(writer).Encode(versionInfo) + if err != nil { log.Error(). Caller(). Err(err). @@ -268,3 +277,21 @@ func (a *AuthProviderWeb) RegisterHandler( writer.WriteHeader(http.StatusOK) writer.Write([]byte(templates.RegisterWeb(registrationId).Render())) } + +//go:embed assets/favicon.png +var favicon []byte + +func FaviconHandler(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "image/png") + http.ServeContent(writer, req, "favicon.ico", time.Unix(0, 0), bytes.NewReader(favicon)) +} + +// Returns a blank page with favicon linked. +func BlankHandler(writer http.ResponseWriter, res *http.Request) { + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + io.WriteString(writer, ` + + + + `) +} diff --git a/hscontrol/templates/general.go b/hscontrol/templates/general.go index 3728b736..6e2af390 100644 --- a/hscontrol/templates/general.go +++ b/hscontrol/templates/general.go @@ -49,6 +49,10 @@ func HtmlStructure(head, body *elem.Element) *elem.Element { attrs.Name: "viewport", attrs.Content: "width=device-width, initial-scale=1.0", }), + elem.Link(attrs.Props{ + attrs.Rel: "icon", + attrs.Href: "/favicon.ico", + }), head, ), body, diff --git a/swagger.go b/swagger.go index 306fc1f6..fa764568 100644 --- a/swagger.go +++ b/swagger.go @@ -20,7 +20,7 @@ func SwaggerUI( - + @@ -57,6 +57,7 @@ func SwaggerUI( writer.Header().Set("Content-Type", "text/plain; charset=utf-8") writer.WriteHeader(http.StatusInternalServerError) + _, err := writer.Write([]byte("Could not render Swagger")) if err != nil { log.Error(). @@ -70,6 +71,7 @@ func SwaggerUI( writer.Header().Set("Content-Type", "text/html; charset=utf-8") writer.WriteHeader(http.StatusOK) + _, err := writer.Write(payload.Bytes()) if err != nil { log.Error(). @@ -85,6 +87,7 @@ func SwaggerAPIv1( ) { writer.Header().Set("Content-Type", "application/json; charset=utf-8") writer.WriteHeader(http.StatusOK) + if _, err := writer.Write(apiV1JSON); err != nil { log.Error(). Caller(). From 000d5c3b0c3f91761c326eddae93c404f2676dd1 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 12 Nov 2025 06:59:43 -0600 Subject: [PATCH 035/178] prettier: use standard config for all files including changelog (#2879) --- .pre-commit-config.yaml | 8 - CHANGELOG.md | 597 ++++++++++++++-------------------------- Makefile | 1 - 3 files changed, 200 insertions(+), 406 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4d98d4d3..77ffe299 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -57,14 +57,6 @@ repos: sass, markdown, ] - exclude: ^CHANGELOG\.md$ - - # Prettier for CHANGELOG.md with special formatting - - id: prettier-changelog - name: prettier-changelog - entry: prettier --write --print-width 80 --prose-wrap always - language: system - files: ^CHANGELOG\.md$ # golangci-lint for Go code quality - id: golangci-lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 9129c526..1e43192e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes +- Add NixOS module in repository for faster iteration [#2857](https://github.com/juanfont/headscale/pull/2857) - Add favicon to webpages [#2858](https://github.com/juanfont/headscale/pull/2858) - Reclaim IPs from the IP allocator when nodes are deleted @@ -15,31 +16,18 @@ ### Changes -- Expire nodes with a custom timestamp - [#2828](https://github.com/juanfont/headscale/pull/2828) -- Fix issue where node expiry was reset when tailscaled restarts - [#2875](https://github.com/juanfont/headscale/pull/2875) -- Fix OIDC authentication when multiple login URLs are opened - [#2861](https://github.com/juanfont/headscale/pull/2861) -- Fix node re-registration failing with expired auth keys - [#2859](https://github.com/juanfont/headscale/pull/2859) -- Remove old unused database tables and indices - [#2844](https://github.com/juanfont/headscale/pull/2844) - [#2872](https://github.com/juanfont/headscale/pull/2872) -- Ignore litestream tables during database validation - [#2843](https://github.com/juanfont/headscale/pull/2843) -- Fix exit node visibility to respect ACL rules - [#2855](https://github.com/juanfont/headscale/pull/2855) -- Fix SSH policy becoming empty when unknown user is referenced - [#2874](https://github.com/juanfont/headscale/pull/2874) -- Fix policy validation when using bypass-grpc mode - [#2854](https://github.com/juanfont/headscale/pull/2854) -- Fix autogroup:self interaction with other ACL rules - [#2842](https://github.com/juanfont/headscale/pull/2842) -- Fix flaky DERP map shuffle test - [#2848](https://github.com/juanfont/headscale/pull/2848) -- Use current stable base images for Debian and Alpine containers - [#2827](https://github.com/juanfont/headscale/pull/2827) +- Expire nodes with a custom timestamp [#2828](https://github.com/juanfont/headscale/pull/2828) +- Fix issue where node expiry was reset when tailscaled restarts [#2875](https://github.com/juanfont/headscale/pull/2875) +- Fix OIDC authentication when multiple login URLs are opened [#2861](https://github.com/juanfont/headscale/pull/2861) +- Fix node re-registration failing with expired auth keys [#2859](https://github.com/juanfont/headscale/pull/2859) +- Remove old unused database tables and indices [#2844](https://github.com/juanfont/headscale/pull/2844) [#2872](https://github.com/juanfont/headscale/pull/2872) +- Ignore litestream tables during database validation [#2843](https://github.com/juanfont/headscale/pull/2843) +- Fix exit node visibility to respect ACL rules [#2855](https://github.com/juanfont/headscale/pull/2855) +- Fix SSH policy becoming empty when unknown user is referenced [#2874](https://github.com/juanfont/headscale/pull/2874) +- Fix policy validation when using bypass-grpc mode [#2854](https://github.com/juanfont/headscale/pull/2854) +- Fix autogroup:self interaction with other ACL rules [#2842](https://github.com/juanfont/headscale/pull/2842) +- Fix flaky DERP map shuffle test [#2848](https://github.com/juanfont/headscale/pull/2848) +- Use current stable base images for Debian and Alpine containers [#2827](https://github.com/juanfont/headscale/pull/2827) ## 0.27.0 (2025-10-27) @@ -119,12 +107,9 @@ the code base over time and make it more correct and efficient. ### BREAKING -- Remove support for 32-bit binaries - [#2692](https://github.com/juanfont/headscale/pull/2692) -- Policy: Zero or empty destination port is no longer allowed - [#2606](https://github.com/juanfont/headscale/pull/2606) -- Stricter hostname validation - [#2383](https://github.com/juanfont/headscale/pull/2383) +- Remove support for 32-bit binaries [#2692](https://github.com/juanfont/headscale/pull/2692) +- Policy: Zero or empty destination port is no longer allowed [#2606](https://github.com/juanfont/headscale/pull/2606) +- Stricter hostname validation [#2383](https://github.com/juanfont/headscale/pull/2383) - Hostnames must be valid DNS labels (2-63 characters, alphanumeric and hyphens only, cannot start/end with hyphen) - **Client Registration (New Nodes)**: Invalid hostnames are automatically @@ -140,53 +125,39 @@ the code base over time and make it more correct and efficient. starting/ending with hyphen are rejected ### Changes -- **Database schema migration improvements for SQLite** - [#2617](https://github.com/juanfont/headscale/pull/2617) + +- **Database schema migration improvements for SQLite** [#2617](https://github.com/juanfont/headscale/pull/2617) - **IMPORTANT: Backup your SQLite database before upgrading** - Introduces safer table renaming migration strategy - Addresses longstanding database integrity issues -- Add flag to directly manipulate the policy in the database - [#2765](https://github.com/juanfont/headscale/pull/2765) -- DERPmap update frequency default changed from 24h to 3h - [#2741](https://github.com/juanfont/headscale/pull/2741) +- Add flag to directly manipulate the policy in the database [#2765](https://github.com/juanfont/headscale/pull/2765) +- DERPmap update frequency default changed from 24h to 3h [#2741](https://github.com/juanfont/headscale/pull/2741) - DERPmap update mechanism has been improved with retry, and is now failing conservatively, preserving the old map upon failure. [#2741](https://github.com/juanfont/headscale/pull/2741) -- Add support for `autogroup:member`, `autogroup:tagged` - [#2572](https://github.com/juanfont/headscale/pull/2572) -- Fix bug where return routes were being removed by policy - [#2767](https://github.com/juanfont/headscale/pull/2767) +- Add support for `autogroup:member`, `autogroup:tagged` [#2572](https://github.com/juanfont/headscale/pull/2572) +- Fix bug where return routes were being removed by policy [#2767](https://github.com/juanfont/headscale/pull/2767) - Remove policy v1 code [#2600](https://github.com/juanfont/headscale/pull/2600) -- Refactor Debian/Ubuntu packaging and drop support for Ubuntu 20.04. - [#2614](https://github.com/juanfont/headscale/pull/2614) -- Remove redundant check regarding `noise` config - [#2658](https://github.com/juanfont/headscale/pull/2658) -- Refactor OpenID Connect documentation - [#2625](https://github.com/juanfont/headscale/pull/2625) -- Don't crash if config file is missing - [#2656](https://github.com/juanfont/headscale/pull/2656) -- Adds `/robots.txt` endpoint to avoid crawlers - [#2643](https://github.com/juanfont/headscale/pull/2643) -- OIDC: Use group claim from UserInfo - [#2663](https://github.com/juanfont/headscale/pull/2663) +- Refactor Debian/Ubuntu packaging and drop support for Ubuntu 20.04. [#2614](https://github.com/juanfont/headscale/pull/2614) +- Remove redundant check regarding `noise` config [#2658](https://github.com/juanfont/headscale/pull/2658) +- Refactor OpenID Connect documentation [#2625](https://github.com/juanfont/headscale/pull/2625) +- Don't crash if config file is missing [#2656](https://github.com/juanfont/headscale/pull/2656) +- Adds `/robots.txt` endpoint to avoid crawlers [#2643](https://github.com/juanfont/headscale/pull/2643) +- OIDC: Use group claim from UserInfo [#2663](https://github.com/juanfont/headscale/pull/2663) - OIDC: Update user with claims from UserInfo _before_ comparing with allowed groups, email and domain [#2663](https://github.com/juanfont/headscale/pull/2663) - Policy will now reject invalid fields, making it easier to spot spelling errors [#2764](https://github.com/juanfont/headscale/pull/2764) -- Add FAQ entry on how to recover from an invalid policy in the database - [#2776](https://github.com/juanfont/headscale/pull/2776) -- EXPERIMENTAL: Add support for `autogroup:self` - [#2789](https://github.com/juanfont/headscale/pull/2789) -- Add healthcheck command - [#2659](https://github.com/juanfont/headscale/pull/2659) +- Add FAQ entry on how to recover from an invalid policy in the database [#2776](https://github.com/juanfont/headscale/pull/2776) +- EXPERIMENTAL: Add support for `autogroup:self` [#2789](https://github.com/juanfont/headscale/pull/2789) +- Add healthcheck command [#2659](https://github.com/juanfont/headscale/pull/2659) ## 0.26.1 (2025-06-06) ### Changes -- Ensure nodes are matching both node key and machine key when connecting. - [#2642](https://github.com/juanfont/headscale/pull/2642) +- Ensure nodes are matching both node key and machine key when connecting. [#2642](https://github.com/juanfont/headscale/pull/2642) ## 0.26.0 (2025-05-14) @@ -220,12 +191,9 @@ ID | Hostname | Approved | Available | Serving (Primary) Note that if an exit route is approved (0.0.0.0/0 or ::/0), both IPv4 and IPv6 will be approved. -- Route API and CLI has been removed - [#2422](https://github.com/juanfont/headscale/pull/2422) -- Routes are now managed via the Node API - [#2422](https://github.com/juanfont/headscale/pull/2422) -- Only routes accessible to the node will be sent to the node - [#2561](https://github.com/juanfont/headscale/pull/2561) +- Route API and CLI has been removed [#2422](https://github.com/juanfont/headscale/pull/2422) +- Routes are now managed via the Node API [#2422](https://github.com/juanfont/headscale/pull/2422) +- Only routes accessible to the node will be sent to the node [#2561](https://github.com/juanfont/headscale/pull/2561) #### Policy v2 @@ -297,12 +265,9 @@ working in v1 and not tested might be broken in v2 (and vice versa). #### Other breaking changes -- Disallow `server_url` and `base_domain` to be equal - [#2544](https://github.com/juanfont/headscale/pull/2544) -- Return full user in API for pre auth keys instead of string - [#2542](https://github.com/juanfont/headscale/pull/2542) -- Pre auth key API/CLI now uses ID over username - [#2542](https://github.com/juanfont/headscale/pull/2542) +- Disallow `server_url` and `base_domain` to be equal [#2544](https://github.com/juanfont/headscale/pull/2544) +- Return full user in API for pre auth keys instead of string [#2542](https://github.com/juanfont/headscale/pull/2542) +- Pre auth key API/CLI now uses ID over username [#2542](https://github.com/juanfont/headscale/pull/2542) - A non-empty list of global nameservers needs to be specified via `dns.nameservers.global` if the configuration option `dns.override_local_dns` is enabled or is not specified in the configuration file. This aligns with @@ -312,48 +277,37 @@ working in v1 and not tested might be broken in v2 (and vice versa). ### Changes - Use Go 1.24 [#2427](https://github.com/juanfont/headscale/pull/2427) -- Add `headscale policy check` command to check policy - [#2553](https://github.com/juanfont/headscale/pull/2553) -- `oidc.map_legacy_users` and `oidc.strip_email_domain` has been removed - [#2411](https://github.com/juanfont/headscale/pull/2411) -- Add more information to `/debug` endpoint - [#2420](https://github.com/juanfont/headscale/pull/2420) +- Add `headscale policy check` command to check policy [#2553](https://github.com/juanfont/headscale/pull/2553) +- `oidc.map_legacy_users` and `oidc.strip_email_domain` has been removed [#2411](https://github.com/juanfont/headscale/pull/2411) +- Add more information to `/debug` endpoint [#2420](https://github.com/juanfont/headscale/pull/2420) - It is now possible to inspect running goroutines and take profiles - View of config, policy, filter, ssh policy per node, connected nodes and DERPmap -- OIDC: Fetch UserInfo to get EmailVerified if necessary - [#2493](https://github.com/juanfont/headscale/pull/2493) +- OIDC: Fetch UserInfo to get EmailVerified if necessary [#2493](https://github.com/juanfont/headscale/pull/2493) - If a OIDC provider doesn't include the `email_verified` claim in its ID tokens, Headscale will attempt to get it from the UserInfo endpoint. -- OIDC: Try to populate name, email and username from UserInfo - [#2545](https://github.com/juanfont/headscale/pull/2545) +- OIDC: Try to populate name, email and username from UserInfo [#2545](https://github.com/juanfont/headscale/pull/2545) - Improve performance by only querying relevant nodes from the database for node updates [#2509](https://github.com/juanfont/headscale/pull/2509) - node FQDNs in the netmap will now contain a dot (".") at the end. This aligns with behaviour of tailscale.com [#2503](https://github.com/juanfont/headscale/pull/2503) -- Restore support for "Override local DNS" - [#2438](https://github.com/juanfont/headscale/pull/2438) -- Add documentation for routes - [#2496](https://github.com/juanfont/headscale/pull/2496) +- Restore support for "Override local DNS" [#2438](https://github.com/juanfont/headscale/pull/2438) +- Add documentation for routes [#2496](https://github.com/juanfont/headscale/pull/2496) ## 0.25.1 (2025-02-25) ### Changes -- Fix issue where registration errors are sent correctly - [#2435](https://github.com/juanfont/headscale/pull/2435) -- Fix issue where routes passed on registration were not saved - [#2444](https://github.com/juanfont/headscale/pull/2444) -- Fix issue where registration page was displayed twice - [#2445](https://github.com/juanfont/headscale/pull/2445) +- Fix issue where registration errors are sent correctly [#2435](https://github.com/juanfont/headscale/pull/2435) +- Fix issue where routes passed on registration were not saved [#2444](https://github.com/juanfont/headscale/pull/2444) +- Fix issue where registration page was displayed twice [#2445](https://github.com/juanfont/headscale/pull/2445) ## 0.25.0 (2025-02-11) ### BREAKING -- Authentication flow has been rewritten - [#2374](https://github.com/juanfont/headscale/pull/2374) This change should be +- Authentication flow has been rewritten [#2374](https://github.com/juanfont/headscale/pull/2374) This change should be transparent to users with the exception of some buxfixes that has been discovered and was fixed as part of the rewrite. - When a node is registered with _a new user_, it will be registered as a new @@ -361,62 +315,44 @@ working in v1 and not tested might be broken in v2 (and vice versa). [#1310](https://github.com/juanfont/headscale/issues/1310)). - A logged out node logging in with the same user will replace the existing node. -- Remove support for Tailscale clients older than 1.62 (Capability version 87) - [#2405](https://github.com/juanfont/headscale/pull/2405) +- Remove support for Tailscale clients older than 1.62 (Capability version 87) [#2405](https://github.com/juanfont/headscale/pull/2405) ### Changes -- `oidc.map_legacy_users` is now `false` by default - [#2350](https://github.com/juanfont/headscale/pull/2350) -- Print Tailscale version instead of capability versions for outdated nodes - [#2391](https://github.com/juanfont/headscale/pull/2391) -- Do not allow renaming of users from OIDC - [#2393](https://github.com/juanfont/headscale/pull/2393) -- Change minimum hostname length to 2 - [#2393](https://github.com/juanfont/headscale/pull/2393) -- Fix migration error caused by nodes having invalid auth keys - [#2412](https://github.com/juanfont/headscale/pull/2412) -- Pre auth keys belonging to a user are no longer deleted with the user - [#2396](https://github.com/juanfont/headscale/pull/2396) -- Pre auth keys that are used by a node can no longer be deleted - [#2396](https://github.com/juanfont/headscale/pull/2396) -- Rehaul HTTP errors, return better status code and errors to users - [#2398](https://github.com/juanfont/headscale/pull/2398) -- Print headscale version and commit on server startup - [#2415](https://github.com/juanfont/headscale/pull/2415) +- `oidc.map_legacy_users` is now `false` by default [#2350](https://github.com/juanfont/headscale/pull/2350) +- Print Tailscale version instead of capability versions for outdated nodes [#2391](https://github.com/juanfont/headscale/pull/2391) +- Do not allow renaming of users from OIDC [#2393](https://github.com/juanfont/headscale/pull/2393) +- Change minimum hostname length to 2 [#2393](https://github.com/juanfont/headscale/pull/2393) +- Fix migration error caused by nodes having invalid auth keys [#2412](https://github.com/juanfont/headscale/pull/2412) +- Pre auth keys belonging to a user are no longer deleted with the user [#2396](https://github.com/juanfont/headscale/pull/2396) +- Pre auth keys that are used by a node can no longer be deleted [#2396](https://github.com/juanfont/headscale/pull/2396) +- Rehaul HTTP errors, return better status code and errors to users [#2398](https://github.com/juanfont/headscale/pull/2398) +- Print headscale version and commit on server startup [#2415](https://github.com/juanfont/headscale/pull/2415) ## 0.24.3 (2025-02-07) ### Changes -- Fix migration error caused by nodes having invalid auth keys - [#2412](https://github.com/juanfont/headscale/pull/2412) -- Pre auth keys belonging to a user are no longer deleted with the user - [#2396](https://github.com/juanfont/headscale/pull/2396) -- Pre auth keys that are used by a node can no longer be deleted - [#2396](https://github.com/juanfont/headscale/pull/2396) +- Fix migration error caused by nodes having invalid auth keys [#2412](https://github.com/juanfont/headscale/pull/2412) +- Pre auth keys belonging to a user are no longer deleted with the user [#2396](https://github.com/juanfont/headscale/pull/2396) +- Pre auth keys that are used by a node can no longer be deleted [#2396](https://github.com/juanfont/headscale/pull/2396) ## 0.24.2 (2025-01-30) ### Changes -- Fix issue where email and username being equal fails to match in Policy - [#2388](https://github.com/juanfont/headscale/pull/2388) -- Delete invalid routes before adding a NOT NULL constraint on node_id - [#2386](https://github.com/juanfont/headscale/pull/2386) +- Fix issue where email and username being equal fails to match in Policy [#2388](https://github.com/juanfont/headscale/pull/2388) +- Delete invalid routes before adding a NOT NULL constraint on node_id [#2386](https://github.com/juanfont/headscale/pull/2386) ## 0.24.1 (2025-01-23) ### Changes -- Fix migration issue with user table for PostgreSQL - [#2367](https://github.com/juanfont/headscale/pull/2367) -- Relax username validation to allow emails - [#2364](https://github.com/juanfont/headscale/pull/2364) +- Fix migration issue with user table for PostgreSQL [#2367](https://github.com/juanfont/headscale/pull/2367) +- Relax username validation to allow emails [#2364](https://github.com/juanfont/headscale/pull/2364) - Remove invalid routes and add stronger constraints for routes to avoid API panic [#2371](https://github.com/juanfont/headscale/pull/2371) -- Fix panic when `derp.update_frequency` is 0 - [#2368](https://github.com/juanfont/headscale/pull/2368) +- Fix panic when `derp.update_frequency` is 0 [#2368](https://github.com/juanfont/headscale/pull/2368) ## 0.24.0 (2025-01-17) @@ -553,12 +489,10 @@ This will also affect the way you ### BREAKING -- Remove `dns.use_username_in_magic_dns` configuration option - [#2020](https://github.com/juanfont/headscale/pull/2020), +- Remove `dns.use_username_in_magic_dns` configuration option [#2020](https://github.com/juanfont/headscale/pull/2020), [#2279](https://github.com/juanfont/headscale/pull/2279) - Having usernames in magic DNS is no longer possible. -- Remove versions older than 1.56 - [#2149](https://github.com/juanfont/headscale/pull/2149) +- Remove versions older than 1.56 [#2149](https://github.com/juanfont/headscale/pull/2149) - Clean up old code required by old versions - User gRPC/API [#2261](https://github.com/juanfont/headscale/pull/2261): - If you depend on a Headscale Web UI, you should wait with this update until @@ -571,27 +505,20 @@ This will also affect the way you - Improved compatibility of built-in DERP server with clients connecting over WebSocket [#2132](https://github.com/juanfont/headscale/pull/2132) -- Allow nodes to use SSH agent forwarding - [#2145](https://github.com/juanfont/headscale/pull/2145) -- Fixed processing of fields in post request in MoveNode rpc - [#2179](https://github.com/juanfont/headscale/pull/2179) +- Allow nodes to use SSH agent forwarding [#2145](https://github.com/juanfont/headscale/pull/2145) +- Fixed processing of fields in post request in MoveNode rpc [#2179](https://github.com/juanfont/headscale/pull/2179) - Added conversion of 'Hostname' to 'givenName' in a node with FQDN rules applied [#2198](https://github.com/juanfont/headscale/pull/2198) -- Fixed updating of hostname and givenName when it is updated in HostInfo - [#2199](https://github.com/juanfont/headscale/pull/2199) -- Fixed missing `stable-debug` container tag - [#2232](https://github.com/juanfont/headscale/pull/2232) +- Fixed updating of hostname and givenName when it is updated in HostInfo [#2199](https://github.com/juanfont/headscale/pull/2199) +- Fixed missing `stable-debug` container tag [#2232](https://github.com/juanfont/headscale/pull/2232) - Loosened up `server_url` and `base_domain` check. It was overly strict in some cases. [#2248](https://github.com/juanfont/headscale/pull/2248) - CLI for managing users now accepts `--identifier` in addition to `--name`, usage of `--identifier` is recommended [#2261](https://github.com/juanfont/headscale/pull/2261) -- Add `dns.extra_records_path` configuration option - [#2262](https://github.com/juanfont/headscale/issues/2262) -- Support client verify for DERP - [#2046](https://github.com/juanfont/headscale/pull/2046) -- Add PKCE Verifier for OIDC - [#2314](https://github.com/juanfont/headscale/pull/2314) +- Add `dns.extra_records_path` configuration option [#2262](https://github.com/juanfont/headscale/issues/2262) +- Support client verify for DERP [#2046](https://github.com/juanfont/headscale/pull/2046) +- Add PKCE Verifier for OIDC [#2314](https://github.com/juanfont/headscale/pull/2314) ## 0.23.0 (2024-09-18) @@ -655,28 +582,22 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460). - Old structure has been remove and the configuration _must_ be converted. - Adds additional configuration for PostgreSQL for setting max open, idle connection and idle connection lifetime. -- API: Machine is now Node - [#1553](https://github.com/juanfont/headscale/pull/1553) -- Remove support for older Tailscale clients - [#1611](https://github.com/juanfont/headscale/pull/1611) +- API: Machine is now Node [#1553](https://github.com/juanfont/headscale/pull/1553) +- Remove support for older Tailscale clients [#1611](https://github.com/juanfont/headscale/pull/1611) - The oldest supported client is 1.42 -- Headscale checks that _at least_ one DERP is defined at start - [#1564](https://github.com/juanfont/headscale/pull/1564) +- Headscale checks that _at least_ one DERP is defined at start [#1564](https://github.com/juanfont/headscale/pull/1564) - If no DERP is configured, the server will fail to start, this can be because it cannot load the DERPMap from file or url. -- Embedded DERP server requires a private key - [#1611](https://github.com/juanfont/headscale/pull/1611) +- Embedded DERP server requires a private key [#1611](https://github.com/juanfont/headscale/pull/1611) - Add a filepath entry to [`derp.server.private_key_path`](https://github.com/juanfont/headscale/blob/b35993981297e18393706b2c963d6db882bba6aa/config-example.yaml#L95) -- Docker images are now built with goreleaser (ko) - [#1716](https://github.com/juanfont/headscale/pull/1716) +- Docker images are now built with goreleaser (ko) [#1716](https://github.com/juanfont/headscale/pull/1716) [#1763](https://github.com/juanfont/headscale/pull/1763) - Entrypoint of container image has changed from shell to headscale, require change from `headscale serve` to `serve` - `/var/lib/headscale` and `/var/run/headscale` is no longer created automatically, see [container docs](./docs/setup/install/container.md) -- Prefixes are now defined per v4 and v6 range. - [#1756](https://github.com/juanfont/headscale/pull/1756) +- Prefixes are now defined per v4 and v6 range. [#1756](https://github.com/juanfont/headscale/pull/1756) - `ip_prefixes` option is now `prefixes.v4` and `prefixes.v6` - `prefixes.allocation` can be set to assign IPs at `sequential` or `random`. [#1869](https://github.com/juanfont/headscale/pull/1869) @@ -691,30 +612,23 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460). note that this option _will be removed_ when tags are fixed. - dns.base_domain can no longer be the same as (or part of) server_url. - This option brings Headscales behaviour in line with Tailscale. -- YAML files are no longer supported for headscale policy. - [#1792](https://github.com/juanfont/headscale/pull/1792) +- YAML files are no longer supported for headscale policy. [#1792](https://github.com/juanfont/headscale/pull/1792) - HuJSON is now the only supported format for policy. -- DNS configuration has been restructured - [#2034](https://github.com/juanfont/headscale/pull/2034) +- DNS configuration has been restructured [#2034](https://github.com/juanfont/headscale/pull/2034) - Please review the new [config-example.yaml](./config-example.yaml) for the new structure. ### Changes -- Use versioned migrations - [#1644](https://github.com/juanfont/headscale/pull/1644) -- Make the OIDC callback page better - [#1484](https://github.com/juanfont/headscale/pull/1484) +- Use versioned migrations [#1644](https://github.com/juanfont/headscale/pull/1644) +- Make the OIDC callback page better [#1484](https://github.com/juanfont/headscale/pull/1484) - SSH support [#1487](https://github.com/juanfont/headscale/pull/1487) -- State management has been improved - [#1492](https://github.com/juanfont/headscale/pull/1492) -- Use error group handling to ensure tests actually pass - [#1535](https://github.com/juanfont/headscale/pull/1535) based on +- State management has been improved [#1492](https://github.com/juanfont/headscale/pull/1492) +- Use error group handling to ensure tests actually pass [#1535](https://github.com/juanfont/headscale/pull/1535) based on [#1460](https://github.com/juanfont/headscale/pull/1460) - Fix hang on SIGTERM [#1492](https://github.com/juanfont/headscale/pull/1492) taken from [#1480](https://github.com/juanfont/headscale/pull/1480) -- Send logs to stderr by default - [#1524](https://github.com/juanfont/headscale/pull/1524) +- Send logs to stderr by default [#1524](https://github.com/juanfont/headscale/pull/1524) - Fix [TS-2023-006](https://tailscale.com/security-bulletins/#ts-2023-006) security UPnP issue [#1563](https://github.com/juanfont/headscale/pull/1563) - Turn off gRPC logging [#1640](https://github.com/juanfont/headscale/pull/1640) @@ -722,21 +636,15 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460). - Added the possibility to manually create a DERP-map entry which can be customized, instead of automatically creating it. [#1565](https://github.com/juanfont/headscale/pull/1565) -- Add support for deleting api keys - [#1702](https://github.com/juanfont/headscale/pull/1702) +- Add support for deleting api keys [#1702](https://github.com/juanfont/headscale/pull/1702) - Add command to backfill IP addresses for nodes missing IPs from configured prefixes. [#1869](https://github.com/juanfont/headscale/pull/1869) -- Log available update as warning - [#1877](https://github.com/juanfont/headscale/pull/1877) -- Add `autogroup:internet` to Policy - [#1917](https://github.com/juanfont/headscale/pull/1917) -- Restore foreign keys and add constraints - [#1562](https://github.com/juanfont/headscale/pull/1562) +- Log available update as warning [#1877](https://github.com/juanfont/headscale/pull/1877) +- Add `autogroup:internet` to Policy [#1917](https://github.com/juanfont/headscale/pull/1917) +- Restore foreign keys and add constraints [#1562](https://github.com/juanfont/headscale/pull/1562) - Make registration page easier to use on mobile devices -- Make write-ahead-log default on and configurable for SQLite - [#1985](https://github.com/juanfont/headscale/pull/1985) -- Add APIs for managing headscale policy. - [#1792](https://github.com/juanfont/headscale/pull/1792) +- Make write-ahead-log default on and configurable for SQLite [#1985](https://github.com/juanfont/headscale/pull/1985) +- Add APIs for managing headscale policy. [#1792](https://github.com/juanfont/headscale/pull/1792) - Fix for registering nodes using preauthkeys when running on a postgres database in a non-UTC timezone. [#764](https://github.com/juanfont/headscale/issues/764) @@ -744,33 +652,25 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460). - CLI commands (all except `serve`) only requires minimal configuration, no more errors or warnings from unset settings [#2109](https://github.com/juanfont/headscale/pull/2109) -- CLI results are now concistently sent to stdout and errors to stderr - [#2109](https://github.com/juanfont/headscale/pull/2109) -- Fix issue where shutting down headscale would hang - [#2113](https://github.com/juanfont/headscale/pull/2113) +- CLI results are now concistently sent to stdout and errors to stderr [#2109](https://github.com/juanfont/headscale/pull/2109) +- Fix issue where shutting down headscale would hang [#2113](https://github.com/juanfont/headscale/pull/2113) ## 0.22.3 (2023-05-12) ### Changes -- Added missing ca-certificates in Docker image - [#1463](https://github.com/juanfont/headscale/pull/1463) +- Added missing ca-certificates in Docker image [#1463](https://github.com/juanfont/headscale/pull/1463) ## 0.22.2 (2023-05-10) ### Changes -- Add environment flags to enable pprof (profiling) - [#1382](https://github.com/juanfont/headscale/pull/1382) +- Add environment flags to enable pprof (profiling) [#1382](https://github.com/juanfont/headscale/pull/1382) - Profiles are continuously generated in our integration tests. -- Fix systemd service file location in `.deb` packages - [#1391](https://github.com/juanfont/headscale/pull/1391) -- Improvements on Noise implementation - [#1379](https://github.com/juanfont/headscale/pull/1379) -- Replace node filter logic, ensuring nodes with access can see each other - [#1381](https://github.com/juanfont/headscale/pull/1381) -- Disable (or delete) both exit routes at the same time - [#1428](https://github.com/juanfont/headscale/pull/1428) +- Fix systemd service file location in `.deb` packages [#1391](https://github.com/juanfont/headscale/pull/1391) +- Improvements on Noise implementation [#1379](https://github.com/juanfont/headscale/pull/1379) +- Replace node filter logic, ensuring nodes with access can see each other [#1381](https://github.com/juanfont/headscale/pull/1381) +- Disable (or delete) both exit routes at the same time [#1428](https://github.com/juanfont/headscale/pull/1428) - Ditch distroless for Docker image, create default socket dir in `/var/run/headscale` [#1450](https://github.com/juanfont/headscale/pull/1450) @@ -778,65 +678,49 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460). ### Changes -- Fix issue where systemd could not bind to port 80 - [#1365](https://github.com/juanfont/headscale/pull/1365) +- Fix issue where systemd could not bind to port 80 [#1365](https://github.com/juanfont/headscale/pull/1365) ## 0.22.0 (2023-04-20) ### Changes -- Add `.deb` packages to release process - [#1297](https://github.com/juanfont/headscale/pull/1297) -- Update and simplify the documentation to use new `.deb` packages - [#1349](https://github.com/juanfont/headscale/pull/1349) -- Add 32-bit Arm platforms to release process - [#1297](https://github.com/juanfont/headscale/pull/1297) +- Add `.deb` packages to release process [#1297](https://github.com/juanfont/headscale/pull/1297) +- Update and simplify the documentation to use new `.deb` packages [#1349](https://github.com/juanfont/headscale/pull/1349) +- Add 32-bit Arm platforms to release process [#1297](https://github.com/juanfont/headscale/pull/1297) - Fix longstanding bug that would prevent "\*" from working properly in ACLs (issue [#699](https://github.com/juanfont/headscale/issues/699)) [#1279](https://github.com/juanfont/headscale/pull/1279) -- Fix issue where IPv6 could not be used in, or while using ACLs (part of - [#809](https://github.com/juanfont/headscale/issues/809)) +- Fix issue where IPv6 could not be used in, or while using ACLs (part of [#809](https://github.com/juanfont/headscale/issues/809)) [#1339](https://github.com/juanfont/headscale/pull/1339) -- Target Go 1.20 and Tailscale 1.38 for Headscale - [#1323](https://github.com/juanfont/headscale/pull/1323) +- Target Go 1.20 and Tailscale 1.38 for Headscale [#1323](https://github.com/juanfont/headscale/pull/1323) ## 0.21.0 (2023-03-20) ### Changes -- Adding "configtest" CLI command. - [#1230](https://github.com/juanfont/headscale/pull/1230) -- Add documentation on connecting with iOS to `/apple` - [#1261](https://github.com/juanfont/headscale/pull/1261) -- Update iOS compatibility and added documentation for iOS - [#1264](https://github.com/juanfont/headscale/pull/1264) -- Allow to delete routes - [#1244](https://github.com/juanfont/headscale/pull/1244) +- Adding "configtest" CLI command. [#1230](https://github.com/juanfont/headscale/pull/1230) +- Add documentation on connecting with iOS to `/apple` [#1261](https://github.com/juanfont/headscale/pull/1261) +- Update iOS compatibility and added documentation for iOS [#1264](https://github.com/juanfont/headscale/pull/1264) +- Allow to delete routes [#1244](https://github.com/juanfont/headscale/pull/1244) ## 0.20.0 (2023-02-03) ### Changes -- Fix wrong behaviour in exit nodes - [#1159](https://github.com/juanfont/headscale/pull/1159) -- Align behaviour of `dns_config.restricted_nameservers` to tailscale - [#1162](https://github.com/juanfont/headscale/pull/1162) -- Make OpenID Connect authenticated client expiry time configurable - [#1191](https://github.com/juanfont/headscale/pull/1191) +- Fix wrong behaviour in exit nodes [#1159](https://github.com/juanfont/headscale/pull/1159) +- Align behaviour of `dns_config.restricted_nameservers` to tailscale [#1162](https://github.com/juanfont/headscale/pull/1162) +- Make OpenID Connect authenticated client expiry time configurable [#1191](https://github.com/juanfont/headscale/pull/1191) - defaults to 180 days like Tailscale SaaS - adds option to use the expiry time from the OpenID token for the node (see config-example.yaml) -- Set ControlTime in Map info sent to nodes - [#1195](https://github.com/juanfont/headscale/pull/1195) -- Populate Tags field on Node updates sent - [#1195](https://github.com/juanfont/headscale/pull/1195) +- Set ControlTime in Map info sent to nodes [#1195](https://github.com/juanfont/headscale/pull/1195) +- Populate Tags field on Node updates sent [#1195](https://github.com/juanfont/headscale/pull/1195) ## 0.19.0 (2023-01-29) ### BREAKING -- Rename Namespace to User - [#1144](https://github.com/juanfont/headscale/pull/1144) +- Rename Namespace to User [#1144](https://github.com/juanfont/headscale/pull/1144) - **BACKUP your database before upgrading** - Command line flags previously taking `--namespace` or `-n` will now require `--user` or `-u` @@ -845,35 +729,23 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460). ### Changes -- Reworked routing and added support for subnet router failover - [#1024](https://github.com/juanfont/headscale/pull/1024) -- Added an OIDC AllowGroups Configuration options and authorization check - [#1041](https://github.com/juanfont/headscale/pull/1041) -- Set `db_ssl` to false by default - [#1052](https://github.com/juanfont/headscale/pull/1052) -- Fix duplicate nodes due to incorrect implementation of the protocol - [#1058](https://github.com/juanfont/headscale/pull/1058) -- Report if a machine is online in CLI more accurately - [#1062](https://github.com/juanfont/headscale/pull/1062) -- Added config option for custom DNS records - [#1035](https://github.com/juanfont/headscale/pull/1035) -- Expire nodes based on OIDC token expiry - [#1067](https://github.com/juanfont/headscale/pull/1067) -- Remove ephemeral nodes on logout - [#1098](https://github.com/juanfont/headscale/pull/1098) -- Performance improvements in ACLs - [#1129](https://github.com/juanfont/headscale/pull/1129) -- OIDC client secret can be passed via a file - [#1127](https://github.com/juanfont/headscale/pull/1127) +- Reworked routing and added support for subnet router failover [#1024](https://github.com/juanfont/headscale/pull/1024) +- Added an OIDC AllowGroups Configuration options and authorization check [#1041](https://github.com/juanfont/headscale/pull/1041) +- Set `db_ssl` to false by default [#1052](https://github.com/juanfont/headscale/pull/1052) +- Fix duplicate nodes due to incorrect implementation of the protocol [#1058](https://github.com/juanfont/headscale/pull/1058) +- Report if a machine is online in CLI more accurately [#1062](https://github.com/juanfont/headscale/pull/1062) +- Added config option for custom DNS records [#1035](https://github.com/juanfont/headscale/pull/1035) +- Expire nodes based on OIDC token expiry [#1067](https://github.com/juanfont/headscale/pull/1067) +- Remove ephemeral nodes on logout [#1098](https://github.com/juanfont/headscale/pull/1098) +- Performance improvements in ACLs [#1129](https://github.com/juanfont/headscale/pull/1129) +- OIDC client secret can be passed via a file [#1127](https://github.com/juanfont/headscale/pull/1127) ## 0.17.1 (2022-12-05) ### Changes -- Correct typo on macOS standalone profile link - [#1028](https://github.com/juanfont/headscale/pull/1028) -- Update platform docs with Fast User Switching - [#1016](https://github.com/juanfont/headscale/pull/1016) +- Correct typo on macOS standalone profile link [#1028](https://github.com/juanfont/headscale/pull/1028) +- Update platform docs with Fast User Switching [#1016](https://github.com/juanfont/headscale/pull/1016) ## 0.17.0 (2022-11-26) @@ -883,13 +755,11 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460). protocol. - Log level option `log_level` was moved to a distinct `log` config section and renamed to `level` [#768](https://github.com/juanfont/headscale/pull/768) -- Removed Alpine Linux container image - [#962](https://github.com/juanfont/headscale/pull/962) +- Removed Alpine Linux container image [#962](https://github.com/juanfont/headscale/pull/962) ### Important Changes -- Added support for Tailscale TS2021 protocol - [#738](https://github.com/juanfont/headscale/pull/738) +- Added support for Tailscale TS2021 protocol [#738](https://github.com/juanfont/headscale/pull/738) - Add experimental support for [SSH ACL](https://tailscale.com/kb/1018/acls/#tailscale-ssh) (see docs for limitations) [#847](https://github.com/juanfont/headscale/pull/847) @@ -909,81 +779,57 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460). ### Changes -- Add ability to specify config location via env var `HEADSCALE_CONFIG` - [#674](https://github.com/juanfont/headscale/issues/674) -- Target Go 1.19 for Headscale - [#778](https://github.com/juanfont/headscale/pull/778) -- Target Tailscale v1.30.0 to build Headscale - [#780](https://github.com/juanfont/headscale/pull/780) +- Add ability to specify config location via env var `HEADSCALE_CONFIG` [#674](https://github.com/juanfont/headscale/issues/674) +- Target Go 1.19 for Headscale [#778](https://github.com/juanfont/headscale/pull/778) +- Target Tailscale v1.30.0 to build Headscale [#780](https://github.com/juanfont/headscale/pull/780) - Give a warning when running Headscale with reverse proxy improperly configured for WebSockets [#788](https://github.com/juanfont/headscale/pull/788) -- Fix subnet routers with Primary Routes - [#811](https://github.com/juanfont/headscale/pull/811) -- Added support for JSON logs - [#653](https://github.com/juanfont/headscale/issues/653) -- Sanitise the node key passed to registration url - [#823](https://github.com/juanfont/headscale/pull/823) -- Add support for generating pre-auth keys with tags - [#767](https://github.com/juanfont/headscale/pull/767) +- Fix subnet routers with Primary Routes [#811](https://github.com/juanfont/headscale/pull/811) +- Added support for JSON logs [#653](https://github.com/juanfont/headscale/issues/653) +- Sanitise the node key passed to registration url [#823](https://github.com/juanfont/headscale/pull/823) +- Add support for generating pre-auth keys with tags [#767](https://github.com/juanfont/headscale/pull/767) - Add support for evaluating `autoApprovers` ACL entries when a machine is registered [#763](https://github.com/juanfont/headscale/pull/763) -- Add config flag to allow Headscale to start if OIDC provider is down - [#829](https://github.com/juanfont/headscale/pull/829) -- Fix prefix length comparison bug in AutoApprovers route evaluation - [#862](https://github.com/juanfont/headscale/pull/862) -- Random node DNS suffix only applied if names collide in namespace. - [#766](https://github.com/juanfont/headscale/issues/766) -- Remove `ip_prefix` configuration option and warning - [#899](https://github.com/juanfont/headscale/pull/899) -- Add `dns_config.override_local_dns` option - [#905](https://github.com/juanfont/headscale/pull/905) -- Fix some DNS config issues - [#660](https://github.com/juanfont/headscale/issues/660) -- Make it possible to disable TS2019 with build flag - [#928](https://github.com/juanfont/headscale/pull/928) -- Fix OIDC registration issues - [#960](https://github.com/juanfont/headscale/pull/960) and +- Add config flag to allow Headscale to start if OIDC provider is down [#829](https://github.com/juanfont/headscale/pull/829) +- Fix prefix length comparison bug in AutoApprovers route evaluation [#862](https://github.com/juanfont/headscale/pull/862) +- Random node DNS suffix only applied if names collide in namespace. [#766](https://github.com/juanfont/headscale/issues/766) +- Remove `ip_prefix` configuration option and warning [#899](https://github.com/juanfont/headscale/pull/899) +- Add `dns_config.override_local_dns` option [#905](https://github.com/juanfont/headscale/pull/905) +- Fix some DNS config issues [#660](https://github.com/juanfont/headscale/issues/660) +- Make it possible to disable TS2019 with build flag [#928](https://github.com/juanfont/headscale/pull/928) +- Fix OIDC registration issues [#960](https://github.com/juanfont/headscale/pull/960) and [#971](https://github.com/juanfont/headscale/pull/971) -- Add support for specifying NextDNS DNS-over-HTTPS resolver - [#940](https://github.com/juanfont/headscale/pull/940) -- Make more sslmode available for postgresql connection - [#927](https://github.com/juanfont/headscale/pull/927) +- Add support for specifying NextDNS DNS-over-HTTPS resolver [#940](https://github.com/juanfont/headscale/pull/940) +- Make more sslmode available for postgresql connection [#927](https://github.com/juanfont/headscale/pull/927) ## 0.16.4 (2022-08-21) ### Changes -- Add ability to connect to PostgreSQL over TLS/SSL - [#745](https://github.com/juanfont/headscale/pull/745) -- Fix CLI registration of expired machines - [#754](https://github.com/juanfont/headscale/pull/754) +- Add ability to connect to PostgreSQL over TLS/SSL [#745](https://github.com/juanfont/headscale/pull/745) +- Fix CLI registration of expired machines [#754](https://github.com/juanfont/headscale/pull/754) ## 0.16.3 (2022-08-17) ### Changes -- Fix issue with OIDC authentication - [#747](https://github.com/juanfont/headscale/pull/747) +- Fix issue with OIDC authentication [#747](https://github.com/juanfont/headscale/pull/747) ## 0.16.2 (2022-08-14) ### Changes -- Fixed bugs in the client registration process after migration to NodeKey - [#735](https://github.com/juanfont/headscale/pull/735) +- Fixed bugs in the client registration process after migration to NodeKey [#735](https://github.com/juanfont/headscale/pull/735) ## 0.16.1 (2022-08-12) ### Changes -- Updated dependencies (including the library that lacked armhf support) - [#722](https://github.com/juanfont/headscale/pull/722) -- Fix missing group expansion in function `excludeCorrectlyTaggedNodes` - [#563](https://github.com/juanfont/headscale/issues/563) +- Updated dependencies (including the library that lacked armhf support) [#722](https://github.com/juanfont/headscale/pull/722) +- Fix missing group expansion in function `excludeCorrectlyTaggedNodes` [#563](https://github.com/juanfont/headscale/issues/563) - Improve registration protocol implementation and switch to NodeKey as main identifier [#725](https://github.com/juanfont/headscale/pull/725) -- Add ability to connect to PostgreSQL via unix socket - [#734](https://github.com/juanfont/headscale/pull/734) +- Add ability to connect to PostgreSQL via unix socket [#734](https://github.com/juanfont/headscale/pull/734) ## 0.16.0 (2022-07-25) @@ -996,44 +842,30 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460). ### Changes -- **Drop** armhf (32-bit ARM) support. - [#609](https://github.com/juanfont/headscale/pull/609) -- Headscale fails to serve if the ACL policy file cannot be parsed - [#537](https://github.com/juanfont/headscale/pull/537) -- Fix labels cardinality error when registering unknown pre-auth key - [#519](https://github.com/juanfont/headscale/pull/519) -- Fix send on closed channel crash in polling - [#542](https://github.com/juanfont/headscale/pull/542) -- Fixed spurious calls to setLastStateChangeToNow from ephemeral nodes - [#566](https://github.com/juanfont/headscale/pull/566) -- Add command for moving nodes between namespaces - [#362](https://github.com/juanfont/headscale/issues/362) +- **Drop** armhf (32-bit ARM) support. [#609](https://github.com/juanfont/headscale/pull/609) +- Headscale fails to serve if the ACL policy file cannot be parsed [#537](https://github.com/juanfont/headscale/pull/537) +- Fix labels cardinality error when registering unknown pre-auth key [#519](https://github.com/juanfont/headscale/pull/519) +- Fix send on closed channel crash in polling [#542](https://github.com/juanfont/headscale/pull/542) +- Fixed spurious calls to setLastStateChangeToNow from ephemeral nodes [#566](https://github.com/juanfont/headscale/pull/566) +- Add command for moving nodes between namespaces [#362](https://github.com/juanfont/headscale/issues/362) - Added more configuration parameters for OpenID Connect (scopes, free-form parameters, domain and user allowlist) -- Add command to set tags on a node - [#525](https://github.com/juanfont/headscale/issues/525) -- Add command to view tags of nodes - [#356](https://github.com/juanfont/headscale/issues/356) -- Add --all (-a) flag to enable routes command - [#360](https://github.com/juanfont/headscale/issues/360) -- Fix issue where nodes was not updated across namespaces - [#560](https://github.com/juanfont/headscale/pull/560) -- Add the ability to rename a nodes name - [#560](https://github.com/juanfont/headscale/pull/560) +- Add command to set tags on a node [#525](https://github.com/juanfont/headscale/issues/525) +- Add command to view tags of nodes [#356](https://github.com/juanfont/headscale/issues/356) +- Add --all (-a) flag to enable routes command [#360](https://github.com/juanfont/headscale/issues/360) +- Fix issue where nodes was not updated across namespaces [#560](https://github.com/juanfont/headscale/pull/560) +- Add the ability to rename a nodes name [#560](https://github.com/juanfont/headscale/pull/560) - Node DNS names are now unique, a random suffix will be added when a node joins - This change contains database changes, remember to **backup** your database before upgrading -- Add option to enable/disable logtail (Tailscale's logging infrastructure) - [#596](https://github.com/juanfont/headscale/pull/596) +- Add option to enable/disable logtail (Tailscale's logging infrastructure) [#596](https://github.com/juanfont/headscale/pull/596) - This change disables the logs by default - Use [Prometheus]'s duration parser, supporting days (`d`), weeks (`w`) and years (`y`) [#598](https://github.com/juanfont/headscale/pull/598) -- Add support for reloading ACLs with SIGHUP - [#601](https://github.com/juanfont/headscale/pull/601) +- Add support for reloading ACLs with SIGHUP [#601](https://github.com/juanfont/headscale/pull/601) - Use new ACL syntax [#618](https://github.com/juanfont/headscale/pull/618) -- Add -c option to specify config file from command line - [#285](https://github.com/juanfont/headscale/issues/285) +- Add -c option to specify config file from command line [#285](https://github.com/juanfont/headscale/issues/285) [#612](https://github.com/juanfont/headscale/pull/601) - Add configuration option to allow Tailscale clients to use a random WireGuard port. [kb/1181/firewalls](https://tailscale.com/kb/1181/firewalls) @@ -1041,19 +873,14 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460). - Improve obtuse UX regarding missing configuration (`ephemeral_node_inactivity_timeout` not set) [#639](https://github.com/juanfont/headscale/pull/639) -- Fix nodes being shown as 'offline' in `tailscale status` - [#648](https://github.com/juanfont/headscale/pull/648) -- Improve shutdown behaviour - [#651](https://github.com/juanfont/headscale/pull/651) +- Fix nodes being shown as 'offline' in `tailscale status` [#648](https://github.com/juanfont/headscale/pull/648) +- Improve shutdown behaviour [#651](https://github.com/juanfont/headscale/pull/651) - Drop Gin as web framework in Headscale [648](https://github.com/juanfont/headscale/pull/648) [677](https://github.com/juanfont/headscale/pull/677) -- Make tailnet node updates check interval configurable - [#675](https://github.com/juanfont/headscale/pull/675) -- Fix regression with HTTP API - [#684](https://github.com/juanfont/headscale/pull/684) -- nodes ls now print both Hostname and Name(Issue - [#647](https://github.com/juanfont/headscale/issues/647) PR +- Make tailnet node updates check interval configurable [#675](https://github.com/juanfont/headscale/pull/675) +- Fix regression with HTTP API [#684](https://github.com/juanfont/headscale/pull/684) +- nodes ls now print both Hostname and Name(Issue [#647](https://github.com/juanfont/headscale/issues/647) PR [#687](https://github.com/juanfont/headscale/pull/687)) ## 0.15.0 (2022-03-20) @@ -1065,8 +892,7 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460). - Boundaries between Namespaces has been removed and all nodes can communicate by default [#357](https://github.com/juanfont/headscale/pull/357) - To limit access between nodes, use [ACLs](./docs/ref/acls.md). -- `/metrics` is now a configurable host:port endpoint: - [#344](https://github.com/juanfont/headscale/pull/344). You must update your +- `/metrics` is now a configurable host:port endpoint: [#344](https://github.com/juanfont/headscale/pull/344). You must update your `config.yaml` file to include: ```yaml metrics_listen_addr: 127.0.0.1:9090 @@ -1074,23 +900,18 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460). ### Features -- Add support for writing ACL files with YAML - [#359](https://github.com/juanfont/headscale/pull/359) -- Users can now use emails in ACL's groups - [#372](https://github.com/juanfont/headscale/issues/372) -- Add shorthand aliases for commands and subcommands - [#376](https://github.com/juanfont/headscale/pull/376) +- Add support for writing ACL files with YAML [#359](https://github.com/juanfont/headscale/pull/359) +- Users can now use emails in ACL's groups [#372](https://github.com/juanfont/headscale/issues/372) +- Add shorthand aliases for commands and subcommands [#376](https://github.com/juanfont/headscale/pull/376) - Add `/windows` endpoint for Windows configuration instructions + registry file download [#392](https://github.com/juanfont/headscale/pull/392) -- Added embedded DERP (and STUN) server into Headscale - [#388](https://github.com/juanfont/headscale/pull/388) +- Added embedded DERP (and STUN) server into Headscale [#388](https://github.com/juanfont/headscale/pull/388) ### Changes - Fix a bug were the same IP could be assigned to multiple hosts if joined in quick succession [#346](https://github.com/juanfont/headscale/pull/346) -- Simplify the code behind registration of machines - [#366](https://github.com/juanfont/headscale/pull/366) +- Simplify the code behind registration of machines [#366](https://github.com/juanfont/headscale/pull/366) - Nodes are now only written to database if they are registered successfully - Fix a limitation in the ACLs that prevented users to write rules with `*` as source [#374](https://github.com/juanfont/headscale/issues/374) @@ -1099,8 +920,7 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460). [#371](https://github.com/juanfont/headscale/pull/371) - Apply normalization function to FQDN on hostnames when hosts registers and retrieve information [#363](https://github.com/juanfont/headscale/issues/363) -- Fix a bug that prevented the use of `tailscale logout` with OIDC - [#508](https://github.com/juanfont/headscale/issues/508) +- Fix a bug that prevented the use of `tailscale logout` with OIDC [#508](https://github.com/juanfont/headscale/issues/508) - Added Tailscale repo HEAD and unstable releases channel to the integration tests targets [#513](https://github.com/juanfont/headscale/pull/513) @@ -1127,13 +947,11 @@ behaviour. ### Features -- Add support for configurable mTLS [docs](./docs/ref/tls.md) - [#297](https://github.com/juanfont/headscale/pull/297) +- Add support for configurable mTLS [docs](./docs/ref/tls.md) [#297](https://github.com/juanfont/headscale/pull/297) ### Changes -- Remove dependency on CGO (switch from CGO SQLite to pure Go) - [#346](https://github.com/juanfont/headscale/pull/346) +- Remove dependency on CGO (switch from CGO SQLite to pure Go) [#346](https://github.com/juanfont/headscale/pull/346) **0.13.0 (2022-02-18):** @@ -1152,25 +970,18 @@ behaviour. ### Changes -- `ip_prefix` is now superseded by `ip_prefixes` in the configuration - [#208](https://github.com/juanfont/headscale/pull/208) -- Upgrade `tailscale` (1.20.4) and other dependencies to latest - [#314](https://github.com/juanfont/headscale/pull/314) -- fix swapped machine<->namespace labels in `/metrics` - [#312](https://github.com/juanfont/headscale/pull/312) -- remove key-value based update mechanism for namespace changes - [#316](https://github.com/juanfont/headscale/pull/316) +- `ip_prefix` is now superseded by `ip_prefixes` in the configuration [#208](https://github.com/juanfont/headscale/pull/208) +- Upgrade `tailscale` (1.20.4) and other dependencies to latest [#314](https://github.com/juanfont/headscale/pull/314) +- fix swapped machine<->namespace labels in `/metrics` [#312](https://github.com/juanfont/headscale/pull/312) +- remove key-value based update mechanism for namespace changes [#316](https://github.com/juanfont/headscale/pull/316) **0.12.4 (2022-01-29):** ### Changes -- Make gRPC Unix Socket permissions configurable - [#292](https://github.com/juanfont/headscale/pull/292) -- Trim whitespace before reading Private Key from file - [#289](https://github.com/juanfont/headscale/pull/289) -- Add new command to generate a private key for `headscale` - [#290](https://github.com/juanfont/headscale/pull/290) +- Make gRPC Unix Socket permissions configurable [#292](https://github.com/juanfont/headscale/pull/292) +- Trim whitespace before reading Private Key from file [#289](https://github.com/juanfont/headscale/pull/289) +- Add new command to generate a private key for `headscale` [#290](https://github.com/juanfont/headscale/pull/290) - Fixed issue where hosts deleted from control server may be written back to the database, as long as they are connected to the control server [#278](https://github.com/juanfont/headscale/pull/278) @@ -1180,8 +991,7 @@ behaviour. ### Changes - Added Alpine container [#270](https://github.com/juanfont/headscale/pull/270) -- Minor updates in dependencies - [#271](https://github.com/juanfont/headscale/pull/271) +- Minor updates in dependencies [#271](https://github.com/juanfont/headscale/pull/271) ## 0.12.2 (2022-01-11) @@ -1200,8 +1010,7 @@ tagging) ### BREAKING -- Upgrade to Tailscale 1.18 - [#229](https://github.com/juanfont/headscale/pull/229) +- Upgrade to Tailscale 1.18 [#229](https://github.com/juanfont/headscale/pull/229) - This change requires a new format for private key, private keys are now generated automatically: 1. Delete your current key @@ -1210,25 +1019,19 @@ tagging) ### Changes -- Unify configuration example - [#197](https://github.com/juanfont/headscale/pull/197) -- Add stricter linting and formatting - [#223](https://github.com/juanfont/headscale/pull/223) +- Unify configuration example [#197](https://github.com/juanfont/headscale/pull/197) +- Add stricter linting and formatting [#223](https://github.com/juanfont/headscale/pull/223) ### Features -- Add gRPC and HTTP API (HTTP API is currently disabled) - [#204](https://github.com/juanfont/headscale/pull/204) -- Use gRPC between the CLI and the server - [#206](https://github.com/juanfont/headscale/pull/206), +- Add gRPC and HTTP API (HTTP API is currently disabled) [#204](https://github.com/juanfont/headscale/pull/204) +- Use gRPC between the CLI and the server [#206](https://github.com/juanfont/headscale/pull/206), [#212](https://github.com/juanfont/headscale/pull/212) -- Beta OpenID Connect support - [#126](https://github.com/juanfont/headscale/pull/126), +- Beta OpenID Connect support [#126](https://github.com/juanfont/headscale/pull/126), [#227](https://github.com/juanfont/headscale/pull/227) ## 0.11.0 (2021-10-25) ### BREAKING -- Make headscale fetch DERP map from URL and file - [#196](https://github.com/juanfont/headscale/pull/196) +- Make headscale fetch DERP map from URL and file [#196](https://github.com/juanfont/headscale/pull/196) diff --git a/Makefile b/Makefile index 9a5b8dfa..1e08cda9 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,6 @@ fmt-go: check-deps $(GO_SOURCES) fmt-prettier: check-deps $(DOC_SOURCES) @echo "Formatting documentation and config files..." prettier --write '**/*.{ts,js,md,yaml,yml,sass,css,scss,html}' - prettier --write --print-width 80 --prose-wrap always CHANGELOG.md .PHONY: fmt-proto fmt-proto: check-deps $(PROTO_SOURCES) From d14be8d43b6a07e0218b8daaa0c10e7db9f5b70b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 12 Nov 2025 07:11:38 -0600 Subject: [PATCH 036/178] nix: add NixOS module and tests (#2857) --- .github/workflows/nix-module-test.yml | 56 ++ CHANGELOG.md | 6 +- README.md | 2 + flake.nix | 34 +- nix/README.md | 41 ++ nix/example-configuration.nix | 145 +++++ nix/module.nix | 727 ++++++++++++++++++++++++++ nix/tests/headscale.nix | 102 ++++ 8 files changed, 1085 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/nix-module-test.yml create mode 100644 nix/README.md create mode 100644 nix/example-configuration.nix create mode 100644 nix/module.nix create mode 100644 nix/tests/headscale.nix diff --git a/.github/workflows/nix-module-test.yml b/.github/workflows/nix-module-test.yml new file mode 100644 index 00000000..18f40f91 --- /dev/null +++ b/.github/workflows/nix-module-test.yml @@ -0,0 +1,56 @@ +name: NixOS Module Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + nix-module-check: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 2 + + - name: Get changed files + id: changed-files + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + filters: | + nix: + - 'nix/**' + - 'flake.nix' + - 'flake.lock' + go: + - 'go.*' + - '**/*.go' + - 'cmd/**' + - 'hscontrol/**' + + - uses: nixbuild/nix-quick-install-action@889f3180bb5f064ee9e3201428d04ae9e41d54ad # v31 + if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true' + + - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true' + with: + primary-key: + nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', + '**/flake.lock') }} + restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} + + - name: Run NixOS module tests + if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true' + run: | + echo "Running NixOS module integration test..." + nix build .#checks.x86_64-linux.headscale -L diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e43192e..15467f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,8 @@ ### Changes - Add NixOS module in repository for faster iteration [#2857](https://github.com/juanfont/headscale/pull/2857) -- Add favicon to webpages - [#2858](https://github.com/juanfont/headscale/pull/2858) -- Reclaim IPs from the IP allocator when nodes are deleted - [#2831](https://github.com/juanfont/headscale/pull/2831) +- Add favicon to webpages [#2858](https://github.com/juanfont/headscale/pull/2858) +- Reclaim IPs from the IP allocator when nodes are deleted [#2831](https://github.com/juanfont/headscale/pull/2831) ## 0.27.1 (2025-11-11) diff --git a/README.md b/README.md index dbde74d9..7381c372 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ and container to run Headscale.** Please have a look at the [`documentation`](https://headscale.net/stable/). +For NixOS users, a module is available in [`nix/`](./nix/). + ## Talks - Fosdem 2023 (video): [Headscale: How we are using integration testing to reimplement Tailscale](https://fosdem.org/2023/schedule/event/goheadscale/) diff --git a/flake.nix b/flake.nix index 86f8b005..8d16f609 100644 --- a/flake.nix +++ b/flake.nix @@ -17,6 +17,12 @@ commitHash = self.rev or self.dirtyRev; in { + # NixOS module + nixosModules = rec { + headscale = import ./nix/module.nix; + default = headscale; + }; + overlay = _: prev: let pkgs = nixpkgs.legacyPackages.${prev.system}; @@ -38,12 +44,9 @@ subPackages = [ "cmd/headscale" ]; - ldflags = [ - "-s" - "-w" - "-X github.com/juanfont/headscale/hscontrol/types.Version=${headscaleVersion}" - "-X github.com/juanfont/headscale/hscontrol/types.GitCommitHash=${commitHash}" - ]; + meta = { + mainProgram = "headscale"; + }; }; hi = buildGo { @@ -228,24 +231,7 @@ apps.default = apps.headscale; checks = { - format = - pkgs.runCommand "check-format" - { - buildInputs = with pkgs; [ - gnumake - nixpkgs-fmt - golangci-lint - nodePackages.prettier - golines - clang-tools - ]; - } '' - ${pkgs.nixpkgs-fmt}/bin/nixpkgs-fmt ${./.} - ${pkgs.golangci-lint}/bin/golangci-lint run --fix --timeout 10m - ${pkgs.nodePackages.prettier}/bin/prettier --write '**/**.{ts,js,md,yaml,yml,sass,css,scss,html}' - ${pkgs.golines}/bin/golines --max-len=88 --base-formatter=gofumpt -w ${./.} - ${pkgs.clang-tools}/bin/clang-format -i ${./.} - ''; + headscale = pkgs.nixosTest (import ./nix/tests/headscale.nix); }; }); } diff --git a/nix/README.md b/nix/README.md new file mode 100644 index 00000000..533e4b5e --- /dev/null +++ b/nix/README.md @@ -0,0 +1,41 @@ +# Headscale NixOS Module + +This directory contains the NixOS module for Headscale. + +## Rationale + +The module is maintained in this repository to keep the code and module +synchronized at the same commit. This allows faster iteration and ensures the +module stays compatible with the latest Headscale changes. All changes should +aim to be upstreamed to nixpkgs. + +## Files + +- **[`module.nix`](./module.nix)** - The NixOS module implementation +- **[`example-configuration.nix`](./example-configuration.nix)** - Example + configuration demonstrating all major features +- **[`tests/`](./tests/)** - NixOS integration tests + +## Usage + +Add to your flake inputs: + +```nix +inputs.headscale.url = "github:juanfont/headscale"; +``` + +Then import the module: + +```nix +imports = [ inputs.headscale.nixosModules.default ]; +``` + +See [`example-configuration.nix`](./example-configuration.nix) for configuration +options. + +## Upstream + +- [nixpkgs module](https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/services/networking/headscale.nix) +- [nixpkgs package](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/he/headscale/package.nix) + +The module in this repository may be newer than the nixpkgs version. diff --git a/nix/example-configuration.nix b/nix/example-configuration.nix new file mode 100644 index 00000000..e1f6cec7 --- /dev/null +++ b/nix/example-configuration.nix @@ -0,0 +1,145 @@ +# Example NixOS configuration using the headscale module +# +# This file demonstrates how to use the headscale NixOS module from this flake. +# To use in your own configuration, add this to your flake.nix inputs: +# +# inputs.headscale.url = "github:juanfont/headscale"; +# +# Then import the module: +# +# imports = [ inputs.headscale.nixosModules.default ]; +# + +{ config, pkgs, ... }: + +{ + # Import the headscale module + # In a real configuration, this would come from the flake input + # imports = [ inputs.headscale.nixosModules.default ]; + + services.headscale = { + enable = true; + + # Optional: Use a specific package (defaults to pkgs.headscale) + # package = pkgs.headscale; + + # Listen on all interfaces (default is 127.0.0.1) + address = "0.0.0.0"; + port = 8080; + + settings = { + # The URL clients will connect to + server_url = "https://headscale.example.com"; + + # IP prefixes for the tailnet + # These use the freeform settings - you can set any headscale config option + prefixes = { + v4 = "100.64.0.0/10"; + v6 = "fd7a:115c:a1e0::/48"; + allocation = "sequential"; + }; + + # DNS configuration with MagicDNS + dns = { + magic_dns = true; + base_domain = "tailnet.example.com"; + + # Whether to override client's local DNS settings (default: true) + # When true, nameservers.global must be set + override_local_dns = true; + + nameservers = { + global = [ "1.1.1.1" "8.8.8.8" ]; + }; + }; + + # DERP (relay) configuration + derp = { + # Use default Tailscale DERP servers + urls = [ "https://controlplane.tailscale.com/derpmap/default" ]; + auto_update_enabled = true; + update_frequency = "24h"; + + # Optional: Run your own DERP server + # server = { + # enabled = true; + # region_id = 999; + # stun_listen_addr = "0.0.0.0:3478"; + # }; + }; + + # Database configuration (SQLite is recommended) + database = { + type = "sqlite"; + sqlite = { + path = "/var/lib/headscale/db.sqlite"; + write_ahead_log = true; + }; + + # PostgreSQL example (not recommended for new deployments) + # type = "postgres"; + # postgres = { + # host = "localhost"; + # port = 5432; + # name = "headscale"; + # user = "headscale"; + # password_file = "/run/secrets/headscale-db-password"; + # }; + }; + + # Logging configuration + log = { + level = "info"; + format = "text"; + }; + + # Optional: OIDC authentication + # oidc = { + # issuer = "https://accounts.google.com"; + # client_id = "your-client-id"; + # client_secret_path = "/run/secrets/oidc-client-secret"; + # scope = [ "openid" "profile" "email" ]; + # allowed_domains = [ "example.com" ]; + # }; + + # Optional: Let's Encrypt TLS certificates + # tls_letsencrypt_hostname = "headscale.example.com"; + # tls_letsencrypt_challenge_type = "HTTP-01"; + + # Optional: Provide your own TLS certificates + # tls_cert_path = "/path/to/cert.pem"; + # tls_key_path = "/path/to/key.pem"; + + # ACL policy configuration + policy = { + mode = "file"; + path = "/var/lib/headscale/policy.hujson"; + }; + + # You can add ANY headscale configuration option here thanks to freeform settings + # For example, experimental features or settings not explicitly defined above: + # experimental_feature = true; + # custom_setting = "value"; + }; + }; + + # Optional: Open firewall ports + networking.firewall = { + allowedTCPPorts = [ 8080 ]; + # If running a DERP server: + # allowedUDPPorts = [ 3478 ]; + }; + + # Optional: Use with nginx reverse proxy for TLS termination + # services.nginx = { + # enable = true; + # virtualHosts."headscale.example.com" = { + # enableACME = true; + # forceSSL = true; + # locations."/" = { + # proxyPass = "http://127.0.0.1:8080"; + # proxyWebsockets = true; + # }; + # }; + # }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 00000000..a75398fb --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,727 @@ +{ config +, lib +, pkgs +, ... +}: +let + cfg = config.services.headscale; + + dataDir = "/var/lib/headscale"; + runDir = "/run/headscale"; + + cliConfig = { + # Turn off update checks since the origin of our package + # is nixpkgs and not Github. + disable_check_updates = true; + + unix_socket = "${runDir}/headscale.sock"; + }; + + settingsFormat = pkgs.formats.yaml { }; + configFile = settingsFormat.generate "headscale.yaml" cfg.settings; + cliConfigFile = settingsFormat.generate "headscale.yaml" cliConfig; + + assertRemovedOption = option: message: { + assertion = !lib.hasAttrByPath option cfg; + message = + "The option `services.headscale.${lib.options.showOption option}` was removed. " + message; + }; +in +{ + # Disable the upstream NixOS module to prevent conflicts + disabledModules = [ "services/networking/headscale.nix" ]; + + options = { + services.headscale = { + enable = lib.mkEnableOption "headscale, Open Source coordination server for Tailscale"; + + package = lib.mkPackageOption pkgs "headscale" { }; + + user = lib.mkOption { + default = "headscale"; + type = lib.types.str; + description = '' + User account under which headscale runs. + + ::: {.note} + If left as the default value this user will automatically be created + on system activation, otherwise you are responsible for + ensuring the user exists before the headscale service starts. + ::: + ''; + }; + + group = lib.mkOption { + default = "headscale"; + type = lib.types.str; + description = '' + Group under which headscale runs. + + ::: {.note} + If left as the default value this group will automatically be created + on system activation, otherwise you are responsible for + ensuring the user exists before the headscale service starts. + ::: + ''; + }; + + address = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = '' + Listening address of headscale. + ''; + example = "0.0.0.0"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8080; + description = '' + Listening port of headscale. + ''; + example = 443; + }; + + settings = lib.mkOption { + description = '' + Overrides to {file}`config.yaml` as a Nix attribute set. + Check the [example config](https://github.com/juanfont/headscale/blob/main/config-example.yaml) + for possible options. + ''; + type = lib.types.submodule { + freeformType = settingsFormat.type; + + options = { + server_url = lib.mkOption { + type = lib.types.str; + default = "http://127.0.0.1:8080"; + description = '' + The url clients will connect to. + ''; + example = "https://myheadscale.example.com:443"; + }; + + noise.private_key_path = lib.mkOption { + type = lib.types.path; + default = "${dataDir}/noise_private.key"; + description = '' + Path to noise private key file, generated automatically if it does not exist. + ''; + }; + + prefixes = + let + prefDesc = '' + Each prefix consists of either an IPv4 or IPv6 address, + and the associated prefix length, delimited by a slash. + It must be within IP ranges supported by the Tailscale + client - i.e., subnets of 100.64.0.0/10 and fd7a:115c:a1e0::/48. + ''; + in + { + v4 = lib.mkOption { + type = lib.types.str; + default = "100.64.0.0/10"; + description = prefDesc; + }; + + v6 = lib.mkOption { + type = lib.types.str; + default = "fd7a:115c:a1e0::/48"; + description = prefDesc; + }; + + allocation = lib.mkOption { + type = lib.types.enum [ + "sequential" + "random" + ]; + example = "random"; + default = "sequential"; + description = '' + Strategy used for allocation of IPs to nodes, available options: + - sequential (default): assigns the next free IP from the previous given IP. + - random: assigns the next free IP from a pseudo-random IP generator (crypto/rand). + ''; + }; + }; + + derp = { + urls = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "https://controlplane.tailscale.com/derpmap/default" ]; + description = '' + List of urls containing DERP maps. + See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps. + ''; + }; + + paths = lib.mkOption { + type = lib.types.listOf lib.types.path; + default = [ ]; + description = '' + List of file paths containing DERP maps. + See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps. + ''; + }; + + auto_update_enabled = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether to automatically update DERP maps on a set frequency. + ''; + example = false; + }; + + update_frequency = lib.mkOption { + type = lib.types.str; + default = "24h"; + description = '' + Frequency to update DERP maps. + ''; + example = "5m"; + }; + + server.private_key_path = lib.mkOption { + type = lib.types.path; + default = "${dataDir}/derp_server_private.key"; + description = '' + Path to derp private key file, generated automatically if it does not exist. + ''; + }; + }; + + ephemeral_node_inactivity_timeout = lib.mkOption { + type = lib.types.str; + default = "30m"; + description = '' + Time before an inactive ephemeral node is deleted. + ''; + example = "5m"; + }; + + database = { + type = lib.mkOption { + type = lib.types.enum [ + "sqlite" + "sqlite3" + "postgres" + ]; + example = "postgres"; + default = "sqlite"; + description = '' + Database engine to use. + Please note that using Postgres is highly discouraged as it is only supported for legacy reasons. + All new development, testing and optimisations are done with SQLite in mind. + ''; + }; + + sqlite = { + path = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = "${dataDir}/db.sqlite"; + description = "Path to the sqlite3 database file."; + }; + + write_ahead_log = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Enable WAL mode for SQLite. This is recommended for production environments. + + ''; + example = true; + }; + }; + + postgres = { + host = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "127.0.0.1"; + description = "Database host address."; + }; + + port = lib.mkOption { + type = lib.types.nullOr lib.types.port; + default = null; + example = 3306; + description = "Database host port."; + }; + + name = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "headscale"; + description = "Database name."; + }; + + user = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "headscale"; + description = "Database user."; + }; + + password_file = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/keys/headscale-dbpassword"; + description = '' + A file containing the password corresponding to + {option}`database.user`. + ''; + }; + }; + }; + + log = { + level = lib.mkOption { + type = lib.types.str; + default = "info"; + description = '' + headscale log level. + ''; + example = "debug"; + }; + + format = lib.mkOption { + type = lib.types.str; + default = "text"; + description = '' + headscale log format. + ''; + example = "json"; + }; + }; + + dns = { + magic_dns = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/). + ''; + example = false; + }; + + base_domain = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + Defines the base domain to create the hostnames for MagicDNS. + This domain must be different from the {option}`server_url` + domain. + {option}`base_domain` must be a FQDN, without the trailing dot. + The FQDN of the hosts will be `hostname.base_domain` (e.g. + `myhost.tailnet.example.com`). + ''; + example = "tailnet.example.com"; + }; + + override_local_dns = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether to use the local DNS settings of a node or override + the local DNS settings and force the use of Headscale's DNS + configuration. + ''; + example = false; + }; + + nameservers = { + global = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + List of nameservers to pass to Tailscale clients. + Required when {option}`override_local_dns` is true. + ''; + }; + }; + + search_domains = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Search domains to inject to Tailscale clients. + ''; + example = [ "mydomain.internal" ]; + }; + }; + + oidc = { + issuer = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + URL to OpenID issuer. + ''; + example = "https://openid.example.com"; + }; + + client_id = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + OpenID Connect client ID. + ''; + }; + + client_secret_path = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Path to OpenID Connect client secret file. Expands environment variables in format ''${VAR}. + ''; + }; + + scope = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ + "openid" + "profile" + "email" + ]; + description = '' + Scopes used in the OIDC flow. + ''; + }; + + extra_params = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = '' + Custom query parameters to send with the Authorize Endpoint request. + ''; + example = { + domain_hint = "example.com"; + }; + }; + + allowed_domains = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Allowed principal domains. if an authenticated user's domain + is not in this list authentication request will be rejected. + ''; + example = [ "example.com" ]; + }; + + allowed_users = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Users allowed to authenticate even if not in allowedDomains. + ''; + example = [ "alice@example.com" ]; + }; + + pkce = { + enabled = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Enable or disable PKCE (Proof Key for Code Exchange) support. + PKCE adds an additional layer of security to the OAuth 2.0 + authorization code flow by preventing authorization code + interception attacks + See https://datatracker.ietf.org/doc/html/rfc7636 + ''; + example = true; + }; + + method = lib.mkOption { + type = lib.types.str; + default = "S256"; + description = '' + PKCE method to use: + - plain: Use plain code verifier + - S256: Use SHA256 hashed code verifier (default, recommended) + ''; + }; + }; + }; + + tls_letsencrypt_hostname = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = ""; + description = '' + Domain name to request a TLS certificate for. + ''; + }; + + tls_letsencrypt_challenge_type = lib.mkOption { + type = lib.types.enum [ + "TLS-ALPN-01" + "HTTP-01" + ]; + default = "HTTP-01"; + description = '' + Type of ACME challenge to use, currently supported types: + `HTTP-01` or `TLS-ALPN-01`. + ''; + }; + + tls_letsencrypt_listen = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = ":http"; + description = '' + When HTTP-01 challenge is chosen, letsencrypt must set up a + verification endpoint, and it will be listening on: + `:http = port 80`. + ''; + }; + + tls_cert_path = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Path to already created certificate. + ''; + }; + + tls_key_path = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Path to key for already created certificate. + ''; + }; + + policy = { + mode = lib.mkOption { + type = lib.types.enum [ + "file" + "database" + ]; + default = "file"; + description = '' + The mode can be "file" or "database" that defines + where the ACL policies are stored and read from. + ''; + }; + + path = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + If the mode is set to "file", the path to a + HuJSON file containing ACL policies. + ''; + }; + }; + }; + }; + }; + }; + }; + + imports = with lib; [ + (mkRenamedOptionModule + [ "services" "headscale" "derp" "autoUpdate" ] + [ "services" "headscale" "settings" "derp" "auto_update_enabled" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "derp" "auto_update_enable" ] + [ "services" "headscale" "settings" "derp" "auto_update_enabled" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "derp" "paths" ] + [ "services" "headscale" "settings" "derp" "paths" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "derp" "updateFrequency" ] + [ "services" "headscale" "settings" "derp" "update_frequency" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "derp" "urls" ] + [ "services" "headscale" "settings" "derp" "urls" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "ephemeralNodeInactivityTimeout" ] + [ "services" "headscale" "settings" "ephemeral_node_inactivity_timeout" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "logLevel" ] + [ "services" "headscale" "settings" "log" "level" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "openIdConnect" "clientId" ] + [ "services" "headscale" "settings" "oidc" "client_id" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "openIdConnect" "clientSecretFile" ] + [ "services" "headscale" "settings" "oidc" "client_secret_path" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "openIdConnect" "issuer" ] + [ "services" "headscale" "settings" "oidc" "issuer" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "serverUrl" ] + [ "services" "headscale" "settings" "server_url" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "tls" "certFile" ] + [ "services" "headscale" "settings" "tls_cert_path" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "tls" "keyFile" ] + [ "services" "headscale" "settings" "tls_key_path" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "tls" "letsencrypt" "challengeType" ] + [ "services" "headscale" "settings" "tls_letsencrypt_challenge_type" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "tls" "letsencrypt" "hostname" ] + [ "services" "headscale" "settings" "tls_letsencrypt_hostname" ] + ) + (mkRenamedOptionModule + [ "services" "headscale" "tls" "letsencrypt" "httpListen" ] + [ "services" "headscale" "settings" "tls_letsencrypt_listen" ] + ) + + (mkRemovedOptionModule [ "services" "headscale" "openIdConnect" "domainMap" ] '' + Headscale no longer uses domain_map. If you're using an old version of headscale you can still set this option via services.headscale.settings.oidc.domain_map. + '') + ]; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = with cfg.settings; dns.magic_dns -> dns.base_domain != ""; + message = "dns.base_domain must be set when using MagicDNS"; + } + { + assertion = with cfg.settings; dns.override_local_dns -> (dns.nameservers.global != [ ]); + message = "dns.nameservers.global must be set when dns.override_local_dns is true"; + } + (assertRemovedOption [ "settings" "acl_policy_path" ] "Use `policy.path` instead.") + (assertRemovedOption [ "settings" "db_host" ] "Use `database.postgres.host` instead.") + (assertRemovedOption [ "settings" "db_name" ] "Use `database.postgres.name` instead.") + (assertRemovedOption [ + "settings" + "db_password_file" + ] "Use `database.postgres.password_file` instead.") + (assertRemovedOption [ "settings" "db_path" ] "Use `database.sqlite.path` instead.") + (assertRemovedOption [ "settings" "db_port" ] "Use `database.postgres.port` instead.") + (assertRemovedOption [ "settings" "db_type" ] "Use `database.type` instead.") + (assertRemovedOption [ "settings" "db_user" ] "Use `database.postgres.user` instead.") + (assertRemovedOption [ "settings" "dns_config" ] "Use `dns` instead.") + (assertRemovedOption [ "settings" "dns_config" "domains" ] "Use `dns.search_domains` instead.") + (assertRemovedOption [ + "settings" + "dns_config" + "nameservers" + ] "Use `dns.nameservers.global` instead.") + (assertRemovedOption [ + "settings" + "oidc" + "strip_email_domain" + ] "The strip_email_domain option got removed upstream") + ]; + + services.headscale.settings = lib.mkMerge [ + cliConfig + { + listen_addr = lib.mkDefault "${cfg.address}:${toString cfg.port}"; + + tls_letsencrypt_cache_dir = "${dataDir}/.cache"; + } + ]; + + environment = { + # Headscale CLI needs a minimal config to be able to locate the unix socket + # to talk to the server instance. + etc."headscale/config.yaml".source = cliConfigFile; + + systemPackages = [ cfg.package ]; + }; + + users.groups.headscale = lib.mkIf (cfg.group == "headscale") { }; + + users.users.headscale = lib.mkIf (cfg.user == "headscale") { + description = "headscale user"; + home = dataDir; + group = cfg.group; + isSystemUser = true; + }; + + systemd.services.headscale = { + description = "headscale coordination server for Tailscale"; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + script = '' + ${lib.optionalString (cfg.settings.database.postgres.password_file != null) '' + export HEADSCALE_DATABASE_POSTGRES_PASS="$(head -n1 ${lib.escapeShellArg cfg.settings.database.postgres.password_file})" + ''} + + exec ${lib.getExe cfg.package} serve --config ${configFile} + ''; + + serviceConfig = + let + capabilityBoundingSet = [ "CAP_CHOWN" ] ++ lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE"; + in + { + Restart = "always"; + RestartSec = "5s"; + Type = "simple"; + User = cfg.user; + Group = cfg.group; + + # Hardening options + RuntimeDirectory = "headscale"; + # Allow headscale group access so users can be added and use the CLI. + RuntimeDirectoryMode = "0750"; + + StateDirectory = "headscale"; + StateDirectoryMode = "0750"; + + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + PrivateMounts = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectHostname = true; + ProtectClock = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + RestrictNamespaces = true; + RemoveIPC = true; + UMask = "0077"; + + CapabilityBoundingSet = capabilityBoundingSet; + AmbientCapabilities = capabilityBoundingSet; + NoNewPrivileges = true; + LockPersonality = true; + RestrictRealtime = true; + SystemCallFilter = [ + "@system-service" + "~@privileged" + "@chown" + ]; + SystemCallArchitectures = "native"; + RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX"; + }; + }; + }; + + meta.maintainers = with lib.maintainers; [ + kradalby + misterio77 + ]; +} diff --git a/nix/tests/headscale.nix b/nix/tests/headscale.nix new file mode 100644 index 00000000..7dc93870 --- /dev/null +++ b/nix/tests/headscale.nix @@ -0,0 +1,102 @@ +{ pkgs, lib, ... }: +let + tls-cert = pkgs.runCommand "selfSignedCerts" { buildInputs = [ pkgs.openssl ]; } '' + openssl req \ + -x509 -newkey rsa:4096 -sha256 -days 365 \ + -nodes -out cert.pem -keyout key.pem \ + -subj '/CN=headscale' -addext "subjectAltName=DNS:headscale" + + mkdir -p $out + cp key.pem cert.pem $out + ''; +in +{ + name = "headscale"; + meta.maintainers = with lib.maintainers; [ + kradalby + misterio77 + ]; + + nodes = + let + headscalePort = 8080; + stunPort = 3478; + peer = { + services.tailscale.enable = true; + security.pki.certificateFiles = [ "${tls-cert}/cert.pem" ]; + }; + in + { + peer1 = peer; + peer2 = peer; + + headscale = { + services = { + headscale = { + enable = true; + port = headscalePort; + settings = { + server_url = "https://headscale"; + ip_prefixes = [ "100.64.0.0/10" ]; + derp.server = { + enabled = true; + region_id = 999; + stun_listen_addr = "0.0.0.0:${toString stunPort}"; + }; + dns = { + base_domain = "tailnet"; + extra_records = [ + { + name = "foo.bar"; + type = "A"; + value = "100.64.0.2"; + } + ]; + override_local_dns = false; + }; + }; + }; + nginx = { + enable = true; + virtualHosts.headscale = { + addSSL = true; + sslCertificate = "${tls-cert}/cert.pem"; + sslCertificateKey = "${tls-cert}/key.pem"; + locations."/" = { + proxyPass = "http://127.0.0.1:${toString headscalePort}"; + proxyWebsockets = true; + }; + }; + }; + }; + networking.firewall = { + allowedTCPPorts = [ + 80 + 443 + ]; + allowedUDPPorts = [ stunPort ]; + }; + environment.systemPackages = [ pkgs.headscale ]; + }; + }; + + testScript = '' + start_all() + headscale.wait_for_unit("headscale") + headscale.wait_for_open_port(443) + + # Create headscale user and preauth-key + headscale.succeed("headscale users create test") + authkey = headscale.succeed("headscale preauthkeys -u 1 create --reusable") + + # Connect peers + up_cmd = f"tailscale up --login-server 'https://headscale' --auth-key {authkey}" + peer1.execute(up_cmd) + peer2.execute(up_cmd) + + # Check that they are reachable from the tailnet + peer1.wait_until_succeeds("tailscale ping peer2") + peer2.wait_until_succeeds("tailscale ping peer1.tailnet") + assert (res := peer1.wait_until_succeeds("${lib.getExe pkgs.dig} +short foo.bar").strip()) == "100.64.0.2", f"Domain {res} did not match 100.64.0.2" + ''; +} From 89285c317b66b806663c6ce1a78fcf09488d8c6a Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 24 Oct 2025 15:43:29 +0200 Subject: [PATCH 037/178] templates: migrate OIDC callback to elem-go Replace html/template with type-safe elem-go templating for OIDC callback page. Improves consistency with other templates and provides compile-time safety. All UI elements and styling preserved. --- hscontrol/assets/oidc_callback_template.html | 307 ------------------- hscontrol/oidc.go | 23 +- hscontrol/oidc_template_test.go | 63 ++++ hscontrol/templates/general.go | 18 +- hscontrol/templates/oidc_callback.go | 223 ++++++++++++++ hscontrol/templates/register_web.go | 8 +- 6 files changed, 304 insertions(+), 338 deletions(-) delete mode 100644 hscontrol/assets/oidc_callback_template.html create mode 100644 hscontrol/oidc_template_test.go create mode 100644 hscontrol/templates/oidc_callback.go diff --git a/hscontrol/assets/oidc_callback_template.html b/hscontrol/assets/oidc_callback_template.html deleted file mode 100644 index 2236f365..00000000 --- a/hscontrol/assets/oidc_callback_template.html +++ /dev/null @@ -1,307 +0,0 @@ - - - - - - Headscale Authentication Succeeded - - - -

-
- -
- -
-
Signed in via your OIDC provider
-

- {{.Verb}} as {{.User}}, you can now close this window. -

-
-
-
-

Not sure how to get started?

-

- Check out beginner and advanced guides on, or read more in the - documentation. -

- - - - - - - View the headscale documentation - - - - - - - - View the tailscale documentation - -
-
- - diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index 7c7895c6..2164215a 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -4,10 +4,8 @@ import ( "bytes" "cmp" "context" - _ "embed" "errors" "fmt" - "html/template" "net/http" "slices" "strings" @@ -16,6 +14,7 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/gorilla/mux" "github.com/juanfont/headscale/hscontrol/db" + "github.com/juanfont/headscale/hscontrol/templates" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types/change" "github.com/juanfont/headscale/hscontrol/util" @@ -191,13 +190,6 @@ type oidcCallbackTemplateConfig struct { Verb string } -//go:embed assets/oidc_callback_template.html -var oidcCallbackTemplateContent string - -var oidcCallbackTemplate = template.Must( - template.New("oidccallback").Parse(oidcCallbackTemplateContent), -) - // OIDCCallbackHandler handles the callback from the OIDC endpoint // Retrieves the nkey from the state cache and adds the node to the users email user // TODO: A confirmation page for new nodes should be added to avoid phishing vulnerabilities @@ -573,21 +565,12 @@ func (a *AuthProviderOIDC) handleRegistration( return !nodeChange.Empty(), nil } -// TODO(kradalby): -// Rewrite in elem-go. func renderOIDCCallbackTemplate( user *types.User, verb string, ) (*bytes.Buffer, error) { - var content bytes.Buffer - if err := oidcCallbackTemplate.Execute(&content, oidcCallbackTemplateConfig{ - User: user.Display(), - Verb: verb, - }); err != nil { - return nil, fmt.Errorf("rendering OIDC callback template: %w", err) - } - - return &content, nil + html := templates.OIDCCallback(user.Display(), verb).Render() + return bytes.NewBufferString(html), nil } // getCookieName generates a unique cookie name based on a cookie value. diff --git a/hscontrol/oidc_template_test.go b/hscontrol/oidc_template_test.go new file mode 100644 index 00000000..5eb2b9f5 --- /dev/null +++ b/hscontrol/oidc_template_test.go @@ -0,0 +1,63 @@ +package hscontrol + +import ( + "os" + "path/filepath" + "testing" + + "github.com/juanfont/headscale/hscontrol/templates" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOIDCCallbackTemplate(t *testing.T) { + tests := []struct { + name string + userName string + verb string + }{ + { + name: "logged_in_user", + userName: "test@example.com", + verb: "Logged in", + }, + { + name: "registered_user", + userName: "newuser@example.com", + verb: "Registered", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Render using the elem-go template + html := templates.OIDCCallback(tt.userName, tt.verb).Render() + + // Verify the HTML contains expected elements + assert.Contains(t, html, "") + assert.Contains(t, html, "Headscale Authentication Succeeded") + assert.Contains(t, html, tt.verb) + assert.Contains(t, html, tt.userName) + assert.Contains(t, html, "You can now close this window") + + // Verify Material for MkDocs design system CSS is present + assert.Contains(t, html, "Material for MkDocs") + assert.Contains(t, html, "Roboto") + assert.Contains(t, html, ".md-typeset") + + // Verify SVG elements are present + assert.Contains(t, html, "