Compare commits
22 Commits
main
...
feature/oi
| Author | SHA1 | Date | |
|---|---|---|---|
| da2878e8fb | |||
| eefc99e807 | |||
| af6d7a8a0b | |||
| d5e2aa97da | |||
| 209ba5c4ea | |||
|
|
5228cb1a40 | ||
|
|
cffdb77c8b | ||
|
|
7706552c99 | ||
|
|
bceac495f9 | ||
|
|
171fd7a3c5 | ||
|
|
ea8fc72570 | ||
|
|
77ba225cdb | ||
|
|
4483fd0cad | ||
|
|
66a5f99bfa | ||
|
|
2e49f3dc67 | ||
|
|
79562b9782 | ||
|
|
58a85b68b3 | ||
|
|
eb23c125fa | ||
|
|
570735f204 | ||
|
|
78570c754f | ||
|
|
25adfaf341 | ||
|
|
be90910d33 |
@ -51,6 +51,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: tailscale-head-image
|
name: tailscale-head-image
|
||||||
path: /tmp/artifacts
|
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
|
- name: Download hi binary
|
||||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
@ -86,6 +91,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
gunzip -c /tmp/artifacts/headscale-image.tar.gz | docker load
|
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-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
|
if [ -f /tmp/artifacts/postgres-image.tar.gz ]; then
|
||||||
gunzip -c /tmp/artifacts/postgres-image.tar.gz | docker load
|
gunzip -c /tmp/artifacts/postgres-image.tar.gz | docker load
|
||||||
fi
|
fi
|
||||||
|
|||||||
69
.github/workflows/test-integration.yaml
vendored
69
.github/workflows/test-integration.yaml
vendored
@ -9,6 +9,9 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
# build: Builds binaries and Docker images once, uploads as artifacts for reuse.
|
# 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-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.
|
# sqlite: Runs all integration tests with SQLite backend.
|
||||||
# postgres: Runs a subset of tests with PostgreSQL to verify database compatibility.
|
# postgres: Runs a subset of tests with PostgreSQL to verify database compatibility.
|
||||||
build:
|
build:
|
||||||
@ -150,9 +153,71 @@ jobs:
|
|||||||
name: postgres-image
|
name: postgres-image
|
||||||
path: postgres-image.tar.gz
|
path: postgres-image.tar.gz
|
||||||
retention-days: 10
|
retention-days: 10
|
||||||
sqlite:
|
build-tailscale-released:
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
needs: build
|
needs: build
|
||||||
if: needs.build.outputs.files-changed == 'true'
|
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:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@ -309,7 +374,7 @@ jobs:
|
|||||||
postgres_flag: "--postgres=0"
|
postgres_flag: "--postgres=0"
|
||||||
database_name: "sqlite"
|
database_name: "sqlite"
|
||||||
postgres:
|
postgres:
|
||||||
needs: [build, build-postgres]
|
needs: [build, build-postgres, build-tailscale-released]
|
||||||
if: needs.build.outputs.files-changed == 'true'
|
if: needs.build.outputs.files-changed == 'true'
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|||||||
@ -13,6 +13,7 @@ linters:
|
|||||||
- gochecknoinits
|
- gochecknoinits
|
||||||
- gocognit
|
- gocognit
|
||||||
- godox
|
- godox
|
||||||
|
- gomodguard
|
||||||
- interfacebloat
|
- interfacebloat
|
||||||
- ireturn
|
- ireturn
|
||||||
- lll
|
- lll
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 0.29.0 (202x-xx-xx)
|
## 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
|
### 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)
|
- 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)
|
- 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)
|
- 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)
|
- 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)
|
## 0.28.0 (2026-02-04)
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ WORKDIR /go/src/tailscale
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
RUN GOARCH=$TARGETARCH go install -v ./cmd/derper
|
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
|
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables curl
|
||||||
|
|
||||||
COPY --from=build-env /go/bin/* /usr/local/bin/
|
COPY --from=build-env /go/bin/* /usr/local/bin/
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
# and are in no way endorsed by Headscale's maintainers as an
|
# and are in no way endorsed by Headscale's maintainers as an
|
||||||
# official nor supported release or distribution.
|
# 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
|
ARG VERSION=dev
|
||||||
ENV GOPATH /go
|
ENV GOPATH /go
|
||||||
WORKDIR /go/src/headscale
|
WORKDIR /go/src/headscale
|
||||||
|
|||||||
@ -36,7 +36,7 @@ RUN GOARCH=$TARGETARCH go install -tags="${BUILD_TAGS}" -ldflags="\
|
|||||||
-X tailscale.com/version.gitCommitStamp=$VERSION_GIT_HASH" \
|
-X tailscale.com/version.gitCommitStamp=$VERSION_GIT_HASH" \
|
||||||
-v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot
|
-v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot
|
||||||
|
|
||||||
FROM alpine:3.22
|
FROM alpine:3.23
|
||||||
# Upstream: ca-certificates ip6tables iptables iproute2
|
# Upstream: ca-certificates ip6tables iptables iproute2
|
||||||
# Tests: curl python3 (traceroute via BusyBox)
|
# Tests: curl python3 (traceroute via BusyBox)
|
||||||
RUN apk add --no-cache ca-certificates curl ip6tables iptables iproute2 python3
|
RUN apk add --no-cache ca-certificates curl ip6tables iptables iproute2 python3
|
||||||
|
|||||||
@ -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_REPO=https://github.com/tailscale/tailscale-rs.git
|
||||||
ARG TAILSCALE_RS_REF=main
|
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
|
RUN cargo build --release --features axum,insecure-keyfetch --example axum
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
|
|||||||
89
cmd/hi/listversions.go
Normal file
89
cmd/hi/listversions.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -29,6 +29,13 @@ func main() {
|
|||||||
return runDoctorCheck(env.Context())
|
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",
|
Name: "clean",
|
||||||
Help: "Clean Docker resources",
|
Help: "Clean Docker resources",
|
||||||
|
|||||||
@ -406,9 +406,10 @@ unix_socket_permission: "0770"
|
|||||||
# use_expiry_from_token: false
|
# use_expiry_from_token: false
|
||||||
#
|
#
|
||||||
# # The OIDC scopes to use, defaults to "openid", "profile" and "email".
|
# # 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
|
# # Custom scopes can be configured as needed, be sure to always include the
|
||||||
# # required "openid" scope.
|
# # required "openid" scope.
|
||||||
# scope: ["openid", "profile", "email"]
|
# scope: ["openid", "profile", "email", "groups"]
|
||||||
#
|
#
|
||||||
# # Only verified email addresses are synchronized to the user profile by
|
# # Only verified email addresses are synchronized to the user profile by
|
||||||
# # default. Unverified emails may be allowed in case an identity provider
|
# # default. Unverified emails may be allowed in case an identity provider
|
||||||
|
|||||||
@ -240,13 +240,96 @@ endpoint.
|
|||||||
| username | `preferred_username` | Depends on identity provider, eg: `ssmith`, `ssmith@idp.example.com`, `\\example.com\ssmith` |
|
| 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 |
|
| 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 |
|
| 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
|
## Limitations
|
||||||
|
|
||||||
- Support for OpenID Connect aims to be generic and vendor independent. It offers only limited support for quirks of
|
- Support for OpenID Connect aims to be generic and vendor independent. It offers only limited support for quirks of
|
||||||
specific identity providers.
|
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 provided by the identity provider needs to adhere to this pattern:
|
||||||
- The username must be at least two characters long.
|
- The username must be at least two characters long.
|
||||||
- It must only contain letters, digits, hyphens, dots, underscores, and up to a single `@`.
|
- It must only contain letters, digits, hyphens, dots, underscores, and up to a single `@`.
|
||||||
|
|||||||
6
flake.lock
generated
6
flake.lock
generated
@ -20,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777270315,
|
"lastModified": 1779351318,
|
||||||
"narHash": "sha256-yKB4G6cKsQsWN7M6rZGk6gkJPDNPIzT05y4qzRyCDlI=",
|
"narHash": "sha256-f+JACbTqzZ+G92DSnXOUGRhGANb8Blh7CoeYOeBF8/U=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "6368eda62c9775c38ef7f714b2555a741c20c72d",
|
"rev": "4a29d733e8a7d5b824c3d8c958a946a9867b3eb2",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
12
flake.nix
12
flake.nix
@ -62,16 +62,16 @@
|
|||||||
|
|
||||||
protoc-gen-grpc-gateway = buildGo rec {
|
protoc-gen-grpc-gateway = buildGo rec {
|
||||||
pname = "grpc-gateway";
|
pname = "grpc-gateway";
|
||||||
version = "2.28.0";
|
version = "2.29.0";
|
||||||
|
|
||||||
src = pkgs.fetchFromGitHub {
|
src = pkgs.fetchFromGitHub {
|
||||||
owner = "grpc-ecosystem";
|
owner = "grpc-ecosystem";
|
||||||
repo = "grpc-gateway";
|
repo = "grpc-gateway";
|
||||||
rev = "v${version}";
|
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 ];
|
nativeBuildInputs = [ pkgs.installShellFiles ];
|
||||||
|
|
||||||
@ -97,16 +97,16 @@
|
|||||||
# Build golangci-lint with Go 1.26 (upstream uses hardcoded Go version)
|
# Build golangci-lint with Go 1.26 (upstream uses hardcoded Go version)
|
||||||
golangci-lint = buildGo rec {
|
golangci-lint = buildGo rec {
|
||||||
pname = "golangci-lint";
|
pname = "golangci-lint";
|
||||||
version = "2.11.4";
|
version = "2.12.2";
|
||||||
|
|
||||||
src = pkgs.fetchFromGitHub {
|
src = pkgs.fetchFromGitHub {
|
||||||
owner = "golangci";
|
owner = "golangci";
|
||||||
repo = "golangci-lint";
|
repo = "golangci-lint";
|
||||||
rev = "v${version}";
|
rev = "v${version}";
|
||||||
hash = "sha256-B19aLvfNRY9TOYw/71f2vpNUuSIz8OI4dL0ijGezsas=";
|
hash = "sha256-qR7fp1x2S+EwEAcplRHTvA3jWwLr/XSiYKSZtAwkrNU=";
|
||||||
};
|
};
|
||||||
|
|
||||||
vendorHash = "sha256-xuoj4+U4tB5gpABKq4Dbp2cxnljxdYoBbO8A7DqPM5E=";
|
vendorHash = "sha256-AG5wtLwWLz55bdp1oi3cW+9O3yj1W1P7MV9zxym7Pb4=";
|
||||||
|
|
||||||
subPackages = [ "cmd/golangci-lint" ];
|
subPackages = [ "cmd/golangci-lint" ];
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"vendor": {
|
"vendor": {
|
||||||
"goModSum": "sha256-nrTbTihH4NpnN817QxQEBZV5c7eneEcHl+AMMh8ehJY=",
|
"goModSum": "sha256-xos6ixeJRMrEengGJaRiSsrm+3S3R0OEsnv1e85Sqhw=",
|
||||||
"sri": "sha256-Y9f0Q2Kw07eB8bURLT0jce+YoSs2WoowEX7t8tkNDvw="
|
"sri": "sha256-bZod9sUUyQ67x/HzZrQ7SK+o5gAUxJhx7Rr6VdIUj1I="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.1
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
// - protoc (unknown)
|
// - protoc (unknown)
|
||||||
// source: headscale/v1/headscale.proto
|
// source: headscale/v1/headscale.proto
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,10 @@ type User struct {
|
|||||||
ProviderId string `protobuf:"bytes,6,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"`
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -122,6 +126,13 @@ func (x *User) GetProfilePicUrl() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *User) GetGroups() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Groups
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type CreateUserRequest struct {
|
type CreateUserRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
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 = "" +
|
const file_headscale_v1_user_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\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" +
|
"\x04User\x12\x0e\n" +
|
||||||
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x12\n" +
|
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x12\n" +
|
||||||
"\x04name\x18\x02 \x01(\tR\x04name\x129\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" +
|
"\vprovider_id\x18\x06 \x01(\tR\n" +
|
||||||
"providerId\x12\x1a\n" +
|
"providerId\x12\x1a\n" +
|
||||||
"\bprovider\x18\a \x01(\tR\bprovider\x12&\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" +
|
"\x11CreateUserRequest\x12\x12\n" +
|
||||||
"\x04name\x18\x01 \x01(\tR\x04name\x12!\n" +
|
"\x04name\x18\x01 \x01(\tR\x04name\x12!\n" +
|
||||||
"\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x14\n" +
|
"\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x14\n" +
|
||||||
|
|||||||
@ -1528,6 +1528,13 @@
|
|||||||
},
|
},
|
||||||
"profilePicUrl": {
|
"profilePicUrl": {
|
||||||
"type": "string"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
75
go.mod
75
go.mod
@ -1,6 +1,6 @@
|
|||||||
module github.com/juanfont/headscale
|
module github.com/juanfont/headscale
|
||||||
|
|
||||||
go 1.26.2
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/arl/statsviz v0.8.0
|
github.com/arl/statsviz v0.8.0
|
||||||
@ -8,20 +8,20 @@ require (
|
|||||||
github.com/chasefleming/elem-go v0.31.0
|
github.com/chasefleming/elem-go v0.31.0
|
||||||
github.com/coder/websocket v1.8.14
|
github.com/coder/websocket v1.8.14
|
||||||
github.com/coreos/go-oidc/v3 v3.18.0
|
github.com/coreos/go-oidc/v3 v3.18.0
|
||||||
github.com/creachadair/command v0.2.2
|
github.com/creachadair/command v0.2.6
|
||||||
github.com/creachadair/flax v0.0.5
|
github.com/creachadair/flax v0.0.6
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
|
||||||
github.com/docker/docker v28.5.2+incompatible
|
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/glebarez/sqlite v1.11.0
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/go-chi/metrics v0.1.1
|
github.com/go-chi/metrics v0.1.1
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5
|
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/gofrs/uuid/v5 v5.4.0
|
||||||
github.com/google/go-cmp v0.7.0
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/gorilla/mux v1.8.1
|
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/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/jagottsicher/termcolor v1.0.2
|
github.com/jagottsicher/termcolor v1.0.2
|
||||||
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
|
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/client_golang v1.23.2
|
||||||
github.com/prometheus/common v0.67.5
|
github.com/prometheus/common v0.67.5
|
||||||
github.com/pterm/pterm v0.12.83
|
github.com/pterm/pterm v0.12.83
|
||||||
github.com/puzpuzpuz/xsync/v4 v4.4.0
|
github.com/puzpuzpuz/xsync/v4 v4.5.0
|
||||||
github.com/rs/zerolog v1.35.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/samber/lo v1.53.0
|
||||||
github.com/sasha-s/go-deadlock v0.3.9
|
github.com/sasha-s/go-deadlock v0.3.9
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd
|
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd
|
||||||
github.com/tailscale/squibble v0.0.0-20260303070345-3ac5157f405e
|
github.com/tailscale/squibble v0.0.0-20260411062017-141f5d618bc4
|
||||||
github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c
|
github.com/tailscale/tailsql v0.0.0-20260521144131-377d992d0d71
|
||||||
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e
|
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||||
golang.org/x/crypto v0.50.0
|
golang.org/x/crypto v0.52.0
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a
|
||||||
golang.org/x/net v0.53.0
|
golang.org/x/net v0.55.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/sync v0.20.0
|
golang.org/x/sync v0.20.0
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d
|
google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94
|
||||||
google.golang.org/grpc v1.80.0
|
google.golang.org/grpc v1.81.1
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
pgregory.net/rapid v1.2.0
|
pgregory.net/rapid v1.3.0
|
||||||
tailscale.com v1.97.0-pre.0.20260429005429-40088602c960
|
tailscale.com v1.98.3
|
||||||
zombiezen.com/go/postgrestest v1.0.1
|
zombiezen.com/go/postgrestest v1.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -77,10 +78,10 @@ require (
|
|||||||
// together, e.g:
|
// together, e.g:
|
||||||
// go get modernc.org/libc@v1.55.3 modernc.org/sqlite@v1.33.1
|
// go get modernc.org/libc@v1.55.3 modernc.org/sqlite@v1.33.1
|
||||||
require (
|
require (
|
||||||
modernc.org/libc v1.70.0 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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
|
// NOTE: gvisor must be updated in lockstep with
|
||||||
@ -129,7 +130,7 @@ require (
|
|||||||
github.com/containerd/continuity v0.4.5 // indirect
|
github.com/containerd/continuity v0.4.5 // indirect
|
||||||
github.com/containerd/errdefs v1.0.0 // indirect
|
github.com/containerd/errdefs v1.0.0 // indirect
|
||||||
github.com/containerd/errdefs/pkg v0.3.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/creachadair/msync v0.8.2 // indirect
|
||||||
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
|
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
|
||||||
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // 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/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/gaissmai/bart v0.26.1 // indirect
|
github.com/gaissmai/bart v0.26.1 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.22.0 // 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-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // 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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // 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/pires/go-proxyproto v0.9.2 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/procfs v0.19.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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/safchain/ethtool v0.7.0 // indirect
|
github.com/safchain/ethtool v0.7.0 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.12.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/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect
|
||||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // 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/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/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/toqueteos/webbrowser v1.2.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // 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/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // 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/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
||||||
golang.org/x/image v0.27.0 // indirect
|
golang.org/x/image v0.40.0 // indirect
|
||||||
golang.org/x/mod v0.35.0 // indirect
|
golang.org/x/mod v0.36.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
golang.org/x/term v0.42.0 // indirect
|
golang.org/x/term v0.43.0 // indirect
|
||||||
golang.org/x/text v0.36.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
golang.org/x/time v0.15.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/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3 // 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
|
k8s.io/client-go v0.34.0 // indirect
|
||||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect
|
software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect
|
||||||
|
|||||||
170
go.sum
170
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 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
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/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.6 h1:+d4xUSZza5nqRVVsVRf5jDbix/0uKxVqFrc30F4uyRI=
|
||||||
github.com/creachadair/command v0.2.2/go.mod h1:Z6Zp6CSJcnaWWR4wHgdqzODnFdxFJAaa/DrcVkeUu3E=
|
github.com/creachadair/command v0.2.6/go.mod h1:HWvS3zEpVakKlNXxT/j1U8tNoDaY7By7ORYQ+m1ha2U=
|
||||||
github.com/creachadair/flax v0.0.5 h1:zt+CRuXQASxwQ68e9GHAOnEgAU29nF0zYMHOCrL5wzE=
|
github.com/creachadair/flax v0.0.6 h1:IFUdRdfKynNTyR5SRlrHMUGnxxMZZen8RVpwcDq/ftk=
|
||||||
github.com/creachadair/flax v0.0.5/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8=
|
github.com/creachadair/flax v0.0.6/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8=
|
||||||
github.com/creachadair/mds v0.26.2 h1:rCtvEV/bCRY0hGfwvvMg0p3yzKgBE8l/9OV4fjF9QQ8=
|
github.com/creachadair/mds v0.28.0 h1:VS4JsBSlClsqBrHT8cYMTMqFHJdU0P/Z1ES6zjnSMXo=
|
||||||
github.com/creachadair/mds v0.26.2/go.mod h1:dMBTCSy3iS3dwh4Rb1zxeZz2d7K8+N24GCTsayWtQRI=
|
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 h1:ujvc/SVJPn+bFwmjUHucXNTTn3opVe2YbQ46mBCnP08=
|
||||||
github.com/creachadair/msync v0.8.2/go.mod h1:LzxqD9kfIl/O3DczkwOgJplLPqwrTbIhINlf9bHIsEY=
|
github.com/creachadair/msync v0.8.2/go.mod h1:LzxqD9kfIl/O3DczkwOgJplLPqwrTbIhINlf9bHIsEY=
|
||||||
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
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/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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
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.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
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 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
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-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 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8=
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
|
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.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
|
||||||
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/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 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
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-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA=
|
||||||
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/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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
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/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 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
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.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
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 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
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/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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
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.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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=
|
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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
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=
|
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.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s=
|
||||||
github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA=
|
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/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.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg=
|
||||||
github.com/puzpuzpuz/xsync/v4 v4.4.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
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 h1:+yPxeC0mEaJzq1BfCt2h4BxlyrvIIBzR6suDc3BEF1U=
|
||||||
github.com/realclientip/realclientip-go v1.0.0/go.mod h1:CXnUdVwFRcXFJIRb/dTYqbT7ud48+Pi2pFm80bxDmcI=
|
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=
|
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/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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||||
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
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/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 h1:rlJzfDetsVvT61uz8x1YIcFn12akMfuPulHtZjtb7Is=
|
||||||
github.com/safchain/ethtool v0.7.0/go.mod h1:MenQKEjXdfkjD3mp2QdCk8B/hwvkrlOTm/FD4gTpFxQ=
|
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/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 h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
|
||||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
|
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-20260310221408-dcd97e42f251 h1:kNnJlwxSzue+VRJuDdZ/yebcMM2q9KqFmK9xQqH1YRc=
|
||||||
github.com/tailscale/setec v0.0.0-20260115174028-19d190c5556d/go.mod h1:6NU8H/GLPVX2TnXAY1duyy9ylLaHwFpr0X93UPiYmNI=
|
github.com/tailscale/setec v0.0.0-20260310221408-dcd97e42f251/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-20260411062017-141f5d618bc4 h1:1ghkd9YIC4J7umZuu9jZz8afWJSj1hCRSzfvdI2Q3Vo=
|
||||||
github.com/tailscale/squibble v0.0.0-20260303070345-3ac5157f405e/go.mod h1:xJkMmR3t+thnUQhA3Q4m2VSlS5pcOq+CIjmU/xfKKx4=
|
github.com/tailscale/squibble v0.0.0-20260411062017-141f5d618bc4/go.mod h1:EVp9PDh7v69Do6aNzFfLvL0fdCph3XskGMi+WcS/uOM=
|
||||||
github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c h1:7lJQ/zycbk1E9e0nUiMuwIDYprFTLpWXUwiPdi+tRlI=
|
github.com/tailscale/tailsql v0.0.0-20260521144131-377d992d0d71 h1:IXwFgoMvxnXLCmQldzyuOO+b8Ko1cxqeuz5o3oaMkj8=
|
||||||
github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c/go.mod h1:bpNmZdvZKmBstrZunT+NXL6hmrFw5AsuT7MGiYS8sRc=
|
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 h1:0tpDdAj9sSfSZg4gMwNTdqMP592sBrq2Sm0w6ipnh7k=
|
||||||
github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
|
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 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
|
||||||
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
|
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-20260427181203-e3ac4a0afb4e h1:GexFR7ak1iz26fxg8HWCpOEqAOL8UEZJ7J3JxeCalDs=
|
||||||
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/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 h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
|
||||||
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
|
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
|
||||||
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
|
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=
|
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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
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.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
|
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.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
|
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.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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=
|
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-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.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.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
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 h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
|
||||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
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.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
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-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-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.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.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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
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 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
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=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.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.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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-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-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
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.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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
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.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
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.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.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.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.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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
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-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.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.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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
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.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 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
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=
|
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 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
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-20260519071638-aa98bba5eb94 h1:DddG61lE5LkX6144z22i0gma9BMBs5aZ9B8lZLobxyw=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 h1:eZCjr/aAF8c5ccm5pb6T4EXgIei5MlAAPWPJk+5ArfY=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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=
|
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=
|
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 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo=
|
||||||
k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY=
|
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.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
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 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
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/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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
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 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
|
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||||
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
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 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
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.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
|
||||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
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 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
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 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
||||||
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
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.98.3 h1:caAbG4UfkKfKPE6b1fj5t4ep5qrwEis5AJu91ruvePw=
|
||||||
tailscale.com v1.97.0-pre.0.20260429005429-40088602c960/go.mod h1:8nwFkmNdNRtTIM2dkmr/DhbzSKeLmzusWOTacX1zVKk=
|
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 h1:aXoADQAJmZDU3+xilYVut0pHhgc0sF8ZspPW9gFNwP4=
|
||||||
zombiezen.com/go/postgrestest v1.0.1/go.mod h1:marlZezr+k2oSJrvXHnZUs1olHqpE9czlz8ZYkVxliQ=
|
zombiezen.com/go/postgrestest v1.0.1/go.mod h1:marlZezr+k2oSJrvXHnZUs1olHqpE9czlz8ZYkVxliQ=
|
||||||
|
|||||||
@ -12,24 +12,17 @@ import (
|
|||||||
"tailscale.com/util/set"
|
"tailscale.com/util/set"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// minVersionParts is the minimum number of version parts needed for major.minor.
|
||||||
// minVersionParts is the minimum number of version parts needed for major.minor.
|
const minVersionParts = 2
|
||||||
minVersionParts = 2
|
|
||||||
|
|
||||||
// legacyDERPCapVer is the capability version when LegacyDERP can be cleaned up.
|
// CanOldCodeBeCleanedUp is called at server startup to panic when
|
||||||
legacyDERPCapVer = 111
|
// [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
|
||||||
// CanOldCodeBeCleanedUp is intended to be called on startup to see if
|
// the code to remove; today there are none.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// All uses of Capability version checks should be listed here.
|
// All capability-version-gated cleanups should be registered here.
|
||||||
func CanOldCodeBeCleanedUp() {
|
func CanOldCodeBeCleanedUp() {
|
||||||
if MinSupportedCapabilityVersion >= legacyDERPCapVer {
|
|
||||||
panic("LegacyDERP can be cleaned up in tail.go")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func tailscaleVersSorted() []string {
|
func tailscaleVersSorted() []string {
|
||||||
|
|||||||
@ -42,6 +42,7 @@ var tailscaleToCapVer = map[string]tailcfg.CapabilityVersion{
|
|||||||
"v1.92": 131,
|
"v1.92": 131,
|
||||||
"v1.94": 131,
|
"v1.94": 131,
|
||||||
"v1.96": 133,
|
"v1.96": 133,
|
||||||
|
"v1.98": 138,
|
||||||
}
|
}
|
||||||
|
|
||||||
var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{
|
var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{
|
||||||
@ -77,6 +78,7 @@ var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{
|
|||||||
130: "v1.90",
|
130: "v1.90",
|
||||||
131: "v1.92",
|
131: "v1.92",
|
||||||
133: "v1.96",
|
133: "v1.96",
|
||||||
|
138: "v1.98",
|
||||||
}
|
}
|
||||||
|
|
||||||
// SupportedMajorMinorVersions is the number of major.minor Tailscale versions supported.
|
// SupportedMajorMinorVersions is the number of major.minor Tailscale versions supported.
|
||||||
@ -84,4 +86,4 @@ const SupportedMajorMinorVersions = 10
|
|||||||
|
|
||||||
// MinSupportedCapabilityVersion represents the minimum capability version
|
// MinSupportedCapabilityVersion represents the minimum capability version
|
||||||
// supported by this Headscale instance (latest 10 minor versions)
|
// supported by this Headscale instance (latest 10 minor versions)
|
||||||
const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 109
|
const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 113
|
||||||
|
|||||||
@ -9,10 +9,9 @@ var tailscaleLatestMajorMinorTests = []struct {
|
|||||||
stripV bool
|
stripV bool
|
||||||
expected []string
|
expected []string
|
||||||
}{
|
}{
|
||||||
{3, false, []string{"v1.92", "v1.94", "v1.96"}},
|
{3, false, []string{"v1.94", "v1.96", "v1.98"}},
|
||||||
{2, true, []string{"1.94", "1.96"}},
|
{2, true, []string{"1.96", "1.98"}},
|
||||||
{10, true, []string{
|
{10, true, []string{
|
||||||
"1.78",
|
|
||||||
"1.80",
|
"1.80",
|
||||||
"1.82",
|
"1.82",
|
||||||
"1.84",
|
"1.84",
|
||||||
@ -22,6 +21,7 @@ var tailscaleLatestMajorMinorTests = []struct {
|
|||||||
"1.92",
|
"1.92",
|
||||||
"1.94",
|
"1.94",
|
||||||
"1.96",
|
"1.96",
|
||||||
|
"1.98",
|
||||||
}},
|
}},
|
||||||
{0, false, nil},
|
{0, false, nil},
|
||||||
}
|
}
|
||||||
@ -30,7 +30,7 @@ var capVerMinimumTailscaleVersionTests = []struct {
|
|||||||
input tailcfg.CapabilityVersion
|
input tailcfg.CapabilityVersion
|
||||||
expected string
|
expected string
|
||||||
}{
|
}{
|
||||||
{109, "v1.78"},
|
{113, "v1.80"},
|
||||||
{32, "v1.24"},
|
{32, "v1.24"},
|
||||||
{41, "v1.30"},
|
{41, "v1.30"},
|
||||||
{46, "v1.32"},
|
{46, "v1.32"},
|
||||||
|
|||||||
@ -215,6 +215,27 @@ AND auth_key_id NOT IN (
|
|||||||
},
|
},
|
||||||
Rollback: func(db *gorm.DB) error { return nil },
|
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
|
// Fix the provider identifier for users that have a double slash in the
|
||||||
// provider identifier.
|
// provider identifier.
|
||||||
{
|
{
|
||||||
@ -315,6 +336,7 @@ AND auth_key_id NOT IN (
|
|||||||
provider_identifier text,
|
provider_identifier text,
|
||||||
provider text,
|
provider text,
|
||||||
profile_pic_url text,
|
profile_pic_url text,
|
||||||
|
groups text,
|
||||||
created_at datetime,
|
created_at datetime,
|
||||||
updated_at datetime,
|
updated_at datetime,
|
||||||
deleted_at datetime
|
deleted_at datetime
|
||||||
@ -381,8 +403,8 @@ AND auth_key_id NOT IN (
|
|||||||
|
|
||||||
// Copy data directly using SQL
|
// Copy data directly using SQL
|
||||||
dataCopySQL := []string{
|
dataCopySQL := []string{
|
||||||
`INSERT INTO users (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, 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`,
|
FROM users_old`,
|
||||||
|
|
||||||
`INSERT INTO pre_auth_keys (id, key, user_id, reusable, ephemeral, used, tags, expiration, created_at)
|
`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 },
|
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",
|
"202502131714",
|
||||||
"202502171819",
|
"202502171819",
|
||||||
"202505091439",
|
"202505091439",
|
||||||
|
"202505141323",
|
||||||
"202505141324",
|
"202505141324",
|
||||||
|
|
||||||
// As of 2025-07-02, no new IDs should be added here.
|
// As of 2025-07-02, no new IDs should be added here.
|
||||||
|
|||||||
@ -144,6 +144,60 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) {
|
|||||||
assert.NotContains(t, node7.Tags, "tag:forbidden", "node7 should NOT have tag:forbidden (unauthorized)")
|
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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ CREATE TABLE users(
|
|||||||
provider_identifier text,
|
provider_identifier text,
|
||||||
provider text,
|
provider text,
|
||||||
profile_pic_url text,
|
profile_pic_url text,
|
||||||
|
groups text,
|
||||||
|
|
||||||
created_at datetime,
|
created_at datetime,
|
||||||
updated_at datetime,
|
updated_at datetime,
|
||||||
|
|||||||
82
hscontrol/db/testdata/sqlite/zero_time_expiry_migration_test.sql
vendored
Normal file
82
hscontrol/db/testdata/sqlite/zero_time_expiry_migration_test.sql
vendored
Normal file
@ -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;
|
||||||
@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -139,10 +140,49 @@ func setDatabaseVersion(db *gorm.DB, version string) error {
|
|||||||
return nil
|
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 "<sep><14 digits>-<12
|
||||||
|
// lowercase hex>" of a Go module pseudo-version. The base form
|
||||||
|
// (vX.0.0-<date>-<hash>) uses "-" before the timestamp; the
|
||||||
|
// pre-release-ancestor and release-ancestor forms
|
||||||
|
// (vX.Y.Z-pre.0.<date>-<hash> and vX.Y.(Z+1)-0.<date>-<hash>) 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
|
// 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-<timestamp>-<commit>) are treated as dev to avoid poisoning
|
||||||
|
// database_versions with synthetic baselines.
|
||||||
func isDev(version string) bool {
|
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
|
// checkVersionUpgradePath verifies that the running headscale version
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@ -56,12 +57,206 @@ func TestSemverString(t *testing.T) {
|
|||||||
assert.Equal(t, "v0.28.3", s.String())
|
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) {
|
func TestIsDev(t *testing.T) {
|
||||||
assert.True(t, isDev(""))
|
tests := []struct {
|
||||||
assert.True(t, isDev("dev"))
|
name string
|
||||||
assert.True(t, isDev("(devel)"))
|
input string
|
||||||
assert.False(t, isDev("v0.28.0"))
|
want bool
|
||||||
assert.False(t, isDev("0.28.0"))
|
}{
|
||||||
|
// 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
|
// versionTestDB creates an in-memory SQLite database with the
|
||||||
|
|||||||
@ -630,6 +630,9 @@ func (b *Batcher) processBatchedChanges() {
|
|||||||
return true
|
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.
|
// Queue a single work item containing all pending changes.
|
||||||
// One item per node ensures a single worker processes them
|
// One item per node ensures a single worker processes them
|
||||||
// sequentially, preventing out-of-order delivery.
|
// sequentially, preventing out-of-order delivery.
|
||||||
|
|||||||
@ -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
|
// TestBatcherWorkerChannelSafety tests that worker goroutines handle closed
|
||||||
// channels safely without panicking when processing work items.
|
// channels safely without panicking when processing work items.
|
||||||
//
|
//
|
||||||
|
|||||||
@ -70,7 +70,6 @@ func TestTailNode(t *testing.T) {
|
|||||||
Name: "empty",
|
Name: "empty",
|
||||||
StableID: "0",
|
StableID: "0",
|
||||||
HomeDERP: 0,
|
HomeDERP: 0,
|
||||||
LegacyDERPString: "127.3.3.40:0",
|
|
||||||
Hostinfo: hiview(tailcfg.Hostinfo{}),
|
Hostinfo: hiview(tailcfg.Hostinfo{}),
|
||||||
MachineAuthorized: true,
|
MachineAuthorized: true,
|
||||||
|
|
||||||
@ -149,7 +148,6 @@ func TestTailNode(t *testing.T) {
|
|||||||
netip.MustParsePrefix("192.168.0.0/24"),
|
netip.MustParsePrefix("192.168.0.0/24"),
|
||||||
},
|
},
|
||||||
HomeDERP: 0,
|
HomeDERP: 0,
|
||||||
LegacyDERPString: "127.3.3.40:0",
|
|
||||||
Hostinfo: hiview(tailcfg.Hostinfo{
|
Hostinfo: hiview(tailcfg.Hostinfo{
|
||||||
RoutableIPs: []netip.Prefix{
|
RoutableIPs: []netip.Prefix{
|
||||||
tsaddr.AllIPv4(),
|
tsaddr.AllIPv4(),
|
||||||
@ -186,7 +184,6 @@ func TestTailNode(t *testing.T) {
|
|||||||
Name: "minimal.example.com.",
|
Name: "minimal.example.com.",
|
||||||
StableID: "0",
|
StableID: "0",
|
||||||
HomeDERP: 0,
|
HomeDERP: 0,
|
||||||
LegacyDERPString: "127.3.3.40:0",
|
|
||||||
Hostinfo: hiview(tailcfg.Hostinfo{}),
|
Hostinfo: hiview(tailcfg.Hostinfo{}),
|
||||||
MachineAuthorized: true,
|
MachineAuthorized: true,
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,14 @@ type PolicyManager interface {
|
|||||||
// NodeCanApproveRoute reports whether the given node can approve the given route.
|
// NodeCanApproveRoute reports whether the given node can approve the given route.
|
||||||
NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool
|
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.
|
// ViaRoutesForPeer computes via grant effects for a viewer-peer pair.
|
||||||
// It returns which routes should be included (peer is via-designated for viewer)
|
// 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,
|
// and excluded (steered to a different peer). When no via grants apply,
|
||||||
|
|||||||
@ -594,6 +594,58 @@ func hasPerNodeGrants(grants []compiledGrant) bool {
|
|||||||
return false
|
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.
|
// globalFilterRules extracts global filter rules from [compiledGrant]s.
|
||||||
// Via grants produce no global rules (they are per-node only); regular
|
// Via grants produce no global rules (they are per-node only); regular
|
||||||
// grants contribute their full pre-compiled ruleset; self grants
|
// grants contribute their full pre-compiled ruleset; self grants
|
||||||
|
|||||||
137
hscontrol/policy/v2/node_recompute_test.go
Normal file
137
hscontrol/policy/v2/node_recompute_test.go
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -45,6 +45,15 @@ type PolicyManager struct {
|
|||||||
autoApproveMapHash deephash.Sum
|
autoApproveMapHash deephash.Sum
|
||||||
autoApproveMap map[netip.Prefix]*netipx.IPSet
|
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
|
// Lazy map of SSH policies
|
||||||
sshPolicyMap map[types.NodeID]*tailcfg.SSHPolicy
|
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.compiledGrants = pm.pol.compileGrants(pm.users, pm.nodes)
|
||||||
pm.userNodeIdx = buildUserNodeIndex(pm.nodes)
|
pm.userNodeIdx = buildUserNodeIndex(pm.nodes)
|
||||||
pm.needsPerNodeFilter = hasPerNodeGrants(pm.compiledGrants)
|
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
|
var filter []tailcfg.FilterRule
|
||||||
if pm.pol == nil || (pm.pol.ACLs == nil && pm.pol.Grants == nil) {
|
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
|
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
|
// SSHPolicy returns the [tailcfg.SSHPolicy] for node, compiling and
|
||||||
// caching on first access. Rules use SessionDuration = 0 (no
|
// caching on first access. Rules use SessionDuration = 0 (no
|
||||||
// auto-approval) and emit check URLs of the form
|
// 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),
|
// 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
|
// so we skip them when collecting affected users, except when tag status changes
|
||||||
// (which affects the user's device set).
|
// (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)
|
// Check for removed nodes (only non-tagged nodes affect autogroup:self)
|
||||||
for nodeID, oldNode := range oldNodeMap {
|
for nodeID, oldNode := range oldNodeMap {
|
||||||
if _, exists := newNodeMap[nodeID]; !exists {
|
if _, exists := newNodeMap[nodeID]; !exists {
|
||||||
if !oldNode.IsTagged() {
|
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 {
|
for nodeID, newNode := range newNodeMap {
|
||||||
if _, exists := oldNodeMap[nodeID]; !exists {
|
if _, exists := oldNodeMap[nodeID]; !exists {
|
||||||
if !newNode.IsTagged() {
|
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() != newNode.IsTagged() {
|
||||||
if !oldNode.IsTagged() {
|
if !oldNode.IsTagged() {
|
||||||
// Was untagged, now tagged: user lost a device
|
// Was untagged, now tagged: user lost a device
|
||||||
affectedUsers[oldNode.User().ID()] = struct{}{}
|
affectedUsers[oldNode.TypedUserID()] = struct{}{}
|
||||||
} else {
|
} else {
|
||||||
// Was tagged, now untagged: user gained a device
|
// Was tagged, now untagged: user gained a device
|
||||||
affectedUsers[newNode.User().ID()] = struct{}{}
|
affectedUsers[newNode.TypedUserID()] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
@ -1342,9 +1403,9 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user changed (both versions are non-tagged here)
|
// Check if user changed (both versions are non-tagged here)
|
||||||
if oldNode.User().ID() != newNode.User().ID() {
|
if oldNode.TypedUserID() != newNode.TypedUserID() {
|
||||||
affectedUsers[oldNode.User().ID()] = struct{}{}
|
affectedUsers[oldNode.TypedUserID()] = struct{}{}
|
||||||
affectedUsers[newNode.User().ID()] = struct{}{}
|
affectedUsers[newNode.TypedUserID()] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if IPs changed (simple check - could be more sophisticated)
|
// 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()
|
newIPs := newNode.IPs()
|
||||||
if len(oldIPs) != len(newIPs) {
|
if len(oldIPs) != len(newIPs) {
|
||||||
affectedUsers[newNode.User().ID()] = struct{}{}
|
affectedUsers[newNode.TypedUserID()] = struct{}{}
|
||||||
} else {
|
} else {
|
||||||
// Check if any IPs are different
|
// Check if any IPs are different
|
||||||
for i, oldIP := range oldIPs {
|
for i, oldIP := range oldIPs {
|
||||||
if i >= len(newIPs) || oldIP != newIPs[i] {
|
if i >= len(newIPs) || oldIP != newIPs[i] {
|
||||||
affectedUsers[newNode.User().ID()] = struct{}{}
|
affectedUsers[newNode.TypedUserID()] = struct{}{}
|
||||||
break
|
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.
|
// because autogroup:self rules depend on the entire user's device set.
|
||||||
for nodeID := range pm.filterRulesMap {
|
for nodeID := range pm.filterRulesMap {
|
||||||
// Find the user for this cached node
|
// Find the user for this cached node
|
||||||
var nodeUserID uint
|
var nodeUserID types.UserID
|
||||||
|
|
||||||
found := false
|
found := false
|
||||||
|
|
||||||
@ -1384,7 +1445,7 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
nodeUserID = node.User().ID()
|
nodeUserID = node.TypedUserID()
|
||||||
found = true
|
found = true
|
||||||
|
|
||||||
break
|
break
|
||||||
@ -1400,7 +1461,7 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
nodeUserID = node.User().ID()
|
nodeUserID = node.TypedUserID()
|
||||||
found = true
|
found = true
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|||||||
@ -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.
|
// TestInvalidateGlobalPolicyCache tests the cache invalidation logic for global policies.
|
||||||
func TestInvalidateGlobalPolicyCache(t *testing.T) {
|
func TestInvalidateGlobalPolicyCache(t *testing.T) {
|
||||||
mustIPPtr := func(s string) *netip.Addr {
|
mustIPPtr := func(s string) *netip.Addr {
|
||||||
|
|||||||
216
hscontrol/state/connect_test.go
Normal file
216
hscontrol/state/connect_test.go
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -594,9 +594,14 @@ func (s *State) Connect(id types.NodeID) ([]change.Change, uint64) {
|
|||||||
c = append(c, change.NodeAdded(id))
|
c = append(c, change.NodeAdded(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coming online may re-enable cap/relay grants and identity-based
|
// Only a node whose online state changes what peers compute (a subnet
|
||||||
// aliases targeting this node, so peers need a fresh netmap.
|
// 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())
|
c = append(c, change.PolicyChange())
|
||||||
|
}
|
||||||
|
|
||||||
return c, epoch
|
return c, epoch
|
||||||
}
|
}
|
||||||
@ -648,13 +653,15 @@ func (s *State) Disconnect(id types.NodeID, epoch uint64) ([]change.Change, erro
|
|||||||
c = change.Change{}
|
c = change.Change{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Going offline can affect policy compilation beyond subnet routes
|
// Only a node whose online state changes what peers compute (a subnet
|
||||||
// (cap/relay grants, tag/group aliases, via routes), so peers need
|
// router, relay target, or via target) needs a full peer recompute.
|
||||||
// a fresh netmap regardless of whether the primary moved.
|
// An ordinary node going offline just sends the lightweight offline
|
||||||
//
|
// patch; emitting a PolicyChange for it would force every peer to
|
||||||
// TODO(kradalby): fires one full netmap recompute per peer on
|
// rebuild its netmap on every disconnect.
|
||||||
// every connect/disconnect. Coalesce in mapper/batcher.go:addToBatch.
|
cs := []change.Change{change.NodeOfflineFor(node), c}
|
||||||
cs := []change.Change{change.NodeOfflineFor(node), c, change.PolicyChange()}
|
if s.polMan.NodeNeedsPeerRecompute(node) {
|
||||||
|
cs = append(cs, change.PolicyChange())
|
||||||
|
}
|
||||||
|
|
||||||
return cs, nil
|
return cs, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -250,6 +250,41 @@ func FilterForNode(nodeID types.NodeID, rs []Change) []Change {
|
|||||||
return result
|
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 {
|
func uniqueNodeIDs(ids []types.NodeID) []types.NodeID {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@ -421,29 +456,19 @@ func NodeRemoved(id types.NodeID) Change {
|
|||||||
return PeersRemoved(id)
|
return PeersRemoved(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeOnlineFor returns a [Change] for when a node comes online.
|
// NodeOnlineFor returns the [Change] for a node coming online: a lightweight
|
||||||
// If the node is a subnet router, a full update is sent instead of a patch.
|
// [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 {
|
func NodeOnlineFor(node types.NodeView) Change {
|
||||||
if node.IsSubnetRouter() {
|
|
||||||
c := FullUpdate()
|
|
||||||
c.Reason = "subnet router online"
|
|
||||||
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
return NodeOnline(node.ID())
|
return NodeOnline(node.ID())
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeOfflineFor returns a [Change] for when a node goes offline.
|
// NodeOfflineFor returns the [Change] for a node going offline: a lightweight
|
||||||
// If the node is a subnet router, a full update is sent instead of a patch.
|
// [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 {
|
func NodeOfflineFor(node types.NodeView) Change {
|
||||||
if node.IsSubnetRouter() {
|
|
||||||
c := FullUpdate()
|
|
||||||
c.Reason = "subnet router offline"
|
|
||||||
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
return NodeOffline(node.ID())
|
return NodeOffline(node.ID())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
package change
|
package change
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/netip"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"tailscale.com/tailcfg"
|
"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) {
|
func TestChange_Constructors(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1185,11 +1185,7 @@ func (nv NodeView) TailNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var derp int
|
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() {
|
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()
|
derp = nv.Hostinfo().NetInfo().PreferredDERP()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1273,7 +1269,6 @@ func (nv NodeView) TailNode(
|
|||||||
AllowedIPs: allowedIPs,
|
AllowedIPs: allowedIPs,
|
||||||
Endpoints: nv.Endpoints().AsSlice(),
|
Endpoints: nv.Endpoints().AsSlice(),
|
||||||
HomeDERP: derp,
|
HomeDERP: derp,
|
||||||
LegacyDERPString: legacyDERP,
|
|
||||||
Hostinfo: nv.Hostinfo(),
|
Hostinfo: nv.Hostinfo(),
|
||||||
Created: nv.CreatedAt().UTC(),
|
Created: nv.CreatedAt().UTC(),
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,7 @@ var _UserCloneNeedsRegeneration = User(struct {
|
|||||||
ProviderIdentifier sql.NullString
|
ProviderIdentifier sql.NullString
|
||||||
Provider string
|
Provider string
|
||||||
ProfilePicURL string
|
ProfilePicURL string
|
||||||
|
Groups string
|
||||||
}{})
|
}{})
|
||||||
|
|
||||||
// Clone makes a deep copy of Node.
|
// Clone makes a deep copy of Node.
|
||||||
|
|||||||
@ -124,8 +124,11 @@ var _UserViewNeedsRegeneration = User(struct {
|
|||||||
ProviderIdentifier sql.NullString
|
ProviderIdentifier sql.NullString
|
||||||
Provider string
|
Provider string
|
||||||
ProfilePicURL string
|
ProfilePicURL string
|
||||||
|
Groups string
|
||||||
}{})
|
}{})
|
||||||
|
|
||||||
|
func (v UserView) Groups() string { return v.ж.Groups }
|
||||||
|
|
||||||
// View returns a read-only view of Node.
|
// View returns a read-only view of Node.
|
||||||
func (p *Node) View() NodeView {
|
func (p *Node) View() NodeView {
|
||||||
return NodeView{ж: p}
|
return NodeView{ж: p}
|
||||||
|
|||||||
@ -95,6 +95,11 @@ type User struct {
|
|||||||
Provider string
|
Provider string
|
||||||
|
|
||||||
ProfilePicURL 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 {
|
func (u *User) StringID() string {
|
||||||
@ -139,6 +144,39 @@ func (u *User) profilePicURL() string {
|
|||||||
return u.ProfilePicURL
|
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 {
|
func (u *User) TailscaleUser() tailcfg.User {
|
||||||
return tailcfg.User{
|
return tailcfg.User{
|
||||||
ID: tailcfg.UserID(u.ID), //nolint:gosec // UserID is bounded
|
ID: tailcfg.UserID(u.ID), //nolint:gosec // UserID is bounded
|
||||||
@ -205,6 +243,7 @@ func (u *User) Proto() *v1.User {
|
|||||||
ProviderId: u.ProviderIdentifier.String,
|
ProviderId: u.ProviderIdentifier.String,
|
||||||
Provider: u.Provider,
|
Provider: u.Provider,
|
||||||
ProfilePicUrl: u.ProfilePicURL,
|
ProfilePicUrl: u.ProfilePicURL,
|
||||||
|
Groups: u.GetGroups(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -447,4 +486,7 @@ func (u *User) FromClaim(claims *OIDCClaims, emailVerifiedRequired bool) {
|
|||||||
u.DisplayName = claims.Name
|
u.DisplayName = claims.Name
|
||||||
u.ProfilePicURL = claims.ProfilePictureURL
|
u.ProfilePicURL = claims.ProfilePictureURL
|
||||||
u.Provider = util.RegisterMethodOIDC
|
u.Provider = util.RegisterMethodOIDC
|
||||||
|
|
||||||
|
// Store OIDC groups for role-based access control
|
||||||
|
u.SetGroups(claims.Groups)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -528,6 +528,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
|
|||||||
Valid: true,
|
Valid: true,
|
||||||
},
|
},
|
||||||
ProfilePicURL: "https://cdn.casbin.org/img/casbin.svg",
|
ProfilePicURL: "https://cdn.casbin.org/img/casbin.svg",
|
||||||
|
Groups: `["org1/department1","org1/department2"]`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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())
|
assert.Falsef(c, netmap.SelfNode.DiscoKey().IsZero(), "%q does not have a valid DiscoKey", client.Hostname())
|
||||||
|
|
||||||
for _, peer := range netmap.Peers {
|
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.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())
|
assert.Truef(c, peer.Hostinfo().Valid(), "peer (%s) of %q does not have Hostinfo", peer.ComputedName(), client.Hostname())
|
||||||
|
|||||||
113
integration/oidc_groups_test.go
Normal file
113
integration/oidc_groups_test.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -522,6 +522,9 @@ func New(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "unstable":
|
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.Repository = "tailscale/tailscale"
|
||||||
tailscaleOptions.Tag = version
|
tailscaleOptions.Tag = version
|
||||||
|
|
||||||
@ -542,7 +545,7 @@ func New(
|
|||||||
log.Printf("Docker run failed for %s (unstable), error: %v", hostname, err)
|
log.Printf("Docker run failed for %s (unstable), error: %v", hostname, err)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
tailscaleOptions.Repository = "tailscale/tailscale"
|
tailscaleOptions.Repository = "ghcr.io/tailscale/tailscale"
|
||||||
tailscaleOptions.Tag = "v" + version
|
tailscaleOptions.Tag = "v" + version
|
||||||
|
|
||||||
err = dockertestutil.PullWithAuth(pool, tailscaleOptions.Repository+":"+tailscaleOptions.Tag)
|
err = dockertestutil.PullWithAuth(pool, tailscaleOptions.Repository+":"+tailscaleOptions.Tag)
|
||||||
|
|||||||
@ -13,6 +13,10 @@ message User {
|
|||||||
string provider_id = 6;
|
string provider_id = 6;
|
||||||
string provider = 7;
|
string provider = 7;
|
||||||
string profile_pic_url = 8;
|
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 {
|
message CreateUserRequest {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user