diff --git a/.github/workflows/integration-test-template.yml b/.github/workflows/integration-test-template.yml index 427f63e2..68dbfd36 100644 --- a/.github/workflows/integration-test-template.yml +++ b/.github/workflows/integration-test-template.yml @@ -51,6 +51,11 @@ jobs: with: name: tailscale-head-image path: /tmp/artifacts + - name: Download tailscale released images + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: tailscale-released-images + path: /tmp/artifacts - name: Download hi binary uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: @@ -86,6 +91,7 @@ jobs: run: | gunzip -c /tmp/artifacts/headscale-image.tar.gz | docker load gunzip -c /tmp/artifacts/tailscale-head-image.tar.gz | docker load + gunzip -c /tmp/artifacts/tailscale-released-images.tar.gz | docker load if [ -f /tmp/artifacts/postgres-image.tar.gz ]; then gunzip -c /tmp/artifacts/postgres-image.tar.gz | docker load fi diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index f257ae0e..f1532a00 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -9,6 +9,9 @@ concurrency: jobs: # build: Builds binaries and Docker images once, uploads as artifacts for reuse. # build-postgres: Pulls postgres image separately to avoid Docker Hub rate limits. + # build-tailscale-released: Pre-pulls released Tailscale images from ghcr.io + # so fork PRs (no DOCKERHUB_USERNAME secret) don't hit Docker Hub rate + # limits at test time. # sqlite: Runs all integration tests with SQLite backend. # postgres: Runs a subset of tests with PostgreSQL to verify database compatibility. build: @@ -150,9 +153,71 @@ jobs: name: postgres-image path: postgres-image.tar.gz retention-days: 10 - sqlite: + build-tailscale-released: + runs-on: ubuntu-24.04-arm needs: build if: needs.build.outputs.files-changed == 'true' + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + with: + primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }} + restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} + - name: Force overlay2 storage driver + run: | + sudo mkdir -p /etc/docker + echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json + sudo systemctl restart docker + docker version + - name: Login to Docker Hub + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_CI_TOKEN }} + if: env.DOCKERHUB_USERNAME != '' + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ env.DOCKERHUB_TOKEN }} + - name: List Tailscale versions to pre-pull + id: versions + run: | + versions=$(nix develop --command go run ./cmd/hi list-versions --set=must --exclude=head) + echo "versions=${versions}" >> "$GITHUB_OUTPUT" + echo "Pre-pulling: ${versions}" + - name: Pull Tailscale images + run: | + # Releases come from ghcr.io (anonymous, unmetered). The + # "unstable" floating tag on ghcr.io has been stale since 2022, + # so it still needs to come from Docker Hub. xargs -P 0 fans + # out one process per tag and returns non-zero if any pull + # fails. + refs="" + for v in ${{ steps.versions.outputs.versions }}; do + if [ "${v}" = "unstable" ]; then + refs="${refs} tailscale/tailscale:${v}" + else + refs="${refs} ghcr.io/tailscale/tailscale:${v}" + fi + done + echo "${refs}" | tr ' ' '\n' | grep -v '^$' \ + | xargs -P 0 -I{} docker pull "{}" + echo "REFS=${refs}" >> "$GITHUB_ENV" + - name: Save Tailscale images to tarball + run: | + # Single docker save with all refs: one consistent snapshot, no + # parallel-daemon race. + docker save ${REFS} | gzip > tailscale-released-images.tar.gz + ls -lh tailscale-released-images.tar.gz + - name: Upload Tailscale released images + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: tailscale-released-images + path: tailscale-released-images.tar.gz + retention-days: 10 + sqlite: + needs: [build, build-tailscale-released] + if: needs.build.outputs.files-changed == 'true' strategy: fail-fast: false matrix: @@ -309,7 +374,7 @@ jobs: postgres_flag: "--postgres=0" database_name: "sqlite" postgres: - needs: [build, build-postgres] + needs: [build, build-postgres, build-tailscale-released] if: needs.build.outputs.files-changed == 'true' strategy: fail-fast: false diff --git a/.golangci.yaml b/.golangci.yaml index ed6630da..1721cf61 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -13,6 +13,7 @@ linters: - gochecknoinits - gocognit - godox + - gomodguard - interfacebloat - ireturn - lll diff --git a/CHANGELOG.md b/CHANGELOG.md index 1663aca0..89f7337c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.29.0 (202x-xx-xx) -**Minimum supported Tailscale client version: v1.76.0** +**Minimum supported Tailscale client version: v1.80.0** ### Tailscale ACL compatibility improvements @@ -306,8 +306,8 @@ connected" routers that maintain their control session but cannot route packets. - Remove old migrations for the debian package [#3185](https://github.com/juanfont/headscale/pull/3185) - Install `config-example.yaml` as example for the debian package [#3186](https://github.com/juanfont/headscale/pull/3186) - Fix user-owned re-registration with zero client expiry and no default storing `0001-01-01 00:00:00` in the database instead of `NULL` [#3199](https://github.com/juanfont/headscale/pull/3199) - - Pre-existing rows with `0001-01-01 00:00:00` are not backfilled; they clear themselves the next time the node re-registers - Fix `tailscaled` restart on a node with no expiry resetting `NULL` to `0001-01-01 00:00:00` in the database, affecting both tagged and untagged nodes [#3197](https://github.com/juanfont/headscale/pull/3197) +- Backfill `nodes.expiry` rows persisted by older versions as `0001-01-01 00:00:00` to `NULL`, so nodes upgraded from <0.28 stop reporting as expired [#3284](https://github.com/juanfont/headscale/issues/3284) ## 0.28.0 (2026-02-04) diff --git a/Dockerfile.derper b/Dockerfile.derper index 0deb69e4..7268b936 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.22 +FROM alpine:3.23 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 c897ab8d..37b0e420 100644 --- a/Dockerfile.integration +++ b/Dockerfile.integration @@ -2,7 +2,7 @@ # and are in no way endorsed by Headscale's maintainers as an # official nor supported release or distribution. -FROM docker.io/golang:1.26.2-trixie AS builder +FROM docker.io/golang:1.26.3-trixie AS builder ARG VERSION=dev ENV GOPATH /go WORKDIR /go/src/headscale diff --git a/Dockerfile.tailscale-HEAD b/Dockerfile.tailscale-HEAD index 7d0d43bf..83f331a2 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.22 +FROM alpine:3.23 # Upstream: ca-certificates ip6tables iptables iproute2 # Tests: curl python3 (traceroute via BusyBox) RUN apk add --no-cache ca-certificates curl ip6tables iptables iproute2 python3 diff --git a/Dockerfile.tailscale-rs b/Dockerfile.tailscale-rs index c6040923..04376477 100644 --- a/Dockerfile.tailscale-rs +++ b/Dockerfile.tailscale-rs @@ -1,4 +1,4 @@ -FROM rust:1.94-bookworm AS builder +FROM rust:1.95-trixie AS builder ARG TAILSCALE_RS_REPO=https://github.com/tailscale/tailscale-rs.git ARG TAILSCALE_RS_REF=main @@ -15,7 +15,7 @@ RUN sed -i '/^axum = \["dep:axum"\]/a insecure-keyfetch = ["ts_control/insecure- RUN cargo build --release --features axum,insecure-keyfetch --example axum -FROM debian:bookworm-slim +FROM debian:trixie-slim RUN apt-get update && \ apt-get install -y --no-install-recommends \ diff --git a/cmd/hi/listversions.go b/cmd/hi/listversions.go new file mode 100644 index 00000000..c2162ad6 --- /dev/null +++ b/cmd/hi/listversions.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/creachadair/command" + "github.com/juanfont/headscale/hscontrol/capver" +) + +var ( + errUnknownSet = errors.New("unknown --set value (want must|all)") + errUnknownFormat = errors.New("unknown --format value (want space|newline|json)") +) + +// ListVersionsConfig holds flags for the list-versions subcommand. +type ListVersionsConfig struct { + Set string `flag:"set,default=must,Version set: must|all"` + Exclude string `flag:"exclude,Comma-separated versions to exclude (e.g. head,unstable)"` + Format string `flag:"format,default=space,Output format: space|newline|json"` +} + +var listVersionsConfig ListVersionsConfig + +// listVersions prints the Tailscale versions used by integration tests +// in a format CI can shell out to. Mirrors integration/scenario.go +// AllVersions and MustTestVersions: "head" and "unstable" are bare +// tags, releases get a "v" prefix so each entry can be appended to +// "ghcr.io/tailscale/tailscale:" directly. +func listVersions(env *command.Env) error { + release := capver.TailscaleLatestMajorMinor(capver.SupportedMajorMinorVersions, true) + all := append([]string{"head", "unstable"}, release...) + must := append(append([]string{}, all[0:4]...), all[len(all)-2:]...) + + var versions []string + + switch listVersionsConfig.Set { + case "must": + versions = must + case "all": + versions = all + default: + return fmt.Errorf("%w: %q", errUnknownSet, listVersionsConfig.Set) + } + + excluded := make(map[string]bool) + + if listVersionsConfig.Exclude != "" { + for v := range strings.SplitSeq(listVersionsConfig.Exclude, ",") { + excluded[strings.TrimSpace(v)] = true + } + } + + out := make([]string, 0, len(versions)) + + for _, v := range versions { + if excluded[v] { + continue + } + + if v != "head" && v != "unstable" { + v = "v" + v + } + + out = append(out, v) + } + + switch listVersionsConfig.Format { + case "space": + fmt.Println(strings.Join(out, " ")) + case "newline": + for _, v := range out { + fmt.Println(v) + } + case "json": + b, err := json.Marshal(out) + if err != nil { + return err + } + + fmt.Println(string(b)) + default: + return fmt.Errorf("%w: %q", errUnknownFormat, listVersionsConfig.Format) + } + + return nil +} diff --git a/cmd/hi/main.go b/cmd/hi/main.go index 2bbfefe0..39d4c410 100644 --- a/cmd/hi/main.go +++ b/cmd/hi/main.go @@ -29,6 +29,13 @@ func main() { return runDoctorCheck(env.Context()) }, }, + { + Name: "list-versions", + Help: "Print Tailscale versions used by integration tests", + Usage: "list-versions [flags]", + SetFlags: command.Flags(flax.MustBind, &listVersionsConfig), + Run: listVersions, + }, { Name: "clean", Help: "Clean Docker resources", diff --git a/config-example.yaml b/config-example.yaml index 22b9f349..04178086 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -406,9 +406,10 @@ unix_socket_permission: "0770" # use_expiry_from_token: false # # # The OIDC scopes to use, defaults to "openid", "profile" and "email". +# # Add "groups" scope to enable group storage for external integrations. # # Custom scopes can be configured as needed, be sure to always include the # # required "openid" scope. -# scope: ["openid", "profile", "email"] +# scope: ["openid", "profile", "email", "groups"] # # # Only verified email addresses are synchronized to the user profile by # # default. Unverified emails may be allowed in case an identity provider diff --git a/docs/ref/oidc.md b/docs/ref/oidc.md index 3d2e8764..78e542b0 100644 --- a/docs/ref/oidc.md +++ b/docs/ref/oidc.md @@ -240,13 +240,96 @@ endpoint. | username | `preferred_username` | Depends on identity provider, eg: `ssmith`, `ssmith@idp.example.com`, `\\example.com\ssmith` | | profile picture | `picture` | URL to a profile picture or avatar | | provider identifier | `iss`, `sub` | A stable and unique identifier for a user, typically a combination of `iss` and `sub` OIDC claims | -| | `groups` | [Only used to filter for allowed groups](#authorize-users-with-filters) | +| group membership | `groups` | Used for [access filtering](#authorize-users-with-filters) and stored for external integrations | + +## Group Storage and Integration + +Starting with Headscale v0.24.0, OIDC group membership is automatically extracted from authentication claims and stored in the database. This enables external integrations (such as web interfaces) to implement role-based access control based on a user's group membership. + +### Group Storage +- Groups are extracted from both ID tokens and UserInfo endpoint responses +- Group membership is updated on every successful OIDC login +- Groups are stored as JSON in the user database for external access +- Multiple group claim formats are supported (`groups`, `roles`, provider-specific claims) + +### External Integration +External applications can query user group membership for implementing role-based access control: + +```bash +# View user groups via Headscale CLI +headscale users list --output json + +# Example database query (for direct database access) +SELECT name, email, groups FROM users WHERE provider = 'oidc'; +``` + +### Scope Requirements +To enable group storage, ensure your OIDC configuration includes the `groups` scope: + +```yaml +oidc: + issuer: "https://sso.example.com" + client_id: "headscale" + client_secret: "generated-secret" + scope: ["openid", "profile", "email", "groups"] +``` + +### Identity provider configuration + +The `groups` scope is not part of the OpenID Connect core specification, so +different identity providers handle it differently. Headscale stores whatever +the provider emits, so misconfiguration on the IdP side shows up as garbage +in the `users.groups` column. + +!!! warning "Authentik: do not attach a custom `groups` property mapping" + + Authentik ships a built-in `groups` scope that emits the user's + `ak_groups.all()` as the `groups` claim automatically. If you also + attach a custom OAuth2 scope mapping with `scope_name: groups` to the + provider, Authentik runs *both* emitters and concatenates their outputs. + A user in groups `admins` and `users` ends up with + `["admins","users","admins","users"]` in the `users.groups` column. + + The fix is to remove the custom mapping from the provider's property + mappings — the built-in `groups` scope alone produces exactly what + Headscale wants. Verified against Authentik 2025.8. + +!!! info "Keycloak and other providers" + + Keycloak does not emit a `groups` claim by default. Create a "Group + Membership" mapper on the client scope `groups` (or directly on the + client) with token claim name `groups` and tick *Add to ID token*, + *Add to access token*, and *Add to userinfo*. + + Other providers may need similar explicit configuration. If group + membership is missing from the stored `users.groups` column, check + the wire data first — the most common failure mode is "the provider + isn't emitting the claim", not "headscale isn't reading it". + +### Headplane Integration +When using [Headplane](https://github.com/tale/headplane) as a web interface for Headscale, OIDC groups enable automatic role-based access control: + +- **Automatic Role Assignment**: Users are assigned roles based on their OIDC group membership +- **Zero-Trust Security**: New users receive minimal access until proper groups are assigned +- **Dynamic Updates**: User roles update automatically on each login based on current group membership +- **Configurable Mapping**: Organizations can customize which groups map to which roles + +Example Headplane role mapping configuration: +```yaml +role_mapping: + owner: ["ceo", "cto", "headscale-owner"] + admin: ["it-admin", "platform-admin"] + network_admin: ["network-team", "devops"] + auditor: ["compliance", "audit-team"] +``` + +For detailed Headplane OIDC configuration, see the [Headplane documentation](https://github.com/tale/headplane/docs). ## Limitations - Support for OpenID Connect aims to be generic and vendor independent. It offers only limited support for quirks of specific identity providers. -- OIDC groups cannot be used in policy rules. +- OIDC groups cannot be used in policy rules directly (use external integrations for role-based access control). - The username provided by the identity provider needs to adhere to this pattern: - The username must be at least two characters long. - It must only contain letters, digits, hyphens, dots, underscores, and up to a single `@`. diff --git a/flake.lock b/flake.lock index 339d9dd1..e505bc31 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1777270315, - "narHash": "sha256-yKB4G6cKsQsWN7M6rZGk6gkJPDNPIzT05y4qzRyCDlI=", + "lastModified": 1779351318, + "narHash": "sha256-f+JACbTqzZ+G92DSnXOUGRhGANb8Blh7CoeYOeBF8/U=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "6368eda62c9775c38ef7f714b2555a741c20c72d", + "rev": "4a29d733e8a7d5b824c3d8c958a946a9867b3eb2", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index ef0b40b8..325a7a0f 100644 --- a/flake.nix +++ b/flake.nix @@ -62,16 +62,16 @@ protoc-gen-grpc-gateway = buildGo rec { pname = "grpc-gateway"; - version = "2.28.0"; + version = "2.29.0"; src = pkgs.fetchFromGitHub { owner = "grpc-ecosystem"; repo = "grpc-gateway"; rev = "v${version}"; - sha256 = "sha256-93omvHb+b+S0w4D+FGEEwYYDjgumJFDAruc1P4elfvA="; + sha256 = "sha256-d9OIIGttyMBSNgpS6mbR5JEIm13qGu2gFHJazJAexdw="; }; - vendorHash = "sha256-jVP5zfFPfHeAEApKNJzZwuZLA+DjKgkL7m2DFG72UNs="; + vendorHash = "sha256-p51yD+v8+rPs+ztlX7r0VQ4XlwUkxu+PxgknKEvH00k="; nativeBuildInputs = [ pkgs.installShellFiles ]; @@ -97,16 +97,16 @@ # Build golangci-lint with Go 1.26 (upstream uses hardcoded Go version) golangci-lint = buildGo rec { pname = "golangci-lint"; - version = "2.11.4"; + version = "2.12.2"; src = pkgs.fetchFromGitHub { owner = "golangci"; repo = "golangci-lint"; rev = "v${version}"; - hash = "sha256-B19aLvfNRY9TOYw/71f2vpNUuSIz8OI4dL0ijGezsas="; + hash = "sha256-qR7fp1x2S+EwEAcplRHTvA3jWwLr/XSiYKSZtAwkrNU="; }; - vendorHash = "sha256-xuoj4+U4tB5gpABKq4Dbp2cxnljxdYoBbO8A7DqPM5E="; + vendorHash = "sha256-AG5wtLwWLz55bdp1oi3cW+9O3yj1W1P7MV9zxym7Pb4="; subPackages = [ "cmd/golangci-lint" ]; diff --git a/flakehashes.json b/flakehashes.json index d585e2e0..ecdb27e7 100644 --- a/flakehashes.json +++ b/flakehashes.json @@ -1,6 +1,6 @@ { "vendor": { - "goModSum": "sha256-nrTbTihH4NpnN817QxQEBZV5c7eneEcHl+AMMh8ehJY=", - "sri": "sha256-Y9f0Q2Kw07eB8bURLT0jce+YoSs2WoowEX7t8tkNDvw=" + "goModSum": "sha256-xos6ixeJRMrEengGJaRiSsrm+3S3R0OEsnv1e85Sqhw=", + "sri": "sha256-bZod9sUUyQ67x/HzZrQ7SK+o5gAUxJhx7Rr6VdIUj1I=" } } diff --git a/gen/go/headscale/v1/headscale_grpc.pb.go b/gen/go/headscale/v1/headscale_grpc.pb.go index 9c1e38e7..07e09b42 100644 --- a/gen/go/headscale/v1/headscale_grpc.pb.go +++ b/gen/go/headscale/v1/headscale_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: headscale/v1/headscale.proto diff --git a/gen/go/headscale/v1/user.pb.go b/gen/go/headscale/v1/user.pb.go index 5f05d084..c1268e26 100644 --- a/gen/go/headscale/v1/user.pb.go +++ b/gen/go/headscale/v1/user.pb.go @@ -32,6 +32,10 @@ type User struct { ProviderId string `protobuf:"bytes,6,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` Provider string `protobuf:"bytes,7,opt,name=provider,proto3" json:"provider,omitempty"` ProfilePicUrl string `protobuf:"bytes,8,opt,name=profile_pic_url,json=profilePicUrl,proto3" json:"profile_pic_url,omitempty"` + // OIDC group memberships extracted from the identity provider's + // `groups` claim at login. Populated by hscontrol/types.User.FromClaim. + // External tools (Headplane, automation) use this for role-based access. + Groups []string `protobuf:"bytes,9,rep,name=groups,proto3" json:"groups,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -122,6 +126,13 @@ func (x *User) GetProfilePicUrl() string { return "" } +func (x *User) GetGroups() []string { + if x != nil { + return x.Groups + } + return nil +} + type CreateUserRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -518,7 +529,7 @@ var File_headscale_v1_user_proto protoreflect.FileDescriptor const file_headscale_v1_user_proto_rawDesc = "" + "\n" + - "\x17headscale/v1/user.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x83\x02\n" + + "\x17headscale/v1/user.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x9b\x02\n" + "\x04User\x12\x0e\n" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x129\n" + @@ -529,7 +540,8 @@ const file_headscale_v1_user_proto_rawDesc = "" + "\vprovider_id\x18\x06 \x01(\tR\n" + "providerId\x12\x1a\n" + "\bprovider\x18\a \x01(\tR\bprovider\x12&\n" + - "\x0fprofile_pic_url\x18\b \x01(\tR\rprofilePicUrl\"\x81\x01\n" + + "\x0fprofile_pic_url\x18\b \x01(\tR\rprofilePicUrl\x12\x16\n" + + "\x06groups\x18\t \x03(\tR\x06groups\"\x81\x01\n" + "\x11CreateUserRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12!\n" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x14\n" + diff --git a/gen/openapiv2/headscale/v1/headscale.swagger.json b/gen/openapiv2/headscale/v1/headscale.swagger.json index 545cf0b5..99480845 100644 --- a/gen/openapiv2/headscale/v1/headscale.swagger.json +++ b/gen/openapiv2/headscale/v1/headscale.swagger.json @@ -1528,6 +1528,13 @@ }, "profilePicUrl": { "type": "string" + }, + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "description": "OIDC group memberships extracted from the identity provider's\n`groups` claim at login. Populated by hscontrol/types.User.FromClaim.\nExternal tools (Headplane, automation) use this for role-based access." } } } diff --git a/go.mod b/go.mod index 946763c1..cb8d955d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/juanfont/headscale -go 1.26.2 +go 1.26.3 require ( github.com/arl/statsviz v0.8.0 @@ -8,20 +8,20 @@ require ( github.com/chasefleming/elem-go v0.31.0 github.com/coder/websocket v1.8.14 github.com/coreos/go-oidc/v3 v3.18.0 - github.com/creachadair/command v0.2.2 - github.com/creachadair/flax v0.0.5 + github.com/creachadair/command v0.2.6 + github.com/creachadair/flax v0.0.6 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/docker/docker v28.5.2+incompatible - github.com/fsnotify/fsnotify v1.9.0 + github.com/fsnotify/fsnotify v1.10.1 github.com/glebarez/sqlite v1.11.0 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/metrics v0.1.1 github.com/go-gormigrate/gormigrate/v2 v2.1.5 - github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 + github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 github.com/gofrs/uuid/v5 v5.4.0 github.com/google/go-cmp v0.7.0 github.com/gorilla/mux v1.8.1 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/jagottsicher/termcolor v1.0.2 github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 @@ -31,31 +31,32 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/prometheus/common v0.67.5 github.com/pterm/pterm v0.12.83 - github.com/puzpuzpuz/xsync/v4 v4.4.0 - github.com/rs/zerolog v1.35.0 + github.com/puzpuzpuz/xsync/v4 v4.5.0 + github.com/realclientip/realclientip-go v1.0.0 + github.com/rs/zerolog v1.35.1 github.com/samber/lo v1.53.0 github.com/sasha-s/go-deadlock v0.3.9 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd - github.com/tailscale/squibble v0.0.0-20260303070345-3ac5157f405e - github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c + github.com/tailscale/squibble v0.0.0-20260411062017-141f5d618bc4 + github.com/tailscale/tailsql v0.0.0-20260521144131-377d992d0d71 github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e go4.org/netipx v0.0.0-20231129151722-fdeea329fbba - golang.org/x/crypto v0.50.0 - golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 - golang.org/x/net v0.53.0 + golang.org/x/crypto v0.52.0 + golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a + golang.org/x/net v0.55.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 - google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d - google.golang.org/grpc v1.80.0 + google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 + google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 - pgregory.net/rapid v1.2.0 - tailscale.com v1.97.0-pre.0.20260429005429-40088602c960 + pgregory.net/rapid v1.3.0 + tailscale.com v1.98.3 zombiezen.com/go/postgrestest v1.0.1 ) @@ -77,10 +78,10 @@ require ( // together, e.g: // go get modernc.org/libc@v1.55.3 modernc.org/sqlite@v1.33.1 require ( - modernc.org/libc v1.70.0 // indirect + modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.48.2 + modernc.org/sqlite v1.50.1 ) // NOTE: gvisor must be updated in lockstep with @@ -129,7 +130,7 @@ require ( github.com/containerd/continuity v0.4.5 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/creachadair/mds v0.26.2 // indirect + github.com/creachadair/mds v0.28.0 // indirect github.com/creachadair/msync v0.8.2 // indirect github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect @@ -144,7 +145,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gaissmai/bart v0.26.1 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect - github.com/go-jose/go-jose/v3 v3.0.4 // indirect + github.com/go-jose/go-jose/v3 v3.0.5 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -168,7 +169,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -203,10 +204,8 @@ require ( github.com/pires/go-proxyproto v0.9.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus-community/pro-bing v0.7.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/procfs v0.19.2 // indirect - github.com/realclientip/realclientip-go v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/safchain/ethtool v0.7.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect @@ -219,9 +218,9 @@ require ( github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect - github.com/tailscale/setec v0.0.0-20260115174028-19d190c5556d // indirect + github.com/tailscale/setec v0.0.0-20260310221408-dcd97e42f251 // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368 // indirect - github.com/tailscale/wireguard-go v0.0.0-20260304043104-4184faf59e56 // indirect + github.com/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e // indirect github.com/toqueteos/webbrowser v1.2.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect @@ -229,24 +228,24 @@ require ( github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect - golang.org/x/image v0.27.0 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/term v0.42.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/image v0.40.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/tools v0.45.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 // indirect k8s.io/client-go v0.34.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect diff --git a/go.sum b/go.sum index 2a8b3780..9c6eebc4 100644 --- a/go.sum +++ b/go.sum @@ -129,12 +129,12 @@ github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8 github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creachadair/command v0.2.2 h1:4RGsUhqFf1imFC+vMWOOCiQdncThCdcdMJp0JNCjxxc= -github.com/creachadair/command v0.2.2/go.mod h1:Z6Zp6CSJcnaWWR4wHgdqzODnFdxFJAaa/DrcVkeUu3E= -github.com/creachadair/flax v0.0.5 h1:zt+CRuXQASxwQ68e9GHAOnEgAU29nF0zYMHOCrL5wzE= -github.com/creachadair/flax v0.0.5/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8= -github.com/creachadair/mds v0.26.2 h1:rCtvEV/bCRY0hGfwvvMg0p3yzKgBE8l/9OV4fjF9QQ8= -github.com/creachadair/mds v0.26.2/go.mod h1:dMBTCSy3iS3dwh4Rb1zxeZz2d7K8+N24GCTsayWtQRI= +github.com/creachadair/command v0.2.6 h1:+d4xUSZza5nqRVVsVRf5jDbix/0uKxVqFrc30F4uyRI= +github.com/creachadair/command v0.2.6/go.mod h1:HWvS3zEpVakKlNXxT/j1U8tNoDaY7By7ORYQ+m1ha2U= +github.com/creachadair/flax v0.0.6 h1:IFUdRdfKynNTyR5SRlrHMUGnxxMZZen8RVpwcDq/ftk= +github.com/creachadair/flax v0.0.6/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8= +github.com/creachadair/mds v0.28.0 h1:VS4JsBSlClsqBrHT8cYMTMqFHJdU0P/Z1ES6zjnSMXo= +github.com/creachadair/mds v0.28.0/go.mod h1:dMBTCSy3iS3dwh4Rb1zxeZz2d7K8+N24GCTsayWtQRI= github.com/creachadair/msync v0.8.2 h1:ujvc/SVJPn+bFwmjUHucXNTTn3opVe2YbQ46mBCnP08= github.com/creachadair/msync v0.8.2/go.mod h1:LzxqD9kfIl/O3DczkwOgJplLPqwrTbIhINlf9bHIsEY= github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= @@ -174,8 +174,8 @@ github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= @@ -192,12 +192,12 @@ github.com/go-chi/metrics v0.1.1 h1:CXhbnkAVVjb0k73EBRQ6Z2YdWFnbXZgNtg1Mboguibk= github.com/go-chi/metrics v0.1.1/go.mod h1:mcGTM1pPalP7WCtb+akNYFO/lwNwBBLCuedepqjoPn4= github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8= github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M= -github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= -github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= +github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= -github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= -github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA= +github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -258,8 +258,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -280,8 +280,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= -github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jagottsicher/termcolor v1.0.2 h1:fo0c51pQSuLBN1+yVX2ZE+hE+P7ULb/TY8eRowJnrsM= @@ -404,8 +404,6 @@ github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Q github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA= -github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -423,8 +421,8 @@ github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5b github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA= github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk= -github.com/puzpuzpuz/xsync/v4 v4.4.0 h1:vlSN6/CkEY0pY8KaB0yqo/pCLZvp9nhdbBdjipT4gWo= -github.com/puzpuzpuz/xsync/v4 v4.4.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= +github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg= +github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= github.com/realclientip/realclientip-go v1.0.0 h1:+yPxeC0mEaJzq1BfCt2h4BxlyrvIIBzR6suDc3BEF1U= github.com/realclientip/realclientip-go v1.0.0/go.mod h1:CXnUdVwFRcXFJIRb/dTYqbT7ud48+Pi2pFm80bxDmcI= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -432,8 +430,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= -github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/safchain/ethtool v0.7.0 h1:rlJzfDetsVvT61uz8x1YIcFn12akMfuPulHtZjtb7Is= github.com/safchain/ethtool v0.7.0/go.mod h1:MenQKEjXdfkjD3mp2QdCk8B/hwvkrlOTm/FD4gTpFxQ= @@ -489,18 +487,18 @@ github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4 github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= -github.com/tailscale/setec v0.0.0-20260115174028-19d190c5556d h1:N+TtzIaGYREbLbKZB0WU0vVnMSfaqUkSf3qMEi03hwE= -github.com/tailscale/setec v0.0.0-20260115174028-19d190c5556d/go.mod h1:6NU8H/GLPVX2TnXAY1duyy9ylLaHwFpr0X93UPiYmNI= -github.com/tailscale/squibble v0.0.0-20260303070345-3ac5157f405e h1:4yfp5/YDr+TzbUME/PalYJVXAsp7zA2Gv2xQMZ9Qors= -github.com/tailscale/squibble v0.0.0-20260303070345-3ac5157f405e/go.mod h1:xJkMmR3t+thnUQhA3Q4m2VSlS5pcOq+CIjmU/xfKKx4= -github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c h1:7lJQ/zycbk1E9e0nUiMuwIDYprFTLpWXUwiPdi+tRlI= -github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c/go.mod h1:bpNmZdvZKmBstrZunT+NXL6hmrFw5AsuT7MGiYS8sRc= +github.com/tailscale/setec v0.0.0-20260310221408-dcd97e42f251 h1:kNnJlwxSzue+VRJuDdZ/yebcMM2q9KqFmK9xQqH1YRc= +github.com/tailscale/setec v0.0.0-20260310221408-dcd97e42f251/go.mod h1:6NU8H/GLPVX2TnXAY1duyy9ylLaHwFpr0X93UPiYmNI= +github.com/tailscale/squibble v0.0.0-20260411062017-141f5d618bc4 h1:1ghkd9YIC4J7umZuu9jZz8afWJSj1hCRSzfvdI2Q3Vo= +github.com/tailscale/squibble v0.0.0-20260411062017-141f5d618bc4/go.mod h1:EVp9PDh7v69Do6aNzFfLvL0fdCph3XskGMi+WcS/uOM= +github.com/tailscale/tailsql v0.0.0-20260521144131-377d992d0d71 h1:IXwFgoMvxnXLCmQldzyuOO+b8Ko1cxqeuz5o3oaMkj8= +github.com/tailscale/tailsql v0.0.0-20260521144131-377d992d0d71/go.mod h1:N2Dm+UzRWz01zPMDl5XHkSVq91kNPWU13uUUibcOm+c= github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368 h1:0tpDdAj9sSfSZg4gMwNTdqMP592sBrq2Sm0w6ipnh7k= github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= -github.com/tailscale/wireguard-go v0.0.0-20260304043104-4184faf59e56 h1:/R1vu+eNhg1eKstmVPEKvsJgkh4TUyb+J+Eadwv+d/I= -github.com/tailscale/wireguard-go v0.0.0-20260304043104-4184faf59e56/go.mod h1:zvaAPQrjUBWufXgqpSQ1/BYu9ZFOKnsNWLFQe+E78cM= +github.com/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e h1:GexFR7ak1iz26fxg8HWCpOEqAOL8UEZJ7J3JxeCalDs= +github.com/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= @@ -532,24 +530,24 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= @@ -563,25 +561,25 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/W golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= -golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= +golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -607,8 +605,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -616,24 +614,24 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= @@ -641,12 +639,12 @@ golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI= -google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 h1:DddG61lE5LkX6144z22i0gma9BMBs5aZ9B8lZLobxyw= +google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 h1:eZCjr/aAF8c5ccm5pb6T4EXgIei5MlAAPWPJk+5ArfY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -676,10 +674,10 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= -modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= -modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= -modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= @@ -688,29 +686,29 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= -modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c= -modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= +modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= -pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc= +pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.97.0-pre.0.20260429005429-40088602c960 h1:I56vAGia4DV24Dbv8N07F/Awtnguvmm7PAgWCxCIdqw= -tailscale.com v1.97.0-pre.0.20260429005429-40088602c960/go.mod h1:8nwFkmNdNRtTIM2dkmr/DhbzSKeLmzusWOTacX1zVKk= +tailscale.com v1.98.3 h1:caAbG4UfkKfKPE6b1fj5t4ep5qrwEis5AJu91ruvePw= +tailscale.com v1.98.3/go.mod h1:U23ZwbZlKJMNU7CScy+lCVVlece/S5n09q0nyudncBI= zombiezen.com/go/postgrestest v1.0.1 h1:aXoADQAJmZDU3+xilYVut0pHhgc0sF8ZspPW9gFNwP4= zombiezen.com/go/postgrestest v1.0.1/go.mod h1:marlZezr+k2oSJrvXHnZUs1olHqpE9czlz8ZYkVxliQ= diff --git a/hscontrol/capver/capver.go b/hscontrol/capver/capver.go index e145b935..86a653dd 100644 --- a/hscontrol/capver/capver.go +++ b/hscontrol/capver/capver.go @@ -12,24 +12,17 @@ import ( "tailscale.com/util/set" ) -const ( - // minVersionParts is the minimum number of version parts needed for major.minor. - minVersionParts = 2 +// minVersionParts is the minimum number of version parts needed for major.minor. +const minVersionParts = 2 - // legacyDERPCapVer is the capability version when LegacyDERP can be cleaned up. - legacyDERPCapVer = 111 -) - -// CanOldCodeBeCleanedUp is intended to be called on startup to see if -// there are old code that can ble cleaned up, entries should contain -// a [tailcfg.CapabilityVersion] where something can be cleaned up and a panic if it can. -// This is only intended to catch things in tests. +// CanOldCodeBeCleanedUp is called at server startup to panic when +// [MinSupportedCapabilityVersion] has crossed a threshold at which a +// backwards-compat emit path can be deleted. Each entry pairs a +// [tailcfg.CapabilityVersion] threshold with the message identifying +// the code to remove; today there are none. // -// All uses of Capability version checks should be listed here. +// All capability-version-gated cleanups should be registered here. func CanOldCodeBeCleanedUp() { - if MinSupportedCapabilityVersion >= legacyDERPCapVer { - panic("LegacyDERP can be cleaned up in tail.go") - } } func tailscaleVersSorted() []string { diff --git a/hscontrol/capver/capver_generated.go b/hscontrol/capver/capver_generated.go index 72318ae5..1091f42b 100644 --- a/hscontrol/capver/capver_generated.go +++ b/hscontrol/capver/capver_generated.go @@ -42,6 +42,7 @@ var tailscaleToCapVer = map[string]tailcfg.CapabilityVersion{ "v1.92": 131, "v1.94": 131, "v1.96": 133, + "v1.98": 138, } var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{ @@ -77,6 +78,7 @@ var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{ 130: "v1.90", 131: "v1.92", 133: "v1.96", + 138: "v1.98", } // SupportedMajorMinorVersions is the number of major.minor Tailscale versions supported. @@ -84,4 +86,4 @@ const SupportedMajorMinorVersions = 10 // MinSupportedCapabilityVersion represents the minimum capability version // supported by this Headscale instance (latest 10 minor versions) -const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 109 +const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 113 diff --git a/hscontrol/capver/capver_test_data.go b/hscontrol/capver/capver_test_data.go index 83bccc08..7309459a 100644 --- a/hscontrol/capver/capver_test_data.go +++ b/hscontrol/capver/capver_test_data.go @@ -9,10 +9,9 @@ var tailscaleLatestMajorMinorTests = []struct { stripV bool expected []string }{ - {3, false, []string{"v1.92", "v1.94", "v1.96"}}, - {2, true, []string{"1.94", "1.96"}}, + {3, false, []string{"v1.94", "v1.96", "v1.98"}}, + {2, true, []string{"1.96", "1.98"}}, {10, true, []string{ - "1.78", "1.80", "1.82", "1.84", @@ -22,6 +21,7 @@ var tailscaleLatestMajorMinorTests = []struct { "1.92", "1.94", "1.96", + "1.98", }}, {0, false, nil}, } @@ -30,7 +30,7 @@ var capVerMinimumTailscaleVersionTests = []struct { input tailcfg.CapabilityVersion expected string }{ - {109, "v1.78"}, + {113, "v1.80"}, {32, "v1.24"}, {41, "v1.30"}, {46, "v1.32"}, diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index d95b7496..ad0dd6f6 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -215,6 +215,27 @@ AND auth_key_id NOT IN ( }, Rollback: func(db *gorm.DB) error { return nil }, }, + // Add groups column to users table for OIDC role mapping. + // Must run before any migration that loads users via the User struct + // (e.g., 202505141324), since the User struct now includes Groups. + { + ID: "202505141323", + Migrate: func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn(&types.User{}, "groups") { + err := tx.Migrator().AddColumn(&types.User{}, "groups") + if err != nil { + return fmt.Errorf("adding groups column to users table: %w", err) + } + } + return nil + }, + Rollback: func(db *gorm.DB) error { + if db.Migrator().HasColumn(&types.User{}, "groups") { + return db.Migrator().DropColumn(&types.User{}, "groups") + } + return nil + }, + }, // Fix the provider identifier for users that have a double slash in the // provider identifier. { @@ -315,6 +336,7 @@ AND auth_key_id NOT IN ( provider_identifier text, provider text, profile_pic_url text, + groups text, created_at datetime, updated_at datetime, deleted_at datetime @@ -381,8 +403,8 @@ AND auth_key_id NOT IN ( // Copy data directly using SQL dataCopySQL := []string{ - `INSERT INTO users (id, name, display_name, email, provider_identifier, provider, profile_pic_url, created_at, updated_at, deleted_at) - SELECT id, name, display_name, email, provider_identifier, provider, profile_pic_url, created_at, updated_at, deleted_at + `INSERT INTO users (id, name, display_name, email, provider_identifier, provider, profile_pic_url, groups, created_at, updated_at, deleted_at) + SELECT id, name, display_name, email, provider_identifier, provider, profile_pic_url, groups, created_at, updated_at, deleted_at FROM users_old`, `INSERT INTO pre_auth_keys (id, key, user_id, reusable, ephemeral, used, tags, expiration, created_at) @@ -722,6 +744,28 @@ WHERE tags IS NOT NULL AND tags != '[]' AND tags != ''; }, Rollback: func(db *gorm.DB) error { return nil }, }, + { + // Clear zero-time node expiry values to NULL. + // Versions before 0.28 persisted a pointer to a zero + // time.Time as '0001-01-01 00:00:00+00:00' rather than + // NULL, which 0.29 reports as an expired node. This + // normalises the existing rows so the column once + // again means "no expiry" when unset. + ID: "202605221435-clear-zero-time-node-expiry", + Migrate: func(tx *gorm.DB) error { + err := tx.Exec(` +UPDATE nodes +SET expiry = NULL +WHERE expiry IS NOT NULL AND expiry < '1900-01-01'; + `).Error + if err != nil { + return fmt.Errorf("clearing zero-time node expiry: %w", err) + } + + return nil + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, }, ) @@ -978,6 +1022,7 @@ func runMigrations(cfg types.DatabaseConfig, dbConn *gorm.DB, migrations *gormig "202502131714", "202502171819", "202505091439", + "202505141323", "202505141324", // As of 2025-07-02, no new IDs should be added here. diff --git a/hscontrol/db/db_test.go b/hscontrol/db/db_test.go index 20e95e7b..60abcfa0 100644 --- a/hscontrol/db/db_test.go +++ b/hscontrol/db/db_test.go @@ -144,6 +144,60 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) { assert.NotContains(t, node7.Tags, "tag:forbidden", "node7 should NOT have tag:forbidden (unauthorized)") }, }, + // Test for the zero-time node expiry migration + // (202605221435-clear-zero-time-node-expiry). Pre-0.28 versions + // stored a zero time.Time as '0001-01-01 00:00:00+00:00' rather + // than NULL, which caused 0.29 to report those nodes as expired. + // Fixes: https://github.com/juanfont/headscale/issues/3284 + { + dbPath: "testdata/sqlite/zero_time_expiry_migration_test.sql", + wantFunc: func(t *testing.T, hsdb *HSDatabase) { + t.Helper() + + nodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) { + return ListNodes(rx) + }) + require.NoError(t, err) + require.Len(t, nodes, 5, "should have all 5 nodes") + + byHostname := make(map[string]*types.Node, len(nodes)) + for _, n := range nodes { + byHostname[n.Hostname] = n + } + + // Node 1 had a zero-time expiry; should be cleared. + node1 := byHostname["node1"] + require.NotNil(t, node1, "node1 should exist") + assert.Nil(t, node1.Expiry, "node1 zero-time expiry should be cleared to NULL") + assert.False(t, node1.IsExpired(), "node1 should not be reported as expired") + + // Node 2 already had NULL expiry; should still be NULL. + node2 := byHostname["node2"] + require.NotNil(t, node2, "node2 should exist") + assert.Nil(t, node2.Expiry, "node2 NULL expiry should be preserved") + assert.False(t, node2.IsExpired(), "node2 should not be reported as expired") + + // Node 3 had a real future expiry; should be preserved. + node3 := byHostname["node3"] + require.NotNil(t, node3, "node3 should exist") + require.NotNil(t, node3.Expiry, "node3 future expiry should be preserved") + assert.Equal(t, 2099, node3.Expiry.UTC().Year(), "node3 expiry year should be 2099") + assert.False(t, node3.IsExpired(), "node3 with future expiry should not be expired") + + // Node 4 had a real past expiry; should be preserved. + node4 := byHostname["node4"] + require.NotNil(t, node4, "node4 should exist") + require.NotNil(t, node4.Expiry, "node4 past expiry should be preserved") + assert.Equal(t, 2020, node4.Expiry.UTC().Year(), "node4 expiry year should be 2020") + assert.True(t, node4.IsExpired(), "node4 with past expiry should still be expired") + + // Node 5 also had a zero-time expiry; should be cleared. + node5 := byHostname["node5"] + require.NotNil(t, node5, "node5 should exist") + assert.Nil(t, node5.Expiry, "node5 zero-time expiry should be cleared to NULL") + assert.False(t, node5.IsExpired(), "node5 should not be reported as expired") + }, + }, } for _, tt := range tests { diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql index 781446c0..d6511404 100644 --- a/hscontrol/db/schema.sql +++ b/hscontrol/db/schema.sql @@ -12,6 +12,7 @@ CREATE TABLE users( provider_identifier text, provider text, profile_pic_url text, + groups text, created_at datetime, updated_at datetime, diff --git a/hscontrol/db/testdata/sqlite/zero_time_expiry_migration_test.sql b/hscontrol/db/testdata/sqlite/zero_time_expiry_migration_test.sql new file mode 100644 index 00000000..9a6d3185 --- /dev/null +++ b/hscontrol/db/testdata/sqlite/zero_time_expiry_migration_test.sql @@ -0,0 +1,82 @@ +-- Test SQL dump for zero-time node expiry migration +-- (202605221435-clear-zero-time-node-expiry) +-- +-- Pre-0.28 versions of Headscale persisted a zero time.Time as the string +-- '0001-01-01 00:00:00+00:00' in nodes.expiry instead of NULL. Upgrading +-- to 0.29 surfaces those rows as "expired" because they look like a +-- timestamp at year 1. This dump exercises the data fix. +-- Fixes: https://github.com/juanfont/headscale/issues/3284 + +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; + +-- Migrations table: all entries BEFORE the zero-time fix have been applied. +-- The new migration is intentionally absent so it runs against this dump. +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'); +INSERT INTO migrations VALUES('202507021200'); +INSERT INTO migrations VALUES('202510311551'); +INSERT INTO migrations VALUES('202511101554-drop-old-idx'); +INSERT INTO migrations VALUES('202511011637-preauthkey-bcrypt'); +INSERT INTO migrations VALUES('202511122344-remove-newline-index'); +INSERT INTO migrations VALUES('202511131445-node-forced-tags-to-tags'); +INSERT INTO migrations VALUES('202601121700-migrate-hostinfo-request-tags'); +INSERT INTO migrations VALUES('202602201200-clear-tagged-node-user-id'); + +-- Users table +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); +INSERT INTO users VALUES(1,'2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL,'user1','User One','user1@example.com',NULL,NULL,NULL); + +-- Pre-auth keys table +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,`prefix` text,`hash` blob,CONSTRAINT `fk_pre_auth_keys_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL); + +-- API keys table +CREATE TABLE `api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`prefix` text,`hash` blob,`created_at` datetime,`expiration` datetime,`last_seen` datetime); + +-- Nodes table - current schema (after the tags rename + last_seen/expiry reordering) +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,`tags` text,`auth_key_id` integer,`last_seen` datetime,`expiry` 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`)); + +-- Node 1: zero-time expiry. After migration: expiry IS NULL. +INSERT INTO nodes VALUES(1,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e01','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605501','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57701','[]','{}','100.64.0.1','fd7a:115c:a1e0::1','node1','node1',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00','0001-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL); + +-- Node 2: NULL expiry already. After migration: still NULL. +INSERT INTO nodes VALUES(2,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e02','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605502','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57702','[]','{}','100.64.0.2','fd7a:115c:a1e0::2','node2','node2',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00',NULL,'[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL); + +-- Node 3: real future expiry. After migration: preserved. +INSERT INTO nodes VALUES(3,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e03','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605503','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57703','[]','{}','100.64.0.3','fd7a:115c:a1e0::3','node3','node3',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00','2099-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL); + +-- Node 4: real past expiry (legitimately expired). After migration: preserved. +INSERT INTO nodes VALUES(4,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e04','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605504','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57704','[]','{}','100.64.0.4','fd7a:115c:a1e0::4','node4','node4',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00','2020-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL); + +-- Node 5: another zero-time row to confirm the WHERE clause matches multiple rows. +INSERT INTO nodes VALUES(5,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e05','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605505','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57705','[]','{}','100.64.0.5','fd7a:115c:a1e0::5','node5','node5',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00','0001-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL); + +-- Policies table (empty) +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('users',1); +INSERT INTO sqlite_sequence VALUES('nodes',5); +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 UNIQUE INDEX IF NOT EXISTS idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''; + +COMMIT; diff --git a/hscontrol/db/versioncheck.go b/hscontrol/db/versioncheck.go index 5c028f43..a071348c 100644 --- a/hscontrol/db/versioncheck.go +++ b/hscontrol/db/versioncheck.go @@ -3,6 +3,7 @@ package db import ( "errors" "fmt" + "regexp" "strconv" "strings" "time" @@ -139,10 +140,49 @@ func setDatabaseVersion(db *gorm.DB, version string) error { return nil } +// pseudoVersionTimeLayout is Go's pseudo-version timestamp layout +// (golang.org/ref/mod#pseudo-versions): UTC yyyymmddhhmmss. +const pseudoVersionTimeLayout = "20060102150405" + +// pseudoVersionSuffix matches the trailing "<14 digits>-<12 +// lowercase hex>" of a Go module pseudo-version. The base form +// (vX.0.0--) uses "-" before the timestamp; the +// pre-release-ancestor and release-ancestor forms +// (vX.Y.Z-pre.0.- and vX.Y.(Z+1)-0.-) use "." +// because the digit-only "0" marker precedes the timestamp. +var pseudoVersionSuffix = regexp.MustCompile(`[-.](\d{14})-[0-9a-f]{12}$`) + +// pseudoVersionTime returns the embedded commit time when v is a +// syntactically and semantically valid Go module pseudo-version. The +// timestamp must parse as a real UTC time; lookalikes with malformed +// dates (e.g. month 13, day 30 in February) are rejected. +func pseudoVersionTime(v string) (time.Time, bool) { + m := pseudoVersionSuffix.FindStringSubmatch(v) + if m == nil { + return time.Time{}, false + } + + t, err := time.Parse(pseudoVersionTimeLayout, m[1]) + if err != nil { + return time.Time{}, false + } + + return t, true +} + // isDev reports whether a version string represents a development build -// that should skip version checking. +// that should skip version checking. Go module pseudo-versions (used by +// untagged main-sha builds, where runtime/debug.BuildInfo falls back to +// vX.Y.Z--) are treated as dev to avoid poisoning +// database_versions with synthetic baselines. func isDev(version string) bool { - return version == "" || version == "dev" || version == "(devel)" + if version == "" || version == "dev" || version == "(devel)" { + return true + } + + _, ok := pseudoVersionTime(version) + + return ok } // checkVersionUpgradePath verifies that the running headscale version diff --git a/hscontrol/db/versioncheck_test.go b/hscontrol/db/versioncheck_test.go index 83c9ab5c..87089ca5 100644 --- a/hscontrol/db/versioncheck_test.go +++ b/hscontrol/db/versioncheck_test.go @@ -3,6 +3,7 @@ package db import ( "fmt" "testing" + "time" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" @@ -56,12 +57,206 @@ func TestSemverString(t *testing.T) { assert.Equal(t, "v0.28.3", s.String()) } +func TestPseudoVersionTime(t *testing.T) { + parseTS := func(s string) time.Time { + t.Helper() + + ts, err := time.Parse(pseudoVersionTimeLayout, s) + require.NoError(t, err) + + return ts + } + + tests := []struct { + name string + input string + wantOK bool + wantTime time.Time + }{ + // Accept: all three Go pseudo-version shapes. + { + name: "no ancestor tag (v0.0.0 base)", + input: "v0.0.0-20260522092201-58a85b68b3d9", + wantOK: true, + wantTime: parseTS("20260522092201"), + }, + { + name: "ancestor is pre-release tag", + input: "v0.29.0-beta.1.0.20260522092201-58a85b68b3d9", + wantOK: true, + wantTime: parseTS("20260522092201"), + }, + { + name: "ancestor is release tag", + input: "v0.29.1-0.20260522092201-58a85b68b3d9", + wantOK: true, + wantTime: parseTS("20260522092201"), + }, + { + name: "earliest realistic Go module date", + input: "v0.0.0-20180101000000-000000000000", + wantOK: true, + wantTime: parseTS("20180101000000"), + }, + + // Reject: real release tags must not look like pseudo-versions. + {name: "release tag", input: "v0.29.0"}, + {name: "pre-release tag", input: "v0.29.0-beta.1"}, + {name: "rc tag", input: "v0.29.0-rc1"}, + {name: "tag with build metadata", input: "v0.29.0+build123"}, + + // Reject: literals handled elsewhere. + {name: "empty", input: ""}, + {name: "dev literal", input: "dev"}, + {name: "devel literal", input: "(devel)"}, + + // Reject: malformed hash. + {name: "hash too short", input: "v0.0.0-20260522092201-58a85b6"}, + {name: "hash too long", input: "v0.0.0-20260522092201-58a85b68b3d9aa"}, + {name: "hash uppercase hex", input: "v0.0.0-20260522092201-58A85B68B3D9"}, + {name: "hash non-hex", input: "v0.0.0-20260522092201-zzzzzzzzzzzz"}, + + // Reject: malformed timestamp. + {name: "timestamp too short", input: "v0.0.0-2026052209220-58a85b68b3d9"}, + {name: "timestamp too long", input: "v0.0.0-202605220922010-58a85b68b3d9"}, + {name: "invalid month", input: "v0.0.0-20261322092201-58a85b68b3d9"}, + {name: "invalid day", input: "v0.0.0-20260230092201-58a85b68b3d9"}, + {name: "invalid hour", input: "v0.0.0-20260522252201-58a85b68b3d9"}, + {name: "invalid minute", input: "v0.0.0-20260522096001-58a85b68b3d9"}, + {name: "invalid second", input: "v0.0.0-20260522092260-58a85b68b3d9"}, + {name: "leap day on non-leap year", input: "v0.0.0-20230229000000-58a85b68b3d9"}, + + // Reject: missing components. + {name: "missing date and hash", input: "v0.0.0-"}, + {name: "missing hash", input: "v0.0.0-20260522092201-"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := pseudoVersionTime(tt.input) + assert.Equal(t, tt.wantOK, ok) + + if tt.wantOK { + assert.True(t, got.Equal(tt.wantTime), + "want %s, got %s", tt.wantTime, got) + } + }) + } +} + func TestIsDev(t *testing.T) { - assert.True(t, isDev("")) - assert.True(t, isDev("dev")) - assert.True(t, isDev("(devel)")) - assert.False(t, isDev("v0.28.0")) - assert.False(t, isDev("0.28.0")) + tests := []struct { + name string + input string + want bool + }{ + // Existing literals. + {name: "empty", input: "", want: true}, + {name: "dev", input: "dev", want: true}, + {name: "devel", input: "(devel)", want: true}, + {name: "release tag", input: "v0.28.0", want: false}, + {name: "release tag no v", input: "0.28.0", want: false}, + {name: "pre-release tag", input: "v0.29.0-beta.1", want: false}, + + // Go module pseudo-versions — all three shapes Go emits per + // golang.org/ref/mod#pseudo-versions. Untagged commits + // (such as main-sha docker builds) must be treated as dev + // so they neither poison database_versions nor trip the + // upgrade-path guard. + { + name: "pseudo v0.0.0 base", + input: "v0.0.0-20260522092201-58a85b68b3d9", + want: true, + }, + { + name: "pseudo from pre-release ancestor", + input: "v0.29.0-beta.1.0.20260522092201-58a85b68b3d9", + want: true, + }, + { + name: "pseudo from release ancestor", + input: "v0.29.1-0.20260522092201-58a85b68b3d9", + want: true, + }, + + // Malformed pseudo-version lookalikes must NOT be treated + // as dev — they fall through to the semver parser. + { + name: "malformed timestamp not dev", + input: "v0.0.0-20261322092201-58a85b68b3d9", + want: false, + }, + { + name: "hash wrong length not dev", + input: "v0.0.0-20260522092201-58a85b6", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isDev(tt.input)) + }) + } +} + +// TestCheckVersionUpgradePath_StoredPseudoVersion exercises the +// upgrade path when database_versions holds a Go module pseudo-version +// written by an untagged main-sha build. Without dev handling, the +// stored pseudo-version parses as v0.0.0 and the next real release +// trips the multi-minor guard. +func TestCheckVersionUpgradePath_StoredPseudoVersion(t *testing.T) { + tests := []struct { + name string + stored string + currentVersion string + }{ + { + name: "v0.0.0 base pseudo to real release", + stored: "v0.0.0-20260520093041-e4e742c776ee", + currentVersion: "v0.29.0-beta.1", + }, + { + name: "pseudo from pre-release ancestor", + stored: "v0.29.0-beta.1.0.20260520093041-e4e742c776ee", + currentVersion: "v0.29.0", + }, + { + name: "pseudo from release ancestor", + stored: "v0.28.1-0.20260520093041-e4e742c776ee", + currentVersion: "v0.29.0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := versionTestDB(t) + require.NoError(t, setDatabaseVersion(db, tt.stored)) + err := checkVersionUpgradePathFromVersions(db, tt.currentVersion) + assert.NoError(t, err) + }) + } +} + +// TestCheckVersionUpgradePath_CurrentPseudoDoesNotPoison locks the +// contract that a main-sha (pseudo-version) binary must preserve the +// stored real release so the next real release can upgrade cleanly. +// Mirrors the gating in db.go around setDatabaseVersion. +func TestCheckVersionUpgradePath_CurrentPseudoDoesNotPoison(t *testing.T) { + db := versionTestDB(t) + require.NoError(t, setDatabaseVersion(db, "v0.28.0")) + + current := "v0.0.0-20260522092201-58a85b68b3d9" + err := checkVersionUpgradePathFromVersions(db, current) + require.NoError(t, err) + + // Mirror db.go: only write the current version when !isDev. + if !isDev(current) { + require.NoError(t, setDatabaseVersion(db, current)) + } + + stored, err := getDatabaseVersion(db) + require.NoError(t, err) + assert.Equal(t, "v0.28.0", stored, + "pseudo-version run must not overwrite stored release") } // versionTestDB creates an in-memory SQLite database with the diff --git a/hscontrol/mapper/batcher.go b/hscontrol/mapper/batcher.go index 66b7b582..005dca8a 100644 --- a/hscontrol/mapper/batcher.go +++ b/hscontrol/mapper/batcher.go @@ -630,6 +630,9 @@ func (b *Batcher) processBatchedChanges() { return true } + // One policy recompute rebuilds the whole netmap; drop same-tick repeats. + pending = change.DedupePolicyChanges(pending) + // Queue a single work item containing all pending changes. // One item per node ensures a single worker processes them // sequentially, preventing out-of-order delivery. diff --git a/hscontrol/mapper/batcher_test.go b/hscontrol/mapper/batcher_test.go index 738c0b6a..49aeb54d 100644 --- a/hscontrol/mapper/batcher_test.go +++ b/hscontrol/mapper/batcher_test.go @@ -1104,6 +1104,72 @@ func TestBatcherWorkQueueBatching(t *testing.T) { } } +// TestBatcherCoalescesPolicyRecomputesPerTick proves that many identical +// broadcast policy changes arriving in a single batcher tick collapse to one +// runtime peer recompute per node. Without coalescing, each PolicyChange drives +// a separate full netmap rebuild for every connected node (the reconnect-storm +// fan-out); with it, a node sees at most one recompute per tick. +func TestBatcherCoalescesPolicyRecomputesPerTick(t *testing.T) { + for _, bf := range allBatcherFunctions { + t.Run(bf.name, func(t *testing.T) { + const ( + nodesPerUser = 4 + policyChangesPerTick = 8 + ) + + testData, cleanup := setupBatcherWithTestData(t, bf.fn, 1, nodesPerUser, 100) + defer cleanup() + + batcher := testData.Batcher + for i := range testData.Nodes { + n := &testData.Nodes[i] + require.NoError(t, batcher.AddNode(n.n.ID, n.ch, tailcfg.CapabilityVersion(100), nil)) + } + + // Many identical broadcast policy changes, then a DERP-map change as + // a sentinel. All land in one tick; the sentinel rides the same work + // item after the recompute(s), so its arrival marks the end of this + // tick's policy responses for a node. + for range policyChangesPerTick { + batcher.AddWork(change.PolicyChange()) + } + + batcher.AddWork(change.DERPMap()) + + for i := range testData.Nodes { + id := testData.Nodes[i].n.ID + ch := testData.Nodes[i].ch + + policyResponses := 0 + deadline := time.After(2 * time.Second) + + drain: + for { + select { + case resp := <-ch: + switch { + case resp.DERPMap != nil && len(resp.Peers) == 0: + // Sentinel: every policy recompute for this tick has + // already been delivered to this node. + break drain + case len(resp.PacketFilters) > 0 && len(resp.Peers) == 0: + // A runtime peer recompute (policyChangeResponse): + // packet filters and incremental peers, no full list. + policyResponses++ + } + case <-deadline: + t.Fatalf("node %d never received the DERP sentinel", id) + } + } + + assert.LessOrEqualf(t, policyResponses, 1, + "node %d received %d policy recomputes in one tick; identical recomputes must coalesce to one", + id, policyResponses) + } + }) + } +} + // TestBatcherWorkerChannelSafety tests that worker goroutines handle closed // channels safely without panicking when processing work items. // diff --git a/hscontrol/mapper/tail_test.go b/hscontrol/mapper/tail_test.go index f973caaf..ee5da85c 100644 --- a/hscontrol/mapper/tail_test.go +++ b/hscontrol/mapper/tail_test.go @@ -70,7 +70,6 @@ func TestTailNode(t *testing.T) { Name: "empty", StableID: "0", HomeDERP: 0, - LegacyDERPString: "127.3.3.40:0", Hostinfo: hiview(tailcfg.Hostinfo{}), MachineAuthorized: true, @@ -148,8 +147,7 @@ func TestTailNode(t *testing.T) { PrimaryRoutes: []netip.Prefix{ netip.MustParsePrefix("192.168.0.0/24"), }, - HomeDERP: 0, - LegacyDERPString: "127.3.3.40:0", + HomeDERP: 0, Hostinfo: hiview(tailcfg.Hostinfo{ RoutableIPs: []netip.Prefix{ tsaddr.AllIPv4(), @@ -186,7 +184,6 @@ func TestTailNode(t *testing.T) { Name: "minimal.example.com.", StableID: "0", HomeDERP: 0, - LegacyDERPString: "127.3.3.40:0", Hostinfo: hiview(tailcfg.Hostinfo{}), MachineAuthorized: true, diff --git a/hscontrol/policy/pm.go b/hscontrol/policy/pm.go index 5709a3f5..4c9dc397 100644 --- a/hscontrol/policy/pm.go +++ b/hscontrol/policy/pm.go @@ -36,6 +36,14 @@ type PolicyManager interface { // NodeCanApproveRoute reports whether the given node can approve the given route. NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool + // NodeNeedsPeerRecompute reports whether peers must recompute their + // netmap when the node's online state changes. True for subnet + // routers, relay targets (tailscale.com/cap/relay), and via targets; + // false for ordinary nodes, which only need a lightweight online or + // offline peer patch. [State.Connect] and [State.Disconnect] use it to + // avoid a tailnet-wide recompute on every ordinary reconnect. + NodeNeedsPeerRecompute(node types.NodeView) bool + // ViaRoutesForPeer computes via grant effects for a viewer-peer pair. // It returns which routes should be included (peer is via-designated for viewer) // and excluded (steered to a different peer). When no via grants apply, diff --git a/hscontrol/policy/v2/compiled.go b/hscontrol/policy/v2/compiled.go index 6019baa6..1f8c9ad4 100644 --- a/hscontrol/policy/v2/compiled.go +++ b/hscontrol/policy/v2/compiled.go @@ -594,6 +594,58 @@ func hasPerNodeGrants(grants []compiledGrant) bool { return false } +// collectRelayTargetIPs returns the set of IPs that are destinations of a +// tailscale.com/cap/relay grant. A node whose IP is in this set is a relay +// target: when it goes offline, peers holding a PeerRelay allocation through +// it must recompute their netmap to drop the now-dead allocation. The relay +// cap is carried on each grant's [tailcfg.CapGrant] with Dsts set to the +// resolved relay destinations (see [Policy.compileOtherDests]); the reversed +// companion rule carries [tailcfg.PeerCapabilityRelayTarget] instead and is +// intentionally skipped. +func collectRelayTargetIPs(grants []compiledGrant) (*netipx.IPSet, error) { + var b netipx.IPSetBuilder + + for i := range grants { + for _, rule := range grants[i].rules { + for _, cg := range rule.CapGrant { + if _, ok := cg.CapMap[tailcfg.PeerCapabilityRelay]; !ok { + continue + } + + for _, dst := range cg.Dsts { + b.AddPrefix(dst) + } + } + } + } + + return b.IPSet() +} + +// collectViaTargetTags returns the set of tags used as via targets across all +// grants. A node carrying any of these tags is a via target: peers steering +// traffic through it must recompute when it goes offline. Returns nil when no +// via grants exist. +func collectViaTargetTags(grants []compiledGrant) map[Tag]struct{} { + var tags map[Tag]struct{} + + for i := range grants { + if grants[i].via == nil { + continue + } + + for _, t := range grants[i].via.viaTags { + if tags == nil { + tags = make(map[Tag]struct{}) + } + + tags[t] = struct{}{} + } + } + + return tags +} + // globalFilterRules extracts global filter rules from [compiledGrant]s. // Via grants produce no global rules (they are per-node only); regular // grants contribute their full pre-compiled ruleset; self grants diff --git a/hscontrol/policy/v2/node_recompute_test.go b/hscontrol/policy/v2/node_recompute_test.go new file mode 100644 index 00000000..ed8c9db1 --- /dev/null +++ b/hscontrol/policy/v2/node_recompute_test.go @@ -0,0 +1,137 @@ +package v2 + +import ( + "net/netip" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "tailscale.com/tailcfg" +) + +// TestNodeNeedsPeerRecompute pins which node roles force peers to recompute +// their netmap when the node's online state changes. An ordinary node only +// needs the lightweight online/offline peer patch; subnet routers, relay +// targets, and via targets change what peers compute and therefore need a +// full recompute. The predicate is keyed on the flipping node, so an ordinary +// node in a tailnet that uses relay or via elsewhere must still be classified +// as not needing a recompute. +func TestNodeNeedsPeerRecompute(t *testing.T) { + users := types.Users{ + {Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"}, + } + + const allowAll = `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + + relayPol := `{ + "tagOwners": {"tag:relay": ["user1@"]}, + "grants": [ + {"src": ["*"], "dst": ["tag:relay"], "app": {"tailscale.com/cap/relay": [{}]}} + ] + }` + + viaPol := `{ + "tagOwners": {"tag:via": ["user1@"]}, + "grants": [ + {"src": ["*"], "dst": ["10.0.0.0/24"], "ip": ["*"], "via": ["tag:via"]} + ] + }` + + taildrivePol := `{ + "tagOwners": {"tag:drive": ["user1@"]}, + "grants": [ + {"src": ["*"], "dst": ["tag:drive"], "app": {"tailscale.com/cap/drive": [{}]}} + ] + }` + + ordinary := node("ordinary", "100.64.0.1", "fd7a:115c:a1e0::1", users[0]) + ordinary.ID = 1 + + subnetRouter := node("subnet", "100.64.0.2", "fd7a:115c:a1e0::2", users[0]) + subnetRouter.ID = 2 + subnetRouter.Hostinfo = &tailcfg.Hostinfo{ + RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")}, + } + subnetRouter.ApprovedRoutes = []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")} + + relayTarget := node("relay", "100.64.0.3", "fd7a:115c:a1e0::3", users[0]) + relayTarget.ID = 3 + relayTarget.Tags = []string{"tag:relay"} + + viaTarget := node("via", "100.64.0.4", "fd7a:115c:a1e0::4", users[0]) + viaTarget.ID = 4 + viaTarget.Tags = []string{"tag:via"} + + driveTarget := node("drive", "100.64.0.5", "fd7a:115c:a1e0::5", users[0]) + driveTarget.ID = 5 + driveTarget.Tags = []string{"tag:drive"} + + tests := []struct { + name string + pol string + nodes types.Nodes + subject *types.Node + want bool + }{ + { + name: "ordinary node under allow-all does not need recompute", + pol: allowAll, + nodes: types.Nodes{ordinary}, + subject: ordinary, + want: false, + }, + { + name: "subnet router needs recompute", + pol: allowAll, + nodes: types.Nodes{subnetRouter}, + subject: subnetRouter, + want: true, + }, + { + name: "relay target needs recompute", + pol: relayPol, + nodes: types.Nodes{relayTarget, ordinary}, + subject: relayTarget, + want: true, + }, + { + name: "ordinary node in a relay-using tailnet does not need recompute", + pol: relayPol, + nodes: types.Nodes{relayTarget, ordinary}, + subject: ordinary, + want: false, + }, + { + name: "via target needs recompute", + pol: viaPol, + nodes: types.Nodes{viaTarget, ordinary}, + subject: viaTarget, + want: true, + }, + { + name: "ordinary node in a via-using tailnet does not need recompute", + pol: viaPol, + nodes: types.Nodes{viaTarget, ordinary}, + subject: ordinary, + want: false, + }, + { + name: "taildrive target does not need recompute", + pol: taildrivePol, + nodes: types.Nodes{driveTarget, ordinary}, + subject: driveTarget, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pm, err := NewPolicyManager([]byte(tt.pol), users, tt.nodes.ViewSlice()) + require.NoError(t, err) + + got := pm.NodeNeedsPeerRecompute(tt.subject.View()) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index 7a914224..d1c570ad 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -45,6 +45,15 @@ type PolicyManager struct { autoApproveMapHash deephash.Sum autoApproveMap map[netip.Prefix]*netipx.IPSet + // relayTargetIPs holds the IPs of nodes that are destinations of a + // tailscale.com/cap/relay grant; viaTargetTags holds the tags used as + // via targets. A node matching either, or that is a subnet router, + // forces peers to recompute their netmap when its online state changes + // (see [PolicyManager.NodeNeedsPeerRecompute]). Recomputed from the + // compiled grants on every policy/user/node change. + relayTargetIPs *netipx.IPSet + viaTargetTags map[Tag]struct{} + // Lazy map of SSH policies sshPolicyMap map[types.NodeID]*tailcfg.SSHPolicy @@ -223,6 +232,14 @@ func (pm *PolicyManager) updateLocked() (bool, error) { pm.compiledGrants = pm.pol.compileGrants(pm.users, pm.nodes) pm.userNodeIdx = buildUserNodeIndex(pm.nodes) pm.needsPerNodeFilter = hasPerNodeGrants(pm.compiledGrants) + pm.viaTargetTags = collectViaTargetTags(pm.compiledGrants) + + relayTargetIPs, err := collectRelayTargetIPs(pm.compiledGrants) + if err != nil { + return false, fmt.Errorf("collecting relay target IPs: %w", err) + } + + pm.relayTargetIPs = relayTargetIPs var filter []tailcfg.FilterRule if pm.pol == nil || (pm.pol.ACLs == nil && pm.pol.Grants == nil) { @@ -360,6 +377,45 @@ func (pm *PolicyManager) updateLocked() (bool, error) { return true, nil } +// NodeNeedsPeerRecompute reports whether peers must recompute their netmap +// when node's online state changes. A plain node only needs the lightweight +// online/offline peer patch; these roles change what peers compute when the +// node goes up or down, so they require a full recompute: +// - subnet router: primary-route failover changes peers' AllowedIPs +// - relay target (tailscale.com/cap/relay): peers must drop a stale +// PeerRelay allocation +// - via target: peers steer traffic through this node +// +// The check is keyed on the node itself, so an ordinary node in a tailnet +// that uses relay or via for other nodes is correctly classified as not +// needing a recompute. +func (pm *PolicyManager) NodeNeedsPeerRecompute(node types.NodeView) bool { + if !node.Valid() { + return false + } + + // Subnet-router status is intrinsic to the node, so it needs no policy + // state and is checked without the lock. + if node.IsSubnetRouter() { + return true + } + + pm.mu.Lock() + defer pm.mu.Unlock() + + if pm.relayTargetIPs != nil && node.InIPSet(pm.relayTargetIPs) { + return true + } + + for tag := range pm.viaTargetTags { + if node.HasTag(string(tag)) { + return true + } + } + + return false +} + // SSHPolicy returns the [tailcfg.SSHPolicy] for node, compiling and // caching on first access. Rules use SessionDuration = 0 (no // auto-approval) and emit check URLs of the form @@ -1299,13 +1355,18 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S // Tagged nodes don't participate in autogroup:self (identity is tag-based), // so we skip them when collecting affected users, except when tag status changes // (which affects the user's device set). - affectedUsers := make(map[uint]struct{}) + // + // Ownership is keyed on TypedUserID (the UserID field), not the User + // association view: the NodeStore holds nodes by value with User as a + // *User pointer, and not every write path hydrates that association. A + // non-tagged node always has UserID set, so it is the reliable owner key. + affectedUsers := make(map[types.UserID]struct{}) // Check for removed nodes (only non-tagged nodes affect autogroup:self) for nodeID, oldNode := range oldNodeMap { if _, exists := newNodeMap[nodeID]; !exists { if !oldNode.IsTagged() { - affectedUsers[oldNode.User().ID()] = struct{}{} + affectedUsers[oldNode.TypedUserID()] = struct{}{} } } } @@ -1314,7 +1375,7 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S for nodeID, newNode := range newNodeMap { if _, exists := oldNodeMap[nodeID]; !exists { if !newNode.IsTagged() { - affectedUsers[newNode.User().ID()] = struct{}{} + affectedUsers[newNode.TypedUserID()] = struct{}{} } } } @@ -1327,10 +1388,10 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S if oldNode.IsTagged() != newNode.IsTagged() { if !oldNode.IsTagged() { // Was untagged, now tagged: user lost a device - affectedUsers[oldNode.User().ID()] = struct{}{} + affectedUsers[oldNode.TypedUserID()] = struct{}{} } else { // Was tagged, now untagged: user gained a device - affectedUsers[newNode.User().ID()] = struct{}{} + affectedUsers[newNode.TypedUserID()] = struct{}{} } continue @@ -1342,9 +1403,9 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S } // Check if user changed (both versions are non-tagged here) - if oldNode.User().ID() != newNode.User().ID() { - affectedUsers[oldNode.User().ID()] = struct{}{} - affectedUsers[newNode.User().ID()] = struct{}{} + if oldNode.TypedUserID() != newNode.TypedUserID() { + affectedUsers[oldNode.TypedUserID()] = struct{}{} + affectedUsers[newNode.TypedUserID()] = struct{}{} } // Check if IPs changed (simple check - could be more sophisticated) @@ -1352,12 +1413,12 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S newIPs := newNode.IPs() if len(oldIPs) != len(newIPs) { - affectedUsers[newNode.User().ID()] = struct{}{} + affectedUsers[newNode.TypedUserID()] = struct{}{} } else { // Check if any IPs are different for i, oldIP := range oldIPs { if i >= len(newIPs) || oldIP != newIPs[i] { - affectedUsers[newNode.User().ID()] = struct{}{} + affectedUsers[newNode.TypedUserID()] = struct{}{} break } } @@ -1370,7 +1431,7 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S // because autogroup:self rules depend on the entire user's device set. for nodeID := range pm.filterRulesMap { // Find the user for this cached node - var nodeUserID uint + var nodeUserID types.UserID found := false @@ -1384,7 +1445,7 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S break } - nodeUserID = node.User().ID() + nodeUserID = node.TypedUserID() found = true break @@ -1400,7 +1461,7 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S break } - nodeUserID = node.User().ID() + nodeUserID = node.TypedUserID() found = true break diff --git a/hscontrol/policy/v2/policy_test.go b/hscontrol/policy/v2/policy_test.go index bb90afc1..ef28bf36 100644 --- a/hscontrol/policy/v2/policy_test.go +++ b/hscontrol/policy/v2/policy_test.go @@ -226,6 +226,72 @@ func TestInvalidateAutogroupSelfCache(t *testing.T) { } } +// TestSetNodesAutogroupSelfUnhydratedUser reproduces the panic seen on +// /machine/map when an autogroup:self policy is active and a non-tagged +// node reaches the policy manager with its UserID set but the User +// association left unhydrated (User pointer nil). The NodeStore stores +// nodes by value with User as a *User; not every write path hydrates the +// association, so the autogroup:self cache invalidation must derive the +// owning user from UserID, not from the User view. +func TestSetNodesAutogroupSelfUnhydratedUser(t *testing.T) { + users := types.Users{ + {Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"}, + {Model: gorm.Model{ID: 2}, Name: "user2", Email: "user2@headscale.net"}, + } + + policy := `{ + "acls": [ + { + "action": "accept", + "src": ["autogroup:member"], + "dst": ["autogroup:self:*"] + } + ] + }` + + // unhydratedNode mirrors a NodeStore snapshot entry whose UserID is + // set (so it is unambiguously user-owned, not tagged) but whose User + // association was never loaded. + unhydratedNode := func(name, ipv4, ipv6 string, userID uint) *types.Node { + return &types.Node{ + Hostname: name, + IPv4: ap(ipv4), + IPv6: ap(ipv6), + UserID: new(userID), + User: nil, + } + } + + initialNodes := types.Nodes{ + node("user1-node1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0]), + node("user2-node1", "100.64.0.2", "fd7a:115c:a1e0::2", users[1]), + } + for i, n := range initialNodes { + n.ID = types.NodeID(i + 1) //nolint:gosec // safe conversion in test + } + + pm, err := NewPolicyManager([]byte(policy), users, initialNodes.ViewSlice()) + require.NoError(t, err) + + require.False(t, initialNodes[0].IsTagged(), "node must be user-owned for autogroup:self") + + // Simulate a node restarting tailscaled: the same node is pushed back + // into the policy manager, but the snapshot version has no hydrated + // User association. This is the exact shape that crashed beta.1. + updatedNodes := types.Nodes{ + unhydratedNode("user1-node1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0].ID), + node("user2-node1", "100.64.0.2", "fd7a:115c:a1e0::2", users[1]), + } + for i, n := range updatedNodes { + n.ID = types.NodeID(i + 1) //nolint:gosec // safe conversion in test + } + + require.NotPanics(t, func() { + _, err = pm.SetNodes(updatedNodes.ViewSlice()) + }, "SetNodes must not panic when a non-tagged node has an unhydrated User") + require.NoError(t, err) +} + // TestInvalidateGlobalPolicyCache tests the cache invalidation logic for global policies. func TestInvalidateGlobalPolicyCache(t *testing.T) { mustIPPtr := func(s string) *netip.Addr { diff --git a/hscontrol/state/connect_test.go b/hscontrol/state/connect_test.go new file mode 100644 index 00000000..3a60d1b3 --- /dev/null +++ b/hscontrol/state/connect_test.go @@ -0,0 +1,216 @@ +package state + +import ( + "net/netip" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/types/change" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" +) + +// runtimePeerComputationReasons returns the Reason of every change in cs that +// carries RequiresRuntimePeerComputation. Each such change makes the batcher +// rebuild a full netmap (packet filters, SSH policy, peer serialization) for +// every connected node, so it is the expensive fan-out the issue is about. +func runtimePeerComputationReasons(cs []change.Change) []string { + var reasons []string + + for _, c := range cs { + if c.RequiresRuntimePeerComputation { + reasons = append(reasons, c.Reason) + } + } + + return reasons +} + +// hasPeerPatch reports whether any change carries a lightweight PeerChange +// patch (e.g. the online/offline indicator). This is the cheap notification a +// reconnect should produce instead of a full runtime recompute. +func hasPeerPatch(cs []change.Change) bool { + for _, c := range cs { + if len(c.PeerPatches) > 0 { + return true + } + } + + return false +} + +// forcesPeerRecompute reports whether any change makes peers rebuild a full +// netmap, whether via a full update (subnet-router path) or a runtime peer +// computation (relay/via path). +func forcesPeerRecompute(cs []change.Change) bool { + for _, c := range cs { + if c.IsFull() || c.RequiresRuntimePeerComputation { + return true + } + } + + return false +} + +// TestConnectDisconnectOrdinaryNodeNoRuntimeRecompute asserts that an ordinary +// node coming online or going offline only sends the lightweight online/offline +// peer patch and does not trigger a runtime peer recompute. +// +// State.Connect and State.Disconnect gate change.PolicyChange() (which sets +// RequiresRuntimePeerComputation, forcing the batcher to rebuild a full netmap +// for every connected node) on NodeNeedsPeerRecompute. An ordinary node is +// neither a subnet router, a relay target, nor a via target, so the gate is +// false and no recompute is emitted. +// +// Emitting that recompute unconditionally turned each reconnect into O(N) full +// netmap rebuilds (and a reconnect storm into O(N^2)), which saturated CPU +// after the v0.28 -> v0.29 upgrade. +func TestConnectDisconnectOrdinaryNodeNoRuntimeRecompute(t *testing.T) { + _, s, nodeID := persistTestSetup(t) + t.Cleanup(func() { _ = s.Close() }) + + t.Run("connect", func(t *testing.T) { + cs, epoch := s.Connect(nodeID) + require.NotZero(t, epoch, "Connect should return a session epoch") + + assert.True(t, hasPeerPatch(cs), + "Connect should still emit a lightweight online peer patch") + + reasons := runtimePeerComputationReasons(cs) + assert.Empty(t, reasons, + "ordinary node connect must not trigger a runtime peer recompute; "+ + "got RequiresRuntimePeerComputation changes: %v", reasons) + }) + + t.Run("disconnect", func(t *testing.T) { + // Connect first so Disconnect's epoch check passes. + _, epoch := s.Connect(nodeID) + + cs, err := s.Disconnect(nodeID, epoch) + require.NoError(t, err) + + assert.True(t, hasPeerPatch(cs), + "Disconnect should still emit a lightweight offline peer patch") + + reasons := runtimePeerComputationReasons(cs) + assert.Empty(t, reasons, + "ordinary node disconnect must not trigger a runtime peer recompute; "+ + "got RequiresRuntimePeerComputation changes: %v", reasons) + }) +} + +// TestConnectDisconnectRelayTargetTriggersRecompute locks the cap/relay case: +// a relay target is not a subnet router, so the only thing that makes its +// connect/disconnect emit a runtime peer recompute is the +// NodeNeedsPeerRecompute gate. Peers must still receive that recompute so they +// drop a stale PeerRelay allocation when the relay goes offline. +func TestConnectDisconnectRelayTargetTriggersRecompute(t *testing.T) { + _, s, nodeID := persistTestSetup(t) + t.Cleanup(func() { _ = s.Close() }) + + // A cap/relay grant whose destination resolves to the node's owning + // user makes the node a relay target without making it a subnet router. + relayPolicy := `{"grants":[{"src":["*"],"dst":["persist-user@"],"app":{"tailscale.com/cap/relay":[{}]}}]}` + _, err := s.SetPolicy([]byte(relayPolicy)) + require.NoError(t, err) + + t.Run("connect", func(t *testing.T) { + cs, epoch := s.Connect(nodeID) + require.NotZero(t, epoch) + + assert.NotEmpty(t, runtimePeerComputationReasons(cs), + "relay-target node connect must trigger a runtime peer recompute") + }) + + t.Run("disconnect", func(t *testing.T) { + _, epoch := s.Connect(nodeID) + + cs, err := s.Disconnect(nodeID, epoch) + require.NoError(t, err) + + assert.NotEmpty(t, runtimePeerComputationReasons(cs), + "relay-target node disconnect must trigger a runtime peer recompute") + }) +} + +// TestConnectDisconnectSubnetRouterForcesRecompute guards that a subnet router +// still forces peers to recompute on connect/disconnect (primary-route +// failover changes their AllowedIPs), so the gate does not over-suppress. +func TestConnectDisconnectSubnetRouterForcesRecompute(t *testing.T) { + _, s, nodeID := persistTestSetup(t) + t.Cleanup(func() { _ = s.Close() }) + + route := netip.MustParsePrefix("10.0.0.0/24") + _, ok := s.nodeStore.UpdateNode(nodeID, func(n *types.Node) { + n.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{route}} + n.ApprovedRoutes = []netip.Prefix{route} + }) + require.True(t, ok) + + t.Run("connect", func(t *testing.T) { + cs, epoch := s.Connect(nodeID) + require.NotZero(t, epoch) + + assert.True(t, forcesPeerRecompute(cs), + "subnet router connect must force a peer recompute") + }) + + t.Run("disconnect", func(t *testing.T) { + _, epoch := s.Connect(nodeID) + + cs, err := s.Disconnect(nodeID, epoch) + require.NoError(t, err) + + assert.True(t, forcesPeerRecompute(cs), + "subnet router disconnect must force a peer recompute") + }) +} + +// TestConnectDisconnectSubnetRouterEmitsPolicyChangeNotFull pins how a subnet +// router forces that recompute: through the gated change.PolicyChange() (a +// runtime peer recompute) and the lightweight online/offline peer patch, not a +// full update. policyChangeResponse is a strict subset of a full update yet +// still carries primary-route failover, so the heavier FullUpdate that the +// online/offline change once emitted for subnet routers is unnecessary. +func TestConnectDisconnectSubnetRouterEmitsPolicyChangeNotFull(t *testing.T) { + _, s, nodeID := persistTestSetup(t) + t.Cleanup(func() { _ = s.Close() }) + + route := netip.MustParsePrefix("10.0.0.0/24") + _, ok := s.nodeStore.UpdateNode(nodeID, func(n *types.Node) { + n.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{route}} + n.ApprovedRoutes = []netip.Prefix{route} + }) + require.True(t, ok) + + assertRecomputeNotFull := func(t *testing.T, cs []change.Change) { + t.Helper() + + assert.NotEmpty(t, runtimePeerComputationReasons(cs), + "subnet router must still drive a runtime peer recompute") + assert.True(t, hasPeerPatch(cs), + "subnet router should still emit the lightweight online/offline patch") + + for _, c := range cs { + assert.Falsef(t, c.IsFull(), + "subnet router recompute must be a PolicyChange, not a full update: %q", c.Reason) + } + } + + t.Run("connect", func(t *testing.T) { + cs, epoch := s.Connect(nodeID) + require.NotZero(t, epoch) + + assertRecomputeNotFull(t, cs) + }) + + t.Run("disconnect", func(t *testing.T) { + _, epoch := s.Connect(nodeID) + + cs, err := s.Disconnect(nodeID, epoch) + require.NoError(t, err) + + assertRecomputeNotFull(t, cs) + }) +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index dce769b7..4daf18f3 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -594,9 +594,14 @@ func (s *State) Connect(id types.NodeID) ([]change.Change, uint64) { c = append(c, change.NodeAdded(id)) } - // Coming online may re-enable cap/relay grants and identity-based - // aliases targeting this node, so peers need a fresh netmap. - c = append(c, change.PolicyChange()) + // Only a node whose online state changes what peers compute (a subnet + // router, relay target, or via target) needs a full peer recompute. + // An ordinary node coming online just sends the lightweight online + // patch above; emitting a PolicyChange for it would force every peer + // to rebuild its netmap on every reconnect. + if s.polMan.NodeNeedsPeerRecompute(node) { + c = append(c, change.PolicyChange()) + } return c, epoch } @@ -648,13 +653,15 @@ func (s *State) Disconnect(id types.NodeID, epoch uint64) ([]change.Change, erro c = change.Change{} } - // Going offline can affect policy compilation beyond subnet routes - // (cap/relay grants, tag/group aliases, via routes), so peers need - // a fresh netmap regardless of whether the primary moved. - // - // TODO(kradalby): fires one full netmap recompute per peer on - // every connect/disconnect. Coalesce in mapper/batcher.go:addToBatch. - cs := []change.Change{change.NodeOfflineFor(node), c, change.PolicyChange()} + // Only a node whose online state changes what peers compute (a subnet + // router, relay target, or via target) needs a full peer recompute. + // An ordinary node going offline just sends the lightweight offline + // patch; emitting a PolicyChange for it would force every peer to + // rebuild its netmap on every disconnect. + cs := []change.Change{change.NodeOfflineFor(node), c} + if s.polMan.NodeNeedsPeerRecompute(node) { + cs = append(cs, change.PolicyChange()) + } return cs, nil } diff --git a/hscontrol/types/change/change.go b/hscontrol/types/change/change.go index abfcaee2..77d0f083 100644 --- a/hscontrol/types/change/change.go +++ b/hscontrol/types/change/change.go @@ -250,6 +250,41 @@ func FilterForNode(nodeID types.NodeID, rs []Change) []Change { return result } +// IsBroadcastPolicyChange reports whether r is a tailnet-wide policy recompute +// with no per-node payload. A recompute reads the current snapshot, so every +// such change is interchangeable and same-tick duplicates are redundant. A +// targeted or self-update ([Change.OriginNode]) recompute is per-node, so it is +// not one of these. +func (r Change) IsBroadcastPolicyChange() bool { + return r.RequiresRuntimePeerComputation && !r.IsTargetedToNode() && r.OriginNode == 0 +} + +// DedupePolicyChanges keeps the first broadcast policy change in a tick and +// drops the rest: each rebuilds a node's whole netmap from the same snapshot, so +// the repeats are wasted work. Order and all other changes are preserved. +func DedupePolicyChanges(changes []Change) []Change { + if len(changes) < 2 { + return changes + } + + out := make([]Change, 0, len(changes)) + seen := false + + for _, r := range changes { + if r.IsBroadcastPolicyChange() { + if seen { + continue + } + + seen = true + } + + out = append(out, r) + } + + return out +} + func uniqueNodeIDs(ids []types.NodeID) []types.NodeID { if len(ids) == 0 { return nil @@ -421,29 +456,19 @@ func NodeRemoved(id types.NodeID) Change { return PeersRemoved(id) } -// NodeOnlineFor returns a [Change] for when a node comes online. -// If the node is a subnet router, a full update is sent instead of a patch. +// NodeOnlineFor returns the [Change] for a node coming online: a lightweight +// [NodeOnline] peer patch. Subnet routers, relay targets, and via targets get +// their full peer recompute from the gated [PolicyChange] that State.Connect +// emits, so no full update is needed here. func NodeOnlineFor(node types.NodeView) Change { - if node.IsSubnetRouter() { - c := FullUpdate() - c.Reason = "subnet router online" - - return c - } - return NodeOnline(node.ID()) } -// NodeOfflineFor returns a [Change] for when a node goes offline. -// If the node is a subnet router, a full update is sent instead of a patch. +// NodeOfflineFor returns the [Change] for a node going offline: a lightweight +// [NodeOffline] peer patch. As with [NodeOnlineFor], subnet routers and other +// recompute-forcing nodes rely on the gated [PolicyChange] from State.Disconnect +// for the peer recompute, so no full update is needed here. func NodeOfflineFor(node types.NodeView) Change { - if node.IsSubnetRouter() { - c := FullUpdate() - c.Reason = "subnet router offline" - - return c - } - return NodeOffline(node.ID()) } diff --git a/hscontrol/types/change/change_test.go b/hscontrol/types/change/change_test.go index 729dc468..14fe077a 100644 --- a/hscontrol/types/change/change_test.go +++ b/hscontrol/types/change/change_test.go @@ -1,11 +1,13 @@ package change import ( + "net/netip" "reflect" "testing" "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "tailscale.com/tailcfg" ) @@ -296,6 +298,99 @@ func TestChange_Merge(t *testing.T) { } } +func TestChange_IsBroadcastPolicyChange(t *testing.T) { + originUpdate := PolicyChange() + originUpdate.OriginNode = 7 + + targeted := PolicyChange() + targeted.TargetNode = 7 + + tests := []struct { + name string + c Change + want bool + }{ + {name: "policy change", c: PolicyChange(), want: true}, + {name: "self-update recompute", c: originUpdate, want: false}, + {name: "targeted recompute", c: targeted, want: false}, + {name: "online patch", c: NodeOnline(1), want: false}, + {name: "full update", c: FullUpdate(), want: false}, + {name: "derp map", c: DERPMap(), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.c.IsBroadcastPolicyChange()) + }) + } +} + +func TestDedupePolicyChanges(t *testing.T) { + // originRecompute is a runtime recompute carrying node-specific payload + // (OriginNode), so it is not the canonical broadcast PolicyChange and must + // never be coalesced away. + originRecompute := PolicyChange() + originRecompute.OriginNode = 7 + + tests := []struct { + name string + changes []Change + want []Change + }{ + { + name: "nil is a no-op", + changes: nil, + want: nil, + }, + { + name: "single policy change is unchanged", + changes: []Change{PolicyChange()}, + want: []Change{PolicyChange()}, + }, + { + name: "identical policy changes collapse to one", + changes: []Change{PolicyChange(), PolicyChange(), PolicyChange()}, + want: []Change{PolicyChange()}, + }, + { + name: "peer patches survive between collapsed policy changes", + changes: []Change{ + NodeOnline(1), PolicyChange(), NodeOnline(2), PolicyChange(), NodeOffline(3), + }, + want: []Change{ + NodeOnline(1), PolicyChange(), NodeOnline(2), NodeOffline(3), + }, + }, + { + name: "NodeAdded is preserved, not treated as a recompute", + changes: []Change{PolicyChange(), NodeAdded(5), PolicyChange()}, + want: []Change{PolicyChange(), NodeAdded(5)}, + }, + { + name: "recompute carrying OriginNode is kept alongside the canonical one", + changes: []Change{PolicyChange(), originRecompute, PolicyChange()}, + want: []Change{PolicyChange(), originRecompute}, + }, + { + name: "non-canonical recomputes are not collapsed", + changes: []Change{originRecompute, originRecompute}, + want: []Change{originRecompute, originRecompute}, + }, + { + name: "changes without any recompute are unchanged", + changes: []Change{NodeOnline(1), DERPMap()}, + want: []Change{NodeOnline(1), DERPMap()}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DedupePolicyChanges(tt.changes) + assert.Equal(t, tt.want, got) + }) + } +} + func TestChange_Constructors(t *testing.T) { tests := []struct { name string @@ -519,3 +614,42 @@ func TestUniqueNodeIDs(t *testing.T) { }) } } + +func TestNodeOnlineOfflineForSubnetRouter(t *testing.T) { + route := netip.MustParsePrefix("10.0.0.0/24") + router := types.Node{ + ID: 1, + Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{route}}, + ApprovedRoutes: []netip.Prefix{route}, + } + view := router.View() + require.True(t, view.IsSubnetRouter(), "test node must be a subnet router") + + tests := []struct { + name string + got Change + wantOnline bool + }{ + {name: "online", got: NodeOnlineFor(view), wantOnline: true}, + {name: "offline", got: NodeOfflineFor(view), wantOnline: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // A subnet router's online/offline transition rides the lightweight + // peer patch, not a full update: the gated PolicyChange that + // State.Connect/Disconnect emit owns the netmap recompute. + assert.False(t, tt.got.IsFull(), + "subnet router online/offline must be a peer patch, not a full update") + + require.NotEmpty(t, tt.got.PeerPatches, + "expected an online/offline peer patch") + + patch := tt.got.PeerPatches[0] + assert.Equal(t, view.ID().NodeID(), patch.NodeID) + + require.NotNil(t, patch.Online) + assert.Equal(t, tt.wantOnline, *patch.Online) + }) + } +} diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 328a3b28..580df92b 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -1185,11 +1185,7 @@ func (nv NodeView) TailNode( } var derp int - // TODO(kradalby): legacyDERP was removed in tailscale/tailscale@2fc4455e6dd9ab7f879d4e2f7cffc2be81f14077 - // and should be removed after 111 is the minimum capver. - legacyDERP := "127.3.3.40:0" // Zero means disconnected or unknown. if nv.Hostinfo().Valid() && nv.Hostinfo().NetInfo().Valid() { - legacyDERP = fmt.Sprintf("127.3.3.40:%d", nv.Hostinfo().NetInfo().PreferredDERP()) derp = nv.Hostinfo().NetInfo().PreferredDERP() } @@ -1266,16 +1262,15 @@ func (nv NodeView) TailNode( Key: nv.NodeKey(), KeyExpiry: keyExpiry.UTC(), - Machine: nv.MachineKey(), - DiscoKey: nv.DiscoKey(), - Addresses: addresses, - PrimaryRoutes: primaryRoutes, - AllowedIPs: allowedIPs, - Endpoints: nv.Endpoints().AsSlice(), - HomeDERP: derp, - LegacyDERPString: legacyDERP, - Hostinfo: nv.Hostinfo(), - Created: nv.CreatedAt().UTC(), + Machine: nv.MachineKey(), + DiscoKey: nv.DiscoKey(), + Addresses: addresses, + PrimaryRoutes: primaryRoutes, + AllowedIPs: allowedIPs, + Endpoints: nv.Endpoints().AsSlice(), + HomeDERP: derp, + Hostinfo: nv.Hostinfo(), + Created: nv.CreatedAt().UTC(), Online: nv.IsOnline().Clone(), diff --git a/hscontrol/types/types_clone.go b/hscontrol/types/types_clone.go index a14dc6fd..2eacb561 100644 --- a/hscontrol/types/types_clone.go +++ b/hscontrol/types/types_clone.go @@ -35,6 +35,7 @@ var _UserCloneNeedsRegeneration = User(struct { ProviderIdentifier sql.NullString Provider string ProfilePicURL string + Groups string }{}) // Clone makes a deep copy of Node. diff --git a/hscontrol/types/types_view.go b/hscontrol/types/types_view.go index 3a9b3b42..0db6b810 100644 --- a/hscontrol/types/types_view.go +++ b/hscontrol/types/types_view.go @@ -124,8 +124,11 @@ var _UserViewNeedsRegeneration = User(struct { ProviderIdentifier sql.NullString Provider string ProfilePicURL string + Groups string }{}) +func (v UserView) Groups() string { return v.ж.Groups } + // View returns a read-only view of Node. func (p *Node) View() NodeView { return NodeView{ж: p} diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go index e5a9e7a5..b2455a67 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -95,6 +95,11 @@ type User struct { Provider string ProfilePicURL string + + // Groups stores the OIDC groups/roles that the user belongs to. + // This is populated from the 'groups' claim in OIDC tokens and + // is used for role-based access control in Headplane. + Groups string `gorm:"type:text"` } func (u *User) StringID() string { @@ -139,6 +144,39 @@ func (u *User) profilePicURL() string { return u.ProfilePicURL } +// GetGroups returns the user's groups as a slice of strings. +// Groups are stored as JSON in the database. +func (u *User) GetGroups() []string { + if u.Groups == "" { + return []string{} + } + + var groups []string + if err := json.Unmarshal([]byte(u.Groups), &groups); err != nil { + log.Error().Err(err).Msg("Failed to unmarshal user groups") + return []string{} + } + + return groups +} + +// SetGroups stores the user's groups as JSON in the database. +func (u *User) SetGroups(groups []string) { + if len(groups) == 0 { + u.Groups = "" + return + } + + data, err := json.Marshal(groups) + if err != nil { + log.Error().Err(err).Msg("Failed to marshal user groups") + u.Groups = "" + return + } + + u.Groups = string(data) +} + func (u *User) TailscaleUser() tailcfg.User { return tailcfg.User{ ID: tailcfg.UserID(u.ID), //nolint:gosec // UserID is bounded @@ -205,6 +243,7 @@ func (u *User) Proto() *v1.User { ProviderId: u.ProviderIdentifier.String, Provider: u.Provider, ProfilePicUrl: u.ProfilePicURL, + Groups: u.GetGroups(), } } @@ -447,4 +486,7 @@ func (u *User) FromClaim(claims *OIDCClaims, emailVerifiedRequired bool) { u.DisplayName = claims.Name u.ProfilePicURL = claims.ProfilePictureURL u.Provider = util.RegisterMethodOIDC + + // Store OIDC groups for role-based access control + u.SetGroups(claims.Groups) } diff --git a/hscontrol/types/users_test.go b/hscontrol/types/users_test.go index 1218979c..08823a3e 100644 --- a/hscontrol/types/users_test.go +++ b/hscontrol/types/users_test.go @@ -528,6 +528,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) { Valid: true, }, ProfilePicURL: "https://cdn.casbin.org/img/casbin.svg", + Groups: `["org1/department1","org1/department2"]`, }, }, } diff --git a/integration/helpers.go b/integration/helpers.go index 197efab7..d47766bd 100644 --- a/integration/helpers.go +++ b/integration/helpers.go @@ -791,7 +791,6 @@ func assertValidNetmap(t *testing.T, client TailscaleClient) { assert.Falsef(c, netmap.SelfNode.DiscoKey().IsZero(), "%q does not have a valid DiscoKey", client.Hostname()) for _, peer := range netmap.Peers { - assert.NotEqualf(c, "127.3.3.40:0", peer.LegacyDERPString(), "peer (%s) has no home DERP in %q's netmap, got: %s", peer.ComputedName(), client.Hostname(), peer.LegacyDERPString()) //nolint:staticcheck // SA1019: testing legacy field assert.NotEqualf(c, 0, peer.HomeDERP(), "peer (%s) has no home DERP in %q's netmap, got: %d", peer.ComputedName(), client.Hostname(), peer.HomeDERP()) assert.Truef(c, peer.Hostinfo().Valid(), "peer (%s) of %q does not have Hostinfo", peer.ComputedName(), client.Hostname()) diff --git a/integration/oidc_groups_test.go b/integration/oidc_groups_test.go new file mode 100644 index 00000000..1695dac8 --- /dev/null +++ b/integration/oidc_groups_test.go @@ -0,0 +1,113 @@ +package integration + +import ( + "sort" + "testing" + + v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/juanfont/headscale/integration/hsic" + "github.com/oauth2-proxy/mockoidc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestOIDCGroupsPersisted verifies that the `groups` claim from an OIDC +// provider is persisted on the user and exposed through the gRPC API. +// +// Implementation under test: +// - users.groups TEXT column added by migration 202505141323. +// - hscontrol/types.User.SetGroups / GetGroups round-trip via JSON. +// - FromClaim calls SetGroups(claims.Groups) so login populates the +// column. OIDCClaims.Groups is FlexibleStringSlice, so providers like +// JumpCloud that return a single string instead of an array also work. +// - v1.User.Groups (proto field 9) is populated by User.Proto() so +// external tools (Headplane) can read group membership over gRPC. +func TestOIDCGroupsPersisted(t *testing.T) { + IntegrationSkip(t) + + // mockoidc serves logins from a strict queue, so keep NodesPerUser=1. + spec := ScenarioSpec{ + NodesPerUser: 1, + Users: []string{"admin", "dev", "solo"}, + OIDCUsers: []mockoidc.MockUser{ + oidcMockUserWithGroups("admin", true, []string{"admins", "engineering"}), + oidcMockUserWithGroups("dev", true, []string{"engineering"}), + // User with empty groups — round-trips as no Groups. + oidcMockUserWithGroups("solo", true, nil), + }, + } + + scenario, err := NewScenario(spec) + 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", + // Make sure the OIDC scope set includes "groups" so mockoidc emits the claim. + "HEADSCALE_OIDC_SCOPE": "openid,profile,email,groups", + } + + err = scenario.CreateHeadscaleEnvWithLoginURL( + nil, + hsic.WithTestName("oidcgroups"), + hsic.WithConfigEnv(oidcMap), + hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())), + ) + requireNoErrHeadscaleEnv(t, err) + + // Drive the OIDC login flow for every client. + _, err = scenario.ListTailscaleClients() + requireNoErrListClients(t, err) + err = scenario.WaitForTailscaleSync() + requireNoErrSync(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + users, err := headscale.ListUsers() + require.NoError(t, err) + + got := groupsByOIDCUserName(users) + + want := map[string][]string{ + "admin": {"admins", "engineering"}, + "dev": {"engineering"}, + "solo": nil, + } + + for name, wantGroups := range want { + gotGroups, ok := got[name] + assert.True(t, ok, "OIDC user %q not present in ListUsers response", name) + assert.ElementsMatch(t, wantGroups, gotGroups, "groups mismatch for user %q", name) + } +} + +// groupsByOIDCUserName picks out OIDC users from a ListUsers response and +// returns a map of username -> sorted groups. CLI-created users (no provider) +// are filtered out so the assertions don't need to know about them. +func groupsByOIDCUserName(users []*v1.User) map[string][]string { + out := map[string][]string{} + for _, u := range users { + if u.GetProvider() != "oidc" { + continue + } + g := append([]string(nil), u.GetGroups()...) + sort.Strings(g) + if len(g) == 0 { + g = nil + } + out[u.GetName()] = g + } + return out +} + +// oidcMockUserWithGroups extends [oidcMockUser] with a Groups claim. +// mockoidc populates the id_token / userinfo from this struct verbatim. +func oidcMockUserWithGroups(username string, emailVerified bool, groups []string) mockoidc.MockUser { + u := oidcMockUser(username, emailVerified) + u.Groups = groups + return u +} diff --git a/integration/tsic/tsic.go b/integration/tsic/tsic.go index bd0342cb..9893f5d5 100644 --- a/integration/tsic/tsic.go +++ b/integration/tsic/tsic.go @@ -522,6 +522,9 @@ func New( } } case "unstable": + // ghcr.io/tailscale/tailscale:unstable is stale (last updated + // 2022); only tailscale/tailscale on Docker Hub publishes + // current unstable builds. tailscaleOptions.Repository = "tailscale/tailscale" tailscaleOptions.Tag = version @@ -542,7 +545,7 @@ func New( log.Printf("Docker run failed for %s (unstable), error: %v", hostname, err) } default: - tailscaleOptions.Repository = "tailscale/tailscale" + tailscaleOptions.Repository = "ghcr.io/tailscale/tailscale" tailscaleOptions.Tag = "v" + version err = dockertestutil.PullWithAuth(pool, tailscaleOptions.Repository+":"+tailscaleOptions.Tag) diff --git a/proto/headscale/v1/user.proto b/proto/headscale/v1/user.proto index bd71bcb1..a20587fe 100644 --- a/proto/headscale/v1/user.proto +++ b/proto/headscale/v1/user.proto @@ -13,6 +13,10 @@ message User { string provider_id = 6; string provider = 7; string profile_pic_url = 8; + // OIDC group memberships extracted from the identity provider's + // `groups` claim at login. Populated by hscontrol/types.User.FromClaim. + // External tools (Headplane, automation) use this for role-based access. + repeated string groups = 9; } message CreateUserRequest {