diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2c07660df..81246706c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,6 +10,10 @@ concurrency: group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} cancel-in-progress: true +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + jobs: build-nix: runs-on: ubuntu-latest @@ -29,20 +33,16 @@ jobs: - '**/*.go' - 'integration_test/' - 'config-example.yaml' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main if: steps.changed-files.outputs.files == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main if: steps.changed-files.outputs.files == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - name: Check vendor hash id: vendorhash if: steps.changed-files.outputs.files == 'true' run: | - nix develop --command -- go run ./cmd/vendorhash check | tee check-result + go run ./cmd/vendorhash check | tee check-result { grep '^expected_sri=' check-result || true grep '^actual_sri=' check-result || true @@ -63,7 +63,7 @@ jobs: - name: Run nix build if: steps.changed-files.outputs.files == 'true' - run: nix build + run: nix build --fallback - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 if: steps.changed-files.outputs.files == 'true' @@ -81,17 +81,13 @@ jobs: - "GOARCH=amd64 GOOS=darwin" 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 }} + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main - name: Run go cross compile env: CGO_ENABLED: 0 - run: env ${{ matrix.env }} nix develop --command -- go build -o "headscale" + run: env ${{ matrix.env }} go build -o "headscale" ./cmd/headscale - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: diff --git a/.github/workflows/check-generated.yml b/.github/workflows/check-generated.yml index 43f1d62d4..fffc3da04 100644 --- a/.github/workflows/check-generated.yml +++ b/.github/workflows/check-generated.yml @@ -12,6 +12,10 @@ concurrency: group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} cancel-in-progress: true +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + jobs: check-generated: runs-on: ubuntu-latest @@ -28,20 +32,15 @@ jobs: - '*.nix' - 'go.*' - '**/*.go' - - '**/*.proto' - - 'buf.gen.yaml' - 'tools/**' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main if: steps.changed-files.outputs.files == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main if: steps.changed-files.outputs.files == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - name: Run make generate if: steps.changed-files.outputs.files == 'true' - run: nix develop --command -- make generate + run: make generate - name: Check for uncommitted changes if: steps.changed-files.outputs.files == 'true' diff --git a/.github/workflows/check-tests.yaml b/.github/workflows/check-tests.yaml index 63a181413..b73011732 100644 --- a/.github/workflows/check-tests.yaml +++ b/.github/workflows/check-tests.yaml @@ -6,6 +6,10 @@ concurrency: group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} cancel-in-progress: true +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + jobs: check-tests: runs-on: ubuntu-latest @@ -24,19 +28,15 @@ jobs: - '**/*.go' - 'integration_test/' - 'config-example.yaml' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main if: steps.changed-files.outputs.files == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main if: steps.changed-files.outputs.files == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - name: Generate and check integration tests if: steps.changed-files.outputs.files == 'true' run: | - nix develop --command bash -c "cd .github/workflows && go generate" + (cd .github/workflows && go generate) git diff --exit-code .github/workflows/test-integration.yaml - name: Show missing tests diff --git a/.github/workflows/container-main.yml b/.github/workflows/container-main.yml index 9b55a273e..e2ebe6fc0 100644 --- a/.github/workflows/container-main.yml +++ b/.github/workflows/container-main.yml @@ -16,6 +16,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.sha }} cancel-in-progress: true +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + jobs: container: if: github.repository == 'juanfont/headscale' @@ -40,12 +44,8 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - 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 }} + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main - name: Set commit timestamp run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> $GITHUB_ENV @@ -56,7 +56,7 @@ jobs: KO_DEFAULTBASEIMAGE: gcr.io/distroless/base-debian13 CGO_ENABLED: "0" run: | - nix develop --command -- ko build \ + ko build \ --bare \ --platform=linux/amd64,linux/arm64 \ --tags=main-${GITHUB_SHA::7},development \ @@ -68,7 +68,7 @@ jobs: KO_DEFAULTBASEIMAGE: gcr.io/distroless/base-debian13 CGO_ENABLED: "0" run: | - nix develop --command -- ko build \ + ko build \ --bare \ --platform=linux/amd64,linux/arm64 \ --tags=main-${GITHUB_SHA::7},development \ @@ -92,19 +92,15 @@ jobs: - name: Checkout 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 }} + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main - name: Build binary env: CGO_ENABLED: "0" GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} - run: nix develop --command -- go build -o headscale ./cmd/headscale + run: go build -o headscale ./cmd/headscale - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: diff --git a/.github/workflows/gc.yml b/.github/workflows/gc.yml new file mode 100644 index 000000000..9df7a0370 --- /dev/null +++ b/.github/workflows/gc.yml @@ -0,0 +1,39 @@ +name: Cache GC + +# Garbage collection for the hestia binary cache. Must run on the default +# branch: a PR job's cache scope is read-only towards the default branch and +# dies with the PR, but the default-branch scope grows forever without GC. + +concurrency: + group: hestia-gc + cancel-in-progress: false + +on: + schedule: + # Daily, off-peak (UTC). + - cron: "23 3 * * *" + workflow_dispatch: + inputs: + dry-run: + description: Plan only; do not repack, touch, or delete anything. + type: boolean + default: false + +permissions: + contents: read + +jobs: + gc: + runs-on: ubuntu-latest + permissions: + # REST cache deletes need actions:write. + actions: write + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main + - name: Run garbage collection + run: '"${HESTIA_BIN}" gc ${{ inputs.dry-run && ''--dry-run'' || '''' }}' + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/integration-test-template.yml b/.github/workflows/integration-test-template.yml index 68dbfd366..590185c96 100644 --- a/.github/workflows/integration-test-template.yml +++ b/.github/workflows/integration-test-template.yml @@ -78,6 +78,13 @@ jobs: echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json sudo systemctl restart docker docker version + - name: Load br_netfilter for in-cluster service routing + if: inputs.test == 'TestK8sOperator' + # TestK8sOperator runs k3s in a container; without br_netfilter on the + # host, bridged pod-to-pod traffic skips kube-proxy's ClusterIP DNAT and + # in-cluster DNS (kube-dns) is unreachable. The module cannot be loaded + # from inside the unprivileged-module rancher/k3s image, so load it here. + run: sudo modprobe br_netfilter - name: Login to Docker Hub env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 5e8ab5c4a..000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Lint - -on: [pull_request] - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - golangci-lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - fetch-depth: 0 - - name: Get changed files - id: changed-files - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - with: - filters: | - files: - - '*.nix' - - 'go.*' - - '**/*.go' - - 'integration_test/' - - 'config-example.yaml' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - if: steps.changed-files.outputs.files == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 - if: steps.changed-files.outputs.files == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - - - name: golangci-lint - if: steps.changed-files.outputs.files == 'true' - run: nix develop --command -- golangci-lint run - --new-from-rev=${{github.event.pull_request.base.sha}} - --output.text.path=stdout - --output.text.print-linter-name - --output.text.print-issued-lines - --output.text.colors - - prettier-lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - fetch-depth: 2 - - name: Get changed files - id: changed-files - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - with: - filters: | - files: - - '*.nix' - - '**/*.md' - - '**/*.yml' - - '**/*.yaml' - - '**/*.ts' - - '**/*.js' - - '**/*.sass' - - '**/*.css' - - '**/*.scss' - - '**/*.html' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - if: steps.changed-files.outputs.files == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 - if: steps.changed-files.outputs.files == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - - - name: Prettify code - if: steps.changed-files.outputs.files == 'true' - run: nix develop --command -- prettier --no-error-on-unmatched-pattern - --ignore-unknown --check **/*.{ts,js,md,yaml,yml,sass,css,scss,html} - - proto-lint: - runs-on: ubuntu-latest - 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: Buf lint - run: nix develop --command -- buf lint proto diff --git a/.github/workflows/nix-checks.yml b/.github/workflows/nix-checks.yml new file mode 100644 index 000000000..03c4b8e8b --- /dev/null +++ b/.github/workflows/nix-checks.yml @@ -0,0 +1,56 @@ +name: Nix Flake Checks + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +# Each job only runs `nix build .#checks..`; the check logic lives +# in flake.nix via the flake-checks library. The fileset-filtered checks hit the +# hestia cache when their inputs are unchanged, so no changed-files gating. +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main + - name: build + run: nix build -L .#checks.x86_64-linux.build + + gotest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main + - name: gotest + run: nix build -L .#checks.x86_64-linux.gotest + + golangci-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main + - name: golangci-lint + run: nix build -L .#checks.x86_64-linux.golangci-lint + + formatting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main + - name: formatting + run: nix build -L .#checks.x86_64-linux.formatting diff --git a/.github/workflows/nix-module-test.yml b/.github/workflows/nix-module-test.yml index 68ad95456..1a7fbcc85 100644 --- a/.github/workflows/nix-module-test.yml +++ b/.github/workflows/nix-module-test.yml @@ -38,18 +38,14 @@ jobs: - 'cmd/**' - 'hscontrol/**' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - name: Run NixOS module tests if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true' run: | echo "Running NixOS module integration test..." - nix build .#checks.x86_64-linux.headscale -L + nix build .#checks.x86_64-linux.headscale -L --fallback diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2eeacb4f..349ad53d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,10 @@ on: - "*" # triggers only if push new tag version workflow_dispatch: +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + jobs: goreleaser: if: github.repository == 'juanfont/headscale' @@ -17,25 +21,6 @@ jobs: with: fetch-depth: 0 - - name: Pin Docker to v28 (avoid v29 breaking changes) - run: | - # Docker 29 breaks docker build via Go client libraries and - # docker load/save with certain tarball formats. - # Pin to Docker 28.x until our tooling is updated. - # https://github.com/actions/runner-images/issues/13474 - sudo install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ - | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ - https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ - | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null - sudo apt-get update -qq - VERSION=$(apt-cache madison docker-ce | grep '28\.5' | head -1 | awk '{print $3}') - sudo apt-get install -y --allow-downgrades \ - "docker-ce=${VERSION}" "docker-ce-cli=${VERSION}" - sudo systemctl restart docker - docker version - - name: Login to DockerHub uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: @@ -49,14 +34,10 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - 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 }} + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main - name: Run goreleaser - run: nix develop --command -- goreleaser release --clean + run: goreleaser release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/servertest.yml b/.github/workflows/servertest.yml new file mode 100644 index 000000000..795b29590 --- /dev/null +++ b/.github/workflows/servertest.yml @@ -0,0 +1,36 @@ +name: Server Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +# hscontrol/servertest is excluded from the sandboxed gotest flake check: it is +# slow (10s+ convergence cases) and timing-sensitive (race/stress/HA property +# tests), so it runs here in the devShell with a generous timeout instead. +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + +permissions: + contents: read + +jobs: + servertest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main + + - name: go test ./hscontrol/servertest + env: + CGO_ENABLED: "0" + run: go test -timeout=20m ./hscontrol/servertest/... diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index a5feddff0..0e2a03377 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -6,6 +6,9 @@ on: [pull_request] concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true +defaults: + run: + shell: nix develop --fallback --command bash -e {0} jobs: # build: Builds binaries and Docker images once, uploads as artifacts for reuse. # build-postgres: Pulls postgres image separately to avoid Docker Hub rate limits. @@ -36,23 +39,18 @@ jobs: - '.github/workflows/test-integration.yaml' - '.github/workflows/integration-test-template.yml' - 'Dockerfile.*' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main if: steps.changed-files.outputs.files == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main if: steps.changed-files.outputs.files == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - name: Build binaries and warm Go cache if: steps.changed-files.outputs.files == 'true' run: | # Build all Go binaries in one nix shell to maximize cache reuse - nix develop --command -- bash -c ' - go build -o hi ./cmd/hi - CGO_ENABLED=0 GOOS=linux go build -o headscale ./cmd/headscale - # Build integration test binary to warm the cache with all dependencies - go test -c ./integration -o /dev/null 2>/dev/null || true - ' + go build -o hi ./cmd/hi + CGO_ENABLED=0 GOOS=linux go build -o headscale ./cmd/headscale + # Build integration test binary to warm the cache with all dependencies + go test -c ./integration -o /dev/null 2>/dev/null || true - name: Upload hi binary if: steps.changed-files.outputs.files == 'true' uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 @@ -128,6 +126,7 @@ jobs: if: needs.build.outputs.files-changed == 'true' steps: - name: Force overlay2 storage driver + shell: bash run: | sudo mkdir -p /etc/docker echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json @@ -143,6 +142,7 @@ jobs: username: ${{ env.DOCKERHUB_USERNAME }} password: ${{ env.DOCKERHUB_TOKEN }} - name: Pull and save postgres image + shell: bash run: | docker pull postgres:latest docker tag postgres:latest postgres:${{ github.sha }} @@ -159,11 +159,8 @@ jobs: 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 }} + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main - name: Force overlay2 storage driver run: | sudo mkdir -p /etc/docker @@ -182,7 +179,7 @@ jobs: - name: List Tailscale versions to pre-pull id: versions run: | - versions=$(nix develop --command go run ./cmd/hi list-versions --set=must --exclude=head) + versions=$(go run ./cmd/hi list-versions --set=must --exclude=head) echo "versions=${versions}" >> "$GITHUB_OUTPUT" echo "Pre-pulling: ${versions}" - name: Pull Tailscale images @@ -244,7 +241,7 @@ jobs: - TestACLDynamicUnknownUserRemoval - TestAPIAuthenticationBypass - TestAPIAuthenticationBypassCurl - - TestGRPCAuthenticationBypass + - TestRemoteCLIAuthenticationBypass - TestCLIWithConfigAuthenticationBypass - TestAuthKeyLogoutAndReloginSameUser - TestAuthKeyLogoutAndReloginNewUser @@ -265,20 +262,34 @@ jobs: - TestAuthWebFlowAuthenticationPingAll - TestAuthWebFlowLogoutAndReloginSameUser - TestAuthWebFlowLogoutAndReloginNewUser - - TestPolicyCheckCommand - - TestSSHTestsRejectFailingPolicy - - TestUserCommand - - TestPreAuthKeyCommand - - TestPreAuthKeyCommandWithoutExpiry - - TestPreAuthKeyCommandReusableEphemeral - - TestPreAuthKeyCorrectUserLoggedInCommand - - TestTaggedNodesCLIOutput - TestApiKeyCommand + - TestApiKeyCommandValidation + - TestAuthCommandValidation - TestNodeCommand - TestNodeExpireCommand - TestNodeRenameCommand + - TestPreAuthKeyCorrectUserLoggedInCommand + - TestTaggedNodesCLIOutput + - TestNodeExpireFlagsCommand + - TestNodeCommandValidation + - TestNodeTagCommand + - TestNodeRouteCommands + - TestNodeBackfillIPsCommand + - TestOAuthClientCommand + - TestOAuthClientCommandValidation + - TestPolicyCheckCommand + - TestSSHTestsRejectFailingPolicy - TestPolicyCommand - TestPolicyBrokenConfigCommand + - TestPreAuthKeyCommand + - TestPreAuthKeyCommandWithoutExpiry + - TestPreAuthKeyCommandReusableEphemeral + - TestPreAuthKeyDeleteCommand + - TestPreAuthKeyCommandValidation + - TestServerInfoCommands + - TestUserCommand + - TestUserCreateCommand + - TestUserCommandValidation - TestDERPVerifyEndpoint - TestResolveMagicDNS - TestResolveMagicDNSExtraRecordsPath @@ -300,6 +311,7 @@ jobs: - Test2118DeletingOnlineNodePanics - TestGrantCapRelay - TestGrantCapDrive + - TestK8sOperator - TestEnablingRoutes - TestHASubnetRouterFailover - TestSubnetRouteACL @@ -367,6 +379,8 @@ jobs: - TestTagsAuthKeyWithoutUserInheritsTags - TestTagsAuthKeyWithoutUserRejectsAdvertisedTags - TestTagsAuthKeyConvertToUserViaCLIRegister + - TestTS2021WebSocketGET + - TestTS2021WASMClientUnderNode - TestTailscaleRustAxum uses: ./.github/workflows/integration-test-template.yml secrets: inherit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 31eb431b4..000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Tests - -on: [push, pull_request] - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - fetch-depth: 2 - - - name: Get changed files - id: changed-files - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - with: - filters: | - files: - - '*.nix' - - 'go.*' - - '**/*.go' - - 'integration_test/' - - 'config-example.yaml' - - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - if: steps.changed-files.outputs.files == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 - if: steps.changed-files.outputs.files == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - - - name: Run tests - if: steps.changed-files.outputs.files == 'true' - env: - # As of 2025-01-06, these env vars was not automatically - # set anymore which breaks the initdb for postgres on - # some of the database migration tests. - LC_ALL: "en_US.UTF-8" - LC_CTYPE: "en_US.UTF-8" - run: nix develop --command -- gotestsum diff --git a/.github/workflows/update-flake.yml b/.github/workflows/update-flake.yml deleted file mode 100644 index 1c8b262ed..000000000 --- a/.github/workflows/update-flake.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: update-flake-lock -on: - workflow_dispatch: # allows manual triggering - schedule: - - cron: "0 0 * * 0" # runs weekly on Sunday at 00:00 - -jobs: - lockfile: - if: github.repository == 'juanfont/headscale' - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Install Nix - uses: DeterminateSystems/nix-installer-action@21a544727d0c62386e78b4befe52d19ad12692e3 # v17 - - name: Update flake.lock - uses: DeterminateSystems/update-flake-lock@428c2b58a4b7414dabd372acb6a03dba1084d3ab # v25 - with: - pr-title: "Update flake.lock" diff --git a/.gitignore b/.gitignore index 8dbdbe855..9f72ef80a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ result integration_test/etc/config.dump.yaml +# OpenAPI spec is served live from the code and emitted on demand, not committed +/openapi/v1/headscale.yaml + # MkDocs .cache /site diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c1b065670..aa4355c4c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,8 +2,9 @@ # See: https://prek.j178.dev/quickstart/ # See: https://prek.j178.dev/builtin/ -# Global exclusions - ignore generated code -exclude: ^gen/ +# Global exclusions - ignore generated code (proto output and emitted OpenAPI) +# and recorded golden fixtures. +exclude: ^(gen|openapi)/|^hscontrol/testdata/apiv1_golden/ repos: # Built-in hooks from pre-commit/pre-commit-hooks diff --git a/AGENTS.md b/AGENTS.md index 7fc325c6a..8f3428acb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -275,6 +275,11 @@ Key reminders: `e = e.Str("k", v)`. Forgetting to reassign silently drops the field. - **Tests**: prefer `hscontrol/servertest/` for server-level tests that don't need Docker — faster than full integration tests. +- **View types in read paths**: response serializers must read through + `NodeView`/`UserView`/`PreAuthKeyView` accessors. `AsStruct()` clones the + whole record on every read — it is only for DB-write/merge clones and mutable + working copies, never to build an API response. `grep AsStruct hscontrol/api` + must come back empty. ## Gotchas diff --git a/CHANGELOG.md b/CHANGELOG.md index 8580865a5..785d7d855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,65 @@ **Minimum supported Tailscale client version: v1.xx.0** +### v1 REST API replaced; gRPC and Protobuf removed + +The v1 REST API now provides an OpenAPI 3.1 specification at +`/api/v1/openapi.yaml`, with interactive documentation at `/api/v1/docs`. This +replaces the Swagger 2.0 document and the `/swagger` UI. The Protobuf, gRPC and +grpc-gateway stack behind it is gone, and the `headscale` CLI now talks to the +HTTP API directly. + +[#3324](https://github.com/juanfont/headscale/pull/3324) + +### OAuth clients and scopes for the v2 API + +The v2 API now authenticates with OAuth 2.0 client-credentials, the way the +Tailscale ecosystem does. An OAuth client mints short-lived access tokens whose +scopes limit which operations they may perform and whose tags limit the devices +they may create, so a credential can be issued with only the access it needs. +The `headscale oauth-clients` command manages them. This lets the Tailscale +Terraform provider and Kubernetes operator drive Headscale unchanged; admin API +keys remain all-access. + +[#3334](https://github.com/juanfont/headscale/pull/3334) + +### BREAKING + +#### API + +- The gRPC API is removed; all programmatic access now goes through the HTTP API at `/api/v1` [#3324](https://github.com/juanfont/headscale/pull/3324) +- API errors are now RFC 7807 `application/problem+json`, including authentication failures, instead of the previous gRPC-status JSON shape [#3324](https://github.com/juanfont/headscale/pull/3324) +- Errors that previously returned HTTP 500 — unknown users or nodes, malformed input, duplicate names — now return the correct 404, 400 or 409 [#3324](https://github.com/juanfont/headscale/pull/3324) +- The OpenAPI document is OpenAPI 3.1 at `/api/v1/openapi.yaml` (docs at `/api/v1/docs`), replacing Swagger 2.0 at `/swagger` [#3324](https://github.com/juanfont/headscale/pull/3324) + +#### CLI + +- `--output json` / `--output yaml` now emit the API's shape — camelCase fields, string-encoded IDs, RFC3339 timestamps — instead of the old Protobuf encoding [#3324](https://github.com/juanfont/headscale/pull/3324) +- `headscale policy` renames the database-bypass flag from `--bypass-grpc-and-access-database-directly` to `--bypass-server-and-access-database-directly` [#3324](https://github.com/juanfont/headscale/pull/3324) + +### Changes + +- Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324) +- Improve systemd service file hardening [#3341](https://github.com/juanfont/headscale/pull/3341) + +## 0.29.2 (2026-07-01) + +**Minimum supported Tailscale client version: v1.80.0** + +### Changes + +- Fix map generation serializing on the policy lock, so a mass reconnect on `autogroup:self`, via or relay policies no longer stalls clients into `unexpected EOF` retry loops [#3358](https://github.com/juanfont/headscale/pull/3358) +- Fix `/ts2021` rejecting the WebSocket `GET` upgrade with 405, which prevented Tailscale JS/WASM control clients from connecting [#3359](https://github.com/juanfont/headscale/pull/3359) +- Gracefully handle nodes with an invalid FQDN (empty or too long) instead of failing map delivery; offending names are logged at startup with the fix command [#3349](https://github.com/juanfont/headscale/pull/3349) + +## 0.29.1 (2026-06-18) + +**Minimum supported Tailscale client version: v1.80.0** + +### Changes + +- Fix nodes with `tags='null'` losing their assigned user on upgrade [#3325](https://github.com/juanfont/headscale/pull/3325) + ## 0.29.0 (2026-06-17) **Minimum supported Tailscale client version: v1.80.0** diff --git a/Dockerfile.wasmclient b/Dockerfile.wasmclient new file mode 100644 index 000000000..cc939a09f --- /dev/null +++ b/Dockerfile.wasmclient @@ -0,0 +1,30 @@ +# For integration testing only. +# +# Builds the Tailscale control client (integration/wasmic/wasmclient) for +# GOOS=js/GOARCH=wasm and packages it with Go's wasm_exec Node runner. The +# container idles; the integration test execs +# node /app/wasm_exec_node.js /app/client.wasm +# to drive a real browser-style WebSocket GET against headscale's /ts2021, +# guarding the regression in issue #3357. + +FROM golang:1.26.4-alpine AS build + +WORKDIR /src + +# Only the module metadata and the wasm client package are needed to build the +# js/wasm binary; its imports (tailscale.com/control/controlhttp, ...) resolve +# from the module proxy. +COPY go.mod go.sum ./ +COPY integration/wasmic/wasmclient ./integration/wasmic/wasmclient + +RUN GOOS=js GOARCH=wasm go build -o /out/client.wasm ./integration/wasmic/wasmclient \ + && cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" /out/wasm_exec.js \ + && cp "$(go env GOROOT)/lib/wasm/wasm_exec_node.js" /out/wasm_exec_node.js + +FROM node:24-alpine + +WORKDIR /app +COPY --from=build /out/ /app/ + +# Idle; the test execs the client on demand with the headscale control URL. +ENTRYPOINT ["tail", "-f", "/dev/null"] diff --git a/Makefile b/Makefile index 6a2f84584..da2726714 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,6 @@ endef # Source file collections using shell find for better performance GO_SOURCES := $(shell find . -name '*.go' -not -path './gen/*' -not -path './vendor/*') -PROTO_SOURCES := $(shell find . -name '*.proto' -not -path './gen/*' -not -path './vendor/*') PRETTIER_SOURCES := $(shell find . \( -name '*.md' -o -name '*.yaml' -o -name '*.yml' -o -name '*.ts' -o -name '*.js' -o -name '*.html' -o -name '*.css' -o -name '*.scss' -o -name '*.sass' \) -not -path './gen/*' -not -path './vendor/*' -not -path './node_modules/*') # Default target @@ -35,8 +34,6 @@ check-deps: $(call check_tool,gofumpt) $(call check_tool,mdformat) $(call check_tool,prettier) - $(call check_tool,clang-format) - $(call check_tool,buf) # Build targets .PHONY: build @@ -53,7 +50,7 @@ test: check-deps $(GO_SOURCES) go.mod go.sum # Formatting targets .PHONY: fmt -fmt: fmt-go fmt-mdformat fmt-prettier fmt-proto +fmt: fmt-go fmt-mdformat fmt-prettier .PHONY: fmt-go fmt-go: check-deps $(GO_SOURCES) @@ -71,35 +68,49 @@ fmt-prettier: check-deps $(PRETTIER_SOURCES) @echo "Formatting markup and config files..." prettier --write '**/*.{ts,js,md,yaml,yml,sass,css,scss,html}' -.PHONY: fmt-proto -fmt-proto: check-deps $(PROTO_SOURCES) - @echo "Formatting Protocol Buffer files..." - clang-format -i $(PROTO_SOURCES) - # Linting targets .PHONY: lint -lint: lint-go lint-proto +lint: lint-go .PHONY: lint-go lint-go: check-deps $(GO_SOURCES) go.mod go.sum @echo "Linting Go code..." golangci-lint run --timeout 10m -.PHONY: lint-proto -lint-proto: check-deps $(PROTO_SOURCES) - @echo "Linting Protocol Buffer files..." - cd proto/ && buf lint - # Code generation .PHONY: generate generate: check-deps @echo "Generating code..." go generate ./... + $(MAKE) client + +# Emit the OpenAPI spec on demand. The server serves it live at /openapi.yaml; +# this is for external consumers or inspection and is not committed. +.PHONY: openapi +openapi: + @echo "Emitting OpenAPI spec from code..." + go run ./cmd/gen-openapi + +# Generate the strongly-typed Go HTTP clients (v1 and v2). The served specs are +# OpenAPI 3.1, but oapi-codegen v2 does not yet read 3.1, so each client is +# generated from a transient 3.0.3 downgrade of its document. Pinned so the +# committed clients are reproducible. +.PHONY: client +client: + @echo "Generating API clients..." + @tmp=$$(mktemp -t headscale-openapi-3.0.XXXXXX.yaml); \ + go run ./cmd/gen-openapi -downgrade "$$tmp" && \ + go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1 \ + -generate types,client -package clientv1 -o gen/client/v1/client.gen.go "$$tmp" && \ + go run ./cmd/gen-openapi -api v2 -downgrade "$$tmp" && \ + go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1 \ + -generate types,client -package clientv2 -o gen/client/v2/client.gen.go "$$tmp"; \ + status=$$?; rm -f "$$tmp"; exit $$status # Clean targets .PHONY: clean clean: - rm -rf headscale gen + rm -rf headscale gen/client # Development workflow .PHONY: dev @@ -119,9 +130,9 @@ help: @echo " all - Run lint, test, and build (default)" @echo " build - Build headscale binary" @echo " test - Run Go tests" - @echo " fmt - Format all code (Go, docs, proto)" - @echo " lint - Lint all code (Go, proto)" - @echo " generate - Generate code from Protocol Buffers" + @echo " fmt - Format all code (Go, docs, markup)" + @echo " lint - Lint all code (Go)" + @echo " generate - Generate code (go generate + client)" @echo " dev - Full development workflow (fmt + lint + test + build)" @echo " clean - Clean build artifacts" @echo "" @@ -129,9 +140,7 @@ help: @echo " fmt-go - Format Go code only" @echo " fmt-mdformat - Format documentation only" @echo " fmt-prettier - Format markup and config files only" - @echo " fmt-proto - Format Protocol Buffer files only" @echo " lint-go - Lint Go code only" - @echo " lint-proto - Lint Protocol Buffer files only" @echo "" @echo "Dependencies:" @echo " check-deps - Verify required tools are available" diff --git a/buf.gen.yaml b/buf.gen.yaml deleted file mode 100644 index d7b832ab3..000000000 --- a/buf.gen.yaml +++ /dev/null @@ -1,21 +0,0 @@ -version: v1 -plugins: - - name: go - out: gen/go - opt: - - paths=source_relative - - name: go-grpc - out: gen/go - opt: - - paths=source_relative - - name: grpc-gateway - out: gen/go - opt: - - paths=source_relative - - generate_unbound_methods=true - # - name: gorm - # out: gen/go - # opt: - # - paths=source_relative,enums=string,gateway=true - - name: openapiv2 - out: gen/openapiv2 diff --git a/cmd/dev/README.md b/cmd/dev/README.md index 46da415db..f703f4421 100644 --- a/cmd/dev/README.md +++ b/cmd/dev/README.md @@ -42,8 +42,7 @@ go tool mts node1 status | `--port` | 8080 | Headscale listen port | | `--keep` | false | Keep state directory on exit | -The metrics/debug port is `port + 1010` (default 9090) and the gRPC -port is `port + 42363` (default 50443). +The metrics/debug port is `port + 1010` (default 9090). ## What it does diff --git a/cmd/dev/main.go b/cmd/dev/main.go index b3c7a8407..a1953123b 100644 --- a/cmd/dev/main.go +++ b/cmd/dev/main.go @@ -28,17 +28,14 @@ var errHealthTimeout = errors.New("health check timed out") var errEmptyAuthKey = errors.New("empty auth key in response") -// maxDevPort is the highest --port value that keeps both the derived -// metrics port (port+1010) and gRPC port (port+42363) inside the valid -// 1..65535 TCP range. -const maxDevPort = 23172 +// maxDevPort is the highest --port value that keeps the derived metrics +// port (port+1010) inside the valid 1..65535 TCP range. +const maxDevPort = 64525 const devConfig = `--- server_url: http://127.0.0.1:%d listen_addr: 127.0.0.1:%d metrics_listen_addr: 127.0.0.1:%d -grpc_listen_addr: 127.0.0.1:%d -grpc_allow_insecure: true noise: private_key_path: %s/noise_private.key @@ -83,7 +80,7 @@ func main() { if *port < 1 || *port > maxDevPort { log.Fatalf( - "--port must be in 1..%d (higher values overflow the derived gRPC port); got %d", + "--port must be in 1..%d (higher values overflow the derived metrics port); got %d", maxDevPort, *port, ) } @@ -101,7 +98,6 @@ func main() { func run() error { metricsPort := *port + 1010 // default 9090 - grpcPort := *port + 42363 // default 50443 tmpDir, err := os.MkdirTemp("", "headscale-dev-") if err != nil { @@ -114,8 +110,9 @@ func run() error { // Write config. configPath := filepath.Join(tmpDir, "config.yaml") - configContent := fmt.Sprintf(devConfig, - *port, *port, metricsPort, grpcPort, + configContent := fmt.Sprintf( + devConfig, + *port, *port, metricsPort, tmpDir, tmpDir, tmpDir, ) @@ -193,7 +190,8 @@ func run() error { } // Print banner. - fmt.Printf(` + fmt.Printf( + ` === Headscale Dev Environment === Server: http://127.0.0.1:%d Metrics: http://127.0.0.1:%d diff --git a/cmd/gen-openapi/main.go b/cmd/gen-openapi/main.go new file mode 100644 index 000000000..32568f14f --- /dev/null +++ b/cmd/gen-openapi/main.go @@ -0,0 +1,75 @@ +// Command gen-openapi emits a Headscale OpenAPI document from the authoritative +// Huma definitions in hscontrol/api/v1 and hscontrol/api/v2. The server also +// serves each spec live (at /openapi.yaml and /api/v2/openapi); this tool emits +// them on demand, and with -downgrade the 3.0.3 form used to generate the typed +// client. The output is not committed. +// +// Usage: +// +// go run ./cmd/gen-openapi # write the v1 3.1 spec to its default path +// go run ./cmd/gen-openapi -api v2 # write the v2 3.1 spec to its default path +// go run ./cmd/gen-openapi -downgrade # write the v1 3.0.3 downgrade (for client gen) +// go run ./cmd/gen-openapi -api v2 -downgrade # the v2 3.0.3 downgrade +package main + +import ( + "flag" + "log" + "os" + "path/filepath" + + apiv1 "github.com/juanfont/headscale/hscontrol/api/v1" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" +) + +// spec bundles a version's full (3.1) and downgraded (3.0.3) generators with the +// committed output path. outPath is relative to the repository root. +type spec struct { + full func() ([]byte, error) + down func() ([]byte, error) + outPath string +} + +// specs maps the -api value to its generators. +var specs = map[string]spec{ + "v1": {apiv1.Spec, apiv1.Spec30, "openapi/v1/headscale.yaml"}, + "v2": {apiv2.Spec, apiv2.Spec30, "openapi/v2/headscale.yaml"}, +} + +func main() { + api := flag.String("api", "v1", "which API spec to emit: v1 or v2") + downgrade := flag.String("downgrade", "", "write the OpenAPI 3.0.3 downgrade to this path instead of the committed 3.1 spec") + flag.Parse() + + s, ok := specs[*api] + if !ok { + log.Fatalf("unknown -api %q (want v1 or v2)", *api) + } + + if *downgrade != "" { + writeSpec(*downgrade, s.down) + + return + } + + writeSpec(s.outPath, s.full) +} + +func writeSpec(path string, gen func() ([]byte, error)) { + spec, err := gen() + if err != nil { + log.Fatalf("generating OpenAPI spec: %v", err) + } + + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + log.Fatalf("creating output directory: %v", err) + } + + err = os.WriteFile(path, spec, 0o600) + if err != nil { + log.Fatalf("writing %s: %v", path, err) + } + + log.Printf("wrote %s", path) +} diff --git a/cmd/headscale/cli/api_key.go b/cmd/headscale/cli/api_key.go index 5504bbf90..82559235a 100644 --- a/cmd/headscale/cli/api_key.go +++ b/cmd/headscale/cli/api_key.go @@ -3,11 +3,11 @@ package cli import ( "context" "fmt" + "net/http" "strconv" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/util" - "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -44,32 +44,40 @@ var listAPIKeys = &cobra.Command{ Use: cmdList, Short: "List the Api keys for headscale", Aliases: []string{"ls", cmdShow}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - response, err := client.ListApiKeys(ctx, &v1.ListApiKeysRequest{}) + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + resp, err := client.ListApiKeysWithResponse(ctx) if err != nil { return fmt.Errorf("listing api keys: %w", err) } - return printListOutput(cmd, response.GetApiKeys(), func() error { - tableData := make(pterm.TableData, 1, 1+len(response.GetApiKeys())) - tableData[0] = []string{"ID", "Prefix", colExpiration, colCreated} + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } - for _, key := range response.GetApiKeys() { + apiKeys := resp.JSON200.ApiKeys + + return printListOutput(cmd, apiKeys, func() error { + rows := make([][]string, 0, len(apiKeys)) + for _, key := range apiKeys { expiration := "-" - - if key.GetExpiration() != nil { - expiration = ColourTime(key.GetExpiration().AsTime()) + if key.Expiration != nil { + expiration = ColourTime(*key.Expiration) } - tableData = append(tableData, []string{ - strconv.FormatUint(key.GetId(), util.Base10), - key.GetPrefix(), + var created string + if key.CreatedAt != nil { + created = key.CreatedAt.Format(HeadscaleDateTimeFormat) + } + + rows = append(rows, []string{ + key.Id, + key.Prefix, expiration, - key.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat), + created, }) } - return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + return renderTable([]string{"ID", "Prefix", colExpiration, colCreated}, rows) }) }), } @@ -82,20 +90,24 @@ Creates a new Api key, the Api key is only visible on creation and cannot be retrieved again. If you lose a key, create a new one and revoke (expire) the old one.`, Aliases: []string{"c", cmdNew}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - expiration, err := expirationFromFlag(cmd) + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + expiryTime, err := expirationFromFlag(cmd) if err != nil { return err } - response, err := client.CreateApiKey(ctx, &v1.CreateApiKeyRequest{ - Expiration: expiration, + resp, err := client.CreateApiKeyWithResponse(ctx, clientv1.CreateApiKeyJSONRequestBody{ + Expiration: &expiryTime, }) if err != nil { return fmt.Errorf("creating api key: %w", err) } - return printOutput(cmd, response.GetApiKey(), response.GetApiKey()) + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.ApiKey, resp.JSON200.ApiKey) }), } @@ -119,21 +131,33 @@ var expireAPIKeyCmd = &cobra.Command{ Use: cmdExpire, Short: "Expire an ApiKey", Aliases: []string{"revoke", aliasExp, "e"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { id, prefix, err := apiKeyIDOrPrefix(cmd) if err != nil { return err } - response, err := client.ExpireApiKey(ctx, &v1.ExpireApiKeyRequest{ - Id: id, - Prefix: prefix, - }) + body := clientv1.ExpireApiKeyJSONRequestBody{} + + if id != 0 { + idStr := strconv.FormatUint(id, util.Base10) + body.Id = &idStr + } + + if prefix != "" { + body.Prefix = &prefix + } + + resp, err := client.ExpireApiKeyWithResponse(ctx, body) if err != nil { return fmt.Errorf("expiring api key: %w", err) } - return printOutput(cmd, response, "Key expired") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "Key expired") }), } @@ -141,20 +165,59 @@ var deleteAPIKeyCmd = &cobra.Command{ Use: cmdDelete, Short: "Delete an ApiKey", Aliases: []string{"remove", aliasDel}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { id, prefix, err := apiKeyIDOrPrefix(cmd) if err != nil { return err } - response, err := client.DeleteApiKey(ctx, &v1.DeleteApiKeyRequest{ - Id: id, - Prefix: prefix, - }) + // The DELETE route addresses the key by its prefix in the path. When the + // user deletes by --id we resolve the id to its (masked) prefix first, + // since the path segment is required and a query-only id cannot be routed. + if prefix == "" { + prefix, err = apiKeyPrefixForID(ctx, client, id) + if err != nil { + return err + } + } + + resp, err := client.DeleteApiKeyWithResponse(ctx, prefix, &clientv1.DeleteApiKeyParams{}) if err != nil { return fmt.Errorf("deleting api key: %w", err) } - return printOutput(cmd, response, "Key deleted") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "Key deleted") }), } + +// apiKeyPrefixForID resolves an API key id to its display prefix by listing the +// keys. The DELETE endpoint addresses keys by prefix in the URL path, so a +// delete by --id needs the prefix; the returned masked prefix is accepted by +// the server's lookup. +func apiKeyPrefixForID( + ctx context.Context, + client *clientv1.ClientWithResponses, + id uint64, +) (string, error) { + resp, err := client.ListApiKeysWithResponse(ctx) + if err != nil { + return "", fmt.Errorf("listing api keys: %w", err) + } + + if resp.StatusCode() != http.StatusOK { + return "", apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + idStr := strconv.FormatUint(id, util.Base10) + for _, key := range resp.JSON200.ApiKeys { + if key.Id == idStr { + return key.Prefix, nil + } + } + + return "", fmt.Errorf("%w: api key %d not found", errMissingParameter, id) +} diff --git a/cmd/headscale/cli/auth.go b/cmd/headscale/cli/auth.go index 8a5476dd0..30abe797a 100644 --- a/cmd/headscale/cli/auth.go +++ b/cmd/headscale/cli/auth.go @@ -3,8 +3,9 @@ package cli import ( "context" "fmt" + "net/http" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/spf13/cobra" ) @@ -33,61 +34,66 @@ var authCmd = &cobra.Command{ var authRegisterCmd = &cobra.Command{ Use: "register", Short: "Register a node to your network", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetString("user") authID, _ := cmd.Flags().GetString("auth-id") - request := &v1.AuthRegisterRequest{ - AuthId: authID, - User: user, - } - - response, err := client.AuthRegister(ctx, request) + resp, err := client.AuthRegisterWithResponse(ctx, clientv1.AuthRegisterJSONRequestBody{ + AuthId: &authID, + User: &user, + }) if err != nil { return fmt.Errorf("registering node: %w", err) } + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + node := resp.JSON200.Node + return printOutput( cmd, - response.GetNode(), - fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName())) + node, + fmt.Sprintf("Node %s registered", node.GivenName), + ) }), } var authApproveCmd = &cobra.Command{ Use: "approve", Short: "Approve a pending authentication request", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { authID, _ := cmd.Flags().GetString("auth-id") - request := &v1.AuthApproveRequest{ - AuthId: authID, - } - - response, err := client.AuthApprove(ctx, request) + resp, err := client.AuthApproveWithResponse(ctx, clientv1.AuthApproveJSONRequestBody{AuthId: &authID}) if err != nil { return fmt.Errorf("approving auth request: %w", err) } - return printOutput(cmd, response, "Auth request approved") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "Auth request approved") }), } var authRejectCmd = &cobra.Command{ Use: "reject", Short: "Reject a pending authentication request", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { authID, _ := cmd.Flags().GetString("auth-id") - request := &v1.AuthRejectRequest{ - AuthId: authID, - } - - response, err := client.AuthReject(ctx, request) + resp, err := client.AuthRejectWithResponse(ctx, clientv1.AuthRejectJSONRequestBody{AuthId: &authID}) if err != nil { return fmt.Errorf("rejecting auth request: %w", err) } - return printOutput(cmd, response, "Auth request rejected") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "Auth request rejected") }), } diff --git a/cmd/headscale/cli/debug.go b/cmd/headscale/cli/debug.go index 1f934f0bb..64262c00d 100644 --- a/cmd/headscale/cli/debug.go +++ b/cmd/headscale/cli/debug.go @@ -3,8 +3,9 @@ package cli import ( "context" "fmt" + "net/http" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/types" "github.com/spf13/cobra" ) @@ -32,7 +33,7 @@ var debugCmd = &cobra.Command{ var createNodeCmd = &cobra.Command{ Use: "create-node", Short: "Create a node that can be registered with `auth register <>` command", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetString("user") name, _ := cmd.Flags().GetString("name") registrationID, _ := cmd.Flags().GetString("key") @@ -44,18 +45,20 @@ var createNodeCmd = &cobra.Command{ routes, _ := cmd.Flags().GetStringSlice("route") - request := &v1.DebugCreateNodeRequest{ - Key: registrationID, - Name: name, - User: user, - Routes: routes, - } - - response, err := client.DebugCreateNode(ctx, request) + resp, err := client.DebugCreateNodeWithResponse(ctx, clientv1.DebugCreateNodeJSONRequestBody{ + Key: ®istrationID, + Name: &name, + User: &user, + Routes: &routes, + }) if err != nil { return fmt.Errorf("creating node: %w", err) } - return printOutput(cmd, response.GetNode(), "Node created") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.Node, "Node created") }), } diff --git a/cmd/headscale/cli/health.go b/cmd/headscale/cli/health.go index b3b4f4307..6fccb0ac2 100644 --- a/cmd/headscale/cli/health.go +++ b/cmd/headscale/cli/health.go @@ -3,8 +3,9 @@ package cli import ( "context" "fmt" + "net/http" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/spf13/cobra" ) @@ -16,12 +17,16 @@ var healthCmd = &cobra.Command{ Use: "health", Short: "Check the health of the Headscale server", Long: "Check the health of the Headscale server. This command will return an exit code of 0 if the server is healthy, or 1 if it is not.", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - response, err := client.Health(ctx, &v1.HealthRequest{}) + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + resp, err := client.HealthWithResponse(ctx) if err != nil { return fmt.Errorf("checking health: %w", err) } - return printOutput(cmd, response, "") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "") }), } diff --git a/cmd/headscale/cli/mockoidc.go b/cmd/headscale/cli/mockoidc.go index be951b762..255143bae 100644 --- a/cmd/headscale/cli/mockoidc.go +++ b/cmd/headscale/cli/mockoidc.go @@ -3,6 +3,7 @@ package cli import ( "context" "encoding/json" + "errors" "fmt" "net" "net/http" @@ -16,19 +17,15 @@ import ( "github.com/spf13/cobra" ) -// Error is used to compare errors as per https://dave.cheney.net/2016/04/07/constant-errors -type Error string - -func (e Error) Error() string { return string(e) } - -const ( - errMockOidcClientIDNotDefined = Error("MOCKOIDC_CLIENT_ID not defined") - errMockOidcClientSecretNotDefined = Error("MOCKOIDC_CLIENT_SECRET not defined") - errMockOidcPortNotDefined = Error("MOCKOIDC_PORT not defined") - errMockOidcUsersNotDefined = Error("MOCKOIDC_USERS not defined") - refreshTTL = 60 * time.Minute +var ( + errMockOidcClientIDNotDefined = errors.New("MOCKOIDC_CLIENT_ID not defined") + errMockOidcClientSecretNotDefined = errors.New("MOCKOIDC_CLIENT_SECRET not defined") + errMockOidcPortNotDefined = errors.New("MOCKOIDC_PORT not defined") + errMockOidcUsersNotDefined = errors.New("MOCKOIDC_USERS not defined") ) +const refreshTTL = 60 * time.Minute + var accessTTL = 2 * time.Minute func init() { diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index 5cf61a43e..9dc798a3a 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -3,17 +3,17 @@ package cli import ( "context" "fmt" + "net/http" "net/netip" "strconv" "strings" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/util" "github.com/pterm/pterm" "github.com/samber/lo" "github.com/spf13/cobra" - "google.golang.org/protobuf/types/known/timestamppb" "tailscale.com/types/key" ) @@ -67,24 +67,31 @@ var registerNodeCmd = &cobra.Command{ Use: "register", Short: "Registers a node to your network", Deprecated: "use 'headscale auth register --auth-id --user ' instead", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetString("user") registrationID, _ := cmd.Flags().GetString("key") - request := &v1.RegisterNodeRequest{ - Key: registrationID, - User: user, + params := &clientv1.RegisterNodeParams{ + User: &user, + Key: ®istrationID, } - response, err := client.RegisterNode(ctx, request) + resp, err := client.RegisterNodeWithResponse(ctx, params) if err != nil { return fmt.Errorf("registering node: %w", err) } + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + node := resp.JSON200.Node + return printOutput( cmd, - response.GetNode(), - fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName())) + node, + fmt.Sprintf("Node %s registered", node.GivenName), + ) }), } @@ -92,16 +99,27 @@ var listNodesCmd = &cobra.Command{ Use: cmdList, Short: "List nodes", Aliases: []string{"ls", cmdShow}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetString("user") - response, err := client.ListNodes(ctx, &v1.ListNodesRequest{User: user}) + params := &clientv1.ListNodesParams{} + if user != "" { + params.User = &user + } + + resp, err := client.ListNodesWithResponse(ctx, params) if err != nil { return fmt.Errorf("listing nodes: %w", err) } - return printListOutput(cmd, response.GetNodes(), func() error { - tableData, err := nodesToPtables(response.GetNodes()) + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + nodes := resp.JSON200.Nodes + + return printListOutput(cmd, nodes, func() error { + tableData, err := nodesToPtables(nodes) if err != nil { return fmt.Errorf("converting to table: %w", err) } @@ -115,27 +133,33 @@ var listNodeRoutesCmd = &cobra.Command{ Use: "list-routes", Short: "List routes available on nodes", Aliases: []string{"lsr", "routes"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") - response, err := client.ListNodes(ctx, &v1.ListNodesRequest{}) + resp, err := client.ListNodesWithResponse(ctx, &clientv1.ListNodesParams{}) if err != nil { return fmt.Errorf("listing nodes: %w", err) } - nodes := response.GetNodes() + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + nodes := resp.JSON200.Nodes + if identifier != 0 { - for _, node := range response.GetNodes() { - if node.GetId() == identifier { - nodes = []*v1.Node{node} + idStr := strconv.FormatUint(identifier, util.Base10) + for _, node := range nodes { + if node.Id == idStr { + nodes = []clientv1.Node{node} break } } } - nodes = lo.Filter(nodes, func(n *v1.Node, _ int) bool { - return (n.GetSubnetRoutes() != nil && len(n.GetSubnetRoutes()) > 0) || (n.GetApprovedRoutes() != nil && len(n.GetApprovedRoutes()) > 0) || (n.GetAvailableRoutes() != nil && len(n.GetAvailableRoutes()) > 0) + nodes = lo.Filter(nodes, func(n clientv1.Node, _ int) bool { + return len(n.SubnetRoutes) > 0 || len(n.ApprovedRoutes) > 0 || len(n.AvailableRoutes) > 0 }) return printListOutput(cmd, nodes, func() error { @@ -151,23 +175,27 @@ var expireNodeCmd = &cobra.Command{ Use --disable to disable key expiry (node will never expire).`, Aliases: []string{"logout", aliasExp, "e"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") disableExpiry, _ := cmd.Flags().GetBool("disable") + nodeID := strconv.FormatUint(identifier, util.Base10) // Handle disable expiry - node will never expire. if disableExpiry { - request := &v1.ExpireNodeRequest{ - NodeId: identifier, - DisableExpiry: true, - } + disable := true - response, err := client.ExpireNode(ctx, request) + resp, err := client.ExpireNodeWithResponse(ctx, nodeID, clientv1.ExpireNodeJSONRequestBody{ + DisableExpiry: &disable, + }) if err != nil { return fmt.Errorf("disabling node expiry: %w", err) } - return printOutput(cmd, response.GetNode(), "Node expiry disabled") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.Node, "Node expiry disabled") } expiry, _ := cmd.Flags().GetString("expiry") @@ -185,28 +213,31 @@ Use --disable to disable key expiry (node will never expire).`, } } - request := &v1.ExpireNodeRequest{ - NodeId: identifier, - Expiry: timestamppb.New(expiryTime), - } - - response, err := client.ExpireNode(ctx, request) + resp, err := client.ExpireNodeWithResponse(ctx, nodeID, clientv1.ExpireNodeJSONRequestBody{ + Expiry: &expiryTime, + }) if err != nil { return fmt.Errorf("expiring node: %w", err) } - if now.Equal(expiryTime) || now.After(expiryTime) { - return printOutput(cmd, response.GetNode(), "Node expired") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) } - return printOutput(cmd, response.GetNode(), "Node expiration updated") + node := resp.JSON200.Node + + if now.Equal(expiryTime) || now.After(expiryTime) { + return printOutput(cmd, node, "Node expired") + } + + return printOutput(cmd, node, "Node expiration updated") }), } var renameNodeCmd = &cobra.Command{ Use: "rename NEW_NAME", Short: "Renames a node in your network", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") newName := "" @@ -214,17 +245,16 @@ var renameNodeCmd = &cobra.Command{ newName = args[0] } - request := &v1.RenameNodeRequest{ - NodeId: identifier, - NewName: newName, - } - - response, err := client.RenameNode(ctx, request) + resp, err := client.RenameNodeWithResponse(ctx, strconv.FormatUint(identifier, util.Base10), newName) if err != nil { return fmt.Errorf("renaming node: %w", err) } - return printOutput(cmd, response.GetNode(), "Node renamed") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.Node, "Node renamed") }), } @@ -232,34 +262,35 @@ var deleteNodeCmd = &cobra.Command{ Use: cmdDelete, Short: "Delete a node", Aliases: []string{aliasDel}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") + nodeID := strconv.FormatUint(identifier, util.Base10) - getRequest := &v1.GetNodeRequest{ - NodeId: identifier, - } - - getResponse, err := client.GetNode(ctx, getRequest) + getResponse, err := client.GetNodeWithResponse(ctx, nodeID) if err != nil { return fmt.Errorf("getting node: %w", err) } - deleteRequest := &v1.DeleteNodeRequest{ - NodeId: identifier, + if getResponse.StatusCode() != http.StatusOK { + return apiError(getResponse.StatusCode(), getResponse.ApplicationproblemJSONDefault) } if !confirmAction(cmd, fmt.Sprintf( "Do you want to remove the node %s?", - getResponse.GetNode().GetName(), + getResponse.JSON200.Node.Name, )) { return printOutput(cmd, map[string]string{colResult: "Node not deleted"}, "Node not deleted") } - _, err = client.DeleteNode(ctx, deleteRequest) + deleteResponse, err := client.DeleteNodeWithResponse(ctx, nodeID) if err != nil { return fmt.Errorf("deleting node: %w", err) } + if deleteResponse.StatusCode() != http.StatusOK { + return apiError(deleteResponse.StatusCode(), deleteResponse.ApplicationproblemJSONDefault) + } + return printOutput( cmd, map[string]string{colResult: "Node deleted"}, @@ -288,23 +319,26 @@ be assigned to nodes.`, return nil } - ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig() - if err != nil { - return fmt.Errorf("connecting to headscale: %w", err) - } - defer cancel() - defer conn.Close() + return withClient(func(ctx context.Context, client *clientv1.ClientWithResponses) error { + confirmed := true - changes, err := client.BackfillNodeIPs(ctx, &v1.BackfillNodeIPsRequest{Confirmed: true}) - if err != nil { - return fmt.Errorf("backfilling IPs: %w", err) - } + resp, err := client.BackfillNodeIPsWithResponse(ctx, &clientv1.BackfillNodeIPsParams{ + Confirmed: &confirmed, + }) + if err != nil { + return fmt.Errorf("backfilling IPs: %w", err) + } - return printOutput(cmd, changes, "Node IPs backfilled successfully") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "Node IPs backfilled successfully") + }) }, } -func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) { +func nodesToPtables(nodes []clientv1.Node) (pterm.TableData, error) { tableHeader := []string{ "ID", "Hostname", @@ -324,81 +358,49 @@ func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) { tableData[0] = tableHeader for _, node := range nodes { - var ephemeral bool - if node.GetPreAuthKey() != nil && node.GetPreAuthKey().GetEphemeral() { - ephemeral = true + // An absent pre-auth key decodes into a zero NodePreAuthKey, so guard + // on Id before reading its flags. + ephemeral := node.PreAuthKey.Id != "" && node.PreAuthKey.Ephemeral + + var lastSeenTime string + if node.LastSeen != nil { + lastSeenTime = node.LastSeen.Format(HeadscaleDateTimeFormat) } - var ( - lastSeen time.Time - lastSeenTime string - ) - - if node.GetLastSeen() != nil { - lastSeen = node.GetLastSeen().AsTime() - lastSeenTime = lastSeen.Format(HeadscaleDateTimeFormat) - } - - var ( - expiry time.Time - expiryTime string - ) - - if node.GetExpiry() != nil { - expiry = node.GetExpiry().AsTime() - expiryTime = expiry.Format(HeadscaleDateTimeFormat) - } else { - expiryTime = "N/A" + expiryTime := "N/A" + if node.Expiry != nil { + expiryTime = node.Expiry.Format(HeadscaleDateTimeFormat) } var machineKey key.MachinePublic - err := machineKey.UnmarshalText( - []byte(node.GetMachineKey()), - ) + err := machineKey.UnmarshalText([]byte(node.MachineKey)) if err != nil { machineKey = key.MachinePublic{} } var nodeKey key.NodePublic - err = nodeKey.UnmarshalText( - []byte(node.GetNodeKey()), - ) + err = nodeKey.UnmarshalText([]byte(node.NodeKey)) if err != nil { return nil, err } - var online string - if node.GetOnline() { + online := pterm.LightRed("offline") + if node.Online { online = pterm.LightGreen("online") - } else { - online = pterm.LightRed("offline") } - var expired string - if node.GetExpiry() != nil && node.GetExpiry().AsTime().Before(time.Now()) { + expired := pterm.LightGreen("no") + if node.Expiry != nil && node.Expiry.Before(time.Now()) { expired = pterm.LightRed("yes") - } else { - expired = pterm.LightGreen("no") } - var tagsBuilder strings.Builder - - for _, tag := range node.GetTags() { - tagsBuilder.WriteString("\n" + tag) - } - - tags := strings.TrimLeft(tagsBuilder.String(), "\n") - - var user string - if node.GetUser() != nil { - user = node.GetUser().GetName() - } + tags := strings.Join(node.Tags, "\n") var ipBuilder strings.Builder - for _, addr := range node.GetIpAddresses() { + for _, addr := range node.IpAddresses { ip, err := netip.ParseAddr(addr) if err == nil { if ipBuilder.Len() > 0 { @@ -412,12 +414,12 @@ func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) { ipAddresses := ipBuilder.String() nodeData := []string{ - strconv.FormatUint(node.GetId(), util.Base10), - node.GetName(), - node.GetGivenName(), + node.Id, + node.Name, + node.GivenName, machineKey.ShortString(), nodeKey.ShortString(), - user, + node.User.Name, tags, ipAddresses, strconv.FormatBool(ephemeral), @@ -436,7 +438,7 @@ func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) { } func nodeRoutesToPtables( - nodes []*v1.Node, + nodes []clientv1.Node, ) pterm.TableData { tableHeader := []string{ "ID", @@ -450,11 +452,11 @@ func nodeRoutesToPtables( for _, node := range nodes { nodeData := []string{ - strconv.FormatUint(node.GetId(), util.Base10), - node.GetGivenName(), - strings.Join(node.GetApprovedRoutes(), "\n"), - strings.Join(node.GetAvailableRoutes(), "\n"), - strings.Join(node.GetSubnetRoutes(), "\n"), + node.Id, + node.GivenName, + strings.Join(node.ApprovedRoutes, "\n"), + strings.Join(node.AvailableRoutes, "\n"), + strings.Join(node.SubnetRoutes, "\n"), } tableData = append( tableData, @@ -469,43 +471,43 @@ var tagCmd = &cobra.Command{ Use: "tag", Short: "Manage the tags of a node", Aliases: []string{"tags", "t"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") tagsToSet, _ := cmd.Flags().GetStringSlice("tags") - // Sending tags to node - request := &v1.SetTagsRequest{ - NodeId: identifier, - Tags: tagsToSet, - } - - resp, err := client.SetTags(ctx, request) + resp, err := client.SetTagsWithResponse(ctx, strconv.FormatUint(identifier, util.Base10), clientv1.SetTagsJSONRequestBody{ + Tags: &tagsToSet, + }) if err != nil { return fmt.Errorf("setting tags: %w", err) } - return printOutput(cmd, resp.GetNode(), "Node updated") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.Node, "Node updated") }), } var approveRoutesCmd = &cobra.Command{ Use: "approve-routes", Short: "Manage the approved routes of a node", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") routes, _ := cmd.Flags().GetStringSlice("routes") - // Sending routes to node - request := &v1.SetApprovedRoutesRequest{ - NodeId: identifier, - Routes: routes, - } - - resp, err := client.SetApprovedRoutes(ctx, request) + resp, err := client.SetApprovedRoutesWithResponse(ctx, strconv.FormatUint(identifier, util.Base10), clientv1.SetApprovedRoutesJSONRequestBody{ + Routes: &routes, + }) if err != nil { return fmt.Errorf("setting approved routes: %w", err) } - return printOutput(cmd, resp.GetNode(), "Node updated") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.Node, "Node updated") }), } diff --git a/cmd/headscale/cli/oauth_client.go b/cmd/headscale/cli/oauth_client.go new file mode 100644 index 000000000..aeb566469 --- /dev/null +++ b/cmd/headscale/cli/oauth_client.go @@ -0,0 +1,271 @@ +package cli + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + + clientv2 "github.com/juanfont/headscale/gen/client/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/spf13/cobra" +) + +// oauthTailnet is the single Headscale tailnet the v2 API addresses as "-". +const oauthTailnet = "-" + +func init() { + rootCmd.AddCommand(oauthClientsCmd) + + oauthClientsCmd.AddCommand(listOAuthClientsCmd) + + createOAuthClientCmd.Flags(). + StringArrayP("scope", "s", nil, "Scope the client's tokens are granted (repeatable): auth_keys, oauth_keys, devices:core, devices:routes, policy_file, feature_settings (each with a :read variant), or all/all:read") + createOAuthClientCmd.Flags(). + StringArrayP("tag", "t", nil, "Tag the client's tokens may assign to devices (repeatable), e.g. tag:k8s-operator") + createOAuthClientCmd.Flags().StringP("description", "d", "", "Human-readable description") + oauthClientsCmd.AddCommand(createOAuthClientCmd) + + deleteOAuthClientCmd.Flags().StringP("id", "i", "", "OAuth client id") + oauthClientsCmd.AddCommand(deleteOAuthClientCmd) +} + +var oauthClientsCmd = &cobra.Command{ + Use: "oauth-clients", + Short: "Manage OAuth clients", + Aliases: []string{"oauth-client", "oauthclients", "oauthclient", "oauth"}, +} + +var createOAuthClientCmd = &cobra.Command{ + Use: "create", + Short: "Create an OAuth client", + Long: `Create a general-purpose OAuth client. It authenticates with the OAuth 2.0 +client-credentials grant and mints short-lived, scope-limited access tokens. +The wire format is compatible with Tailscale tooling (the Terraform provider, +the Kubernetes operator, tscli, ...), so those can drive Headscale unchanged. + +The client secret is shown ONCE on creation and cannot be retrieved again; if +you lose it, delete the client and create a new one. + +Scopes gate what the client's tokens may do; tags are the device tags those +tokens may assign and are required when the scopes include devices:core or +auth_keys.`, + Aliases: []string{"c", cmdNew}, + RunE: func(cmd *cobra.Command, _ []string) error { + scopes, _ := cmd.Flags().GetStringArray("scope") + tags, _ := cmd.Flags().GetStringArray("tag") + description, _ := cmd.Flags().GetString("description") + + if len(scopes) == 0 { + return fmt.Errorf("at least one --scope is required: %w", errMissingParameter) + } + + ctx, client, cancel, err := newV2Client() + if err != nil { + return err + } + defer cancel() + + keyType := "client" + + resp, err := client.CreateKeyWithResponse(ctx, oauthTailnet, clientv2.CreateKeyRequest{ + KeyType: &keyType, + Scopes: &scopes, + Tags: &tags, + Description: &description, + }) + if err != nil { + return fmt.Errorf("creating oauth client: %w", err) + } + + err = v2Error(resp.HTTPResponse.StatusCode, resp.Body) + if err != nil { + return err + } + + key := resp.JSON200 + + return printOutput(cmd, key, + fmt.Sprintf("OAuth client %s created.\nSecret (shown once, store it now): %s", key.Id, ptrStr(key.Key))) + }, +} + +var listOAuthClientsCmd = &cobra.Command{ + Use: cmdList, + Short: "List OAuth clients", + Aliases: []string{"ls", cmdShow}, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx, client, cancel, err := newV2Client() + if err != nil { + return err + } + defer cancel() + + resp, err := client.ListKeysWithResponse(ctx, oauthTailnet, nil) + if err != nil { + return fmt.Errorf("listing oauth clients: %w", err) + } + + err = v2Error(resp.HTTPResponse.StatusCode, resp.Body) + if err != nil { + return err + } + + // The keys endpoint is multiplexed; keep only OAuth clients. + clients := make([]clientv2.Key, 0, len(resp.JSON200.Keys)) + + for _, k := range resp.JSON200.Keys { + if k.KeyType == "client" { + clients = append(clients, k) + } + } + + return printListOutput(cmd, clients, func() error { + rows := make([][]string, 0, len(clients)) + for _, c := range clients { + rows = append(rows, []string{ + c.Id, + strings.Join(ptrStrs(c.Scopes), ","), + strings.Join(ptrStrs(c.Tags), ","), + ptrStr(c.Description), + c.Created.Format(HeadscaleDateTimeFormat), + }) + } + + return renderTable([]string{"ID", "Scopes", "Tags", "Description", colCreated}, rows) + }) + }, +} + +var deleteOAuthClientCmd = &cobra.Command{ + Use: cmdDelete, + Short: "Delete an OAuth client", + Aliases: []string{"remove", aliasDel}, + RunE: func(cmd *cobra.Command, _ []string) error { + id, _ := cmd.Flags().GetString("id") + if id == "" { + return fmt.Errorf("--id is required: %w", errMissingParameter) + } + + ctx, client, cancel, err := newV2Client() + if err != nil { + return err + } + defer cancel() + + resp, err := client.DeleteKeyWithResponse(ctx, oauthTailnet, id) + if err != nil { + return fmt.Errorf("deleting oauth client: %w", err) + } + + err = v2Error(resp.HTTPResponse.StatusCode, resp.Body) + if err != nil { + return err + } + + return printOutput(cmd, map[string]string{"id": id}, "OAuth client "+id+" deleted") + }, +} + +// newV2Client builds a generated v2 API client, selecting the transport the same +// way the v1 client does: over the local unix socket it is unauthenticated +// (local trust); a remote address injects the configured API key as a bearer +// token. +func newV2Client() (context.Context, *clientv2.ClientWithResponses, context.CancelFunc, error) { + cfg, err := types.LoadCLIConfig() + if err != nil { + return nil, nil, nil, fmt.Errorf("loading configuration: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout) + + if cfg.CLI.Address == "" { + socketPath := cfg.UnixSocket + + httpClient := &http.Client{Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialHeadscaleSocket(ctx, socketPath) + }, + }} + + client, err := clientv2.NewClientWithResponses("http://local", clientv2.WithHTTPClient(httpClient)) + if err != nil { + cancel() + + return nil, nil, nil, err + } + + return ctx, client, cancel, nil + } + + if cfg.CLI.APIKey == "" { + cancel() + + return nil, nil, nil, errAPIKeyNotSet + } + + transport := &http.Transport{} + if cfg.CLI.Insecure { + //nolint:gosec // intentionally honouring the insecure flag + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + + apiKey := cfg.CLI.APIKey + + client, err := clientv2.NewClientWithResponses( + clientBaseURL(cfg.CLI.Address), + clientv2.WithHTTPClient(&http.Client{Transport: transport}), + clientv2.WithRequestEditorFn(func(_ context.Context, req *http.Request) error { + req.Header.Set("Authorization", "Bearer "+apiKey) + + return nil + }), + ) + if err != nil { + cancel() + + return nil, nil, nil, err + } + + return ctx, client, cancel, nil +} + +// v2Error turns a non-2xx v2 response into an error. The v2 API emits the +// Tailscale error body ({"message":...}) rather than RFC 7807, so it reads the +// "message" field instead of the generated problem+json types. +func v2Error(status int, body []byte) error { + if status >= http.StatusOK && status < http.StatusMultipleChoices { + return nil + } + + var e struct { + Message string `json:"message"` + } + + if json.Unmarshal(body, &e) == nil && e.Message != "" { + //nolint:err113 // surfacing the server's message + return fmt.Errorf("api error (%d): %s", status, e.Message) + } + + //nolint:err113 // surfacing the server's body + return fmt.Errorf("api error (%d): %s", status, strings.TrimSpace(string(body))) +} + +func ptrStr(s *string) string { + if s == nil { + return "" + } + + return *s +} + +func ptrStrs(s *[]string) []string { + if s == nil { + return nil + } + + return *s +} diff --git a/cmd/headscale/cli/policy.go b/cmd/headscale/cli/policy.go index d6a1f573b..ae69cc8a9 100644 --- a/cmd/headscale/cli/policy.go +++ b/cmd/headscale/cli/policy.go @@ -1,11 +1,13 @@ package cli import ( + "context" "errors" "fmt" + "net/http" "os" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/db" "github.com/juanfont/headscale/hscontrol/policy" "github.com/juanfont/headscale/hscontrol/types" @@ -14,14 +16,13 @@ import ( ) const ( - bypassFlag = "bypass-grpc-and-access-database-directly" //nolint:gosec // not a credential + bypassFlag = "bypass-server-and-access-database-directly" //nolint:gosec // not a credential ) var errAborted = errors.New("command aborted by user") -// bypassDatabase loads the server config and opens the database directly, -// bypassing the gRPC server. The caller is responsible for closing the -// returned database handle. +// bypassDatabase opens the database directly, bypassing the running server. +// The caller must close the returned handle. func bypassDatabase() (*db.HSDatabase, error) { cfg, err := types.LoadServerConfig() if err != nil { @@ -36,19 +37,29 @@ func bypassDatabase() (*db.HSDatabase, error) { return d, nil } +// openBypassDB confirms the destructive bypass action and opens the database +// directly. The caller is responsible for closing the returned handle. +func openBypassDB(cmd *cobra.Command) (*db.HSDatabase, error) { + if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") { + return nil, errAborted + } + + return bypassDatabase() +} + func init() { rootCmd.AddCommand(policyCmd) - getPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing gRPC and does not require the server to be running") + getPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing the API and does not require the server to be running") policyCmd.AddCommand(getPolicy) setPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format") - setPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing gRPC and does not require the server to be running") + setPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing the API and does not require the server to be running") mustMarkRequired(setPolicy, "file") policyCmd.AddCommand(setPolicy) checkPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format") - checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no gRPC, no running server) to resolve user references and to evaluate the policy's tests and sshTests blocks. Required when those checks are needed.") + checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no running server required) to resolve user references and to evaluate the policy's tests and sshTests blocks. Required when those checks are needed.") mustMarkRequired(checkPolicy, "file") policyCmd.AddCommand(checkPolicy) } @@ -66,11 +77,7 @@ var getPolicy = &cobra.Command{ var policyData string if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass { - if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") { - return errAborted - } - - d, err := bypassDatabase() + d, err := openBypassDB(cmd) if err != nil { return err } @@ -83,19 +90,23 @@ var getPolicy = &cobra.Command{ policyData = pol.Data } else { - ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig() - if err != nil { - return fmt.Errorf("connecting to headscale: %w", err) - } - defer cancel() - defer conn.Close() + err := withClient(func(ctx context.Context, client *clientv1.ClientWithResponses) error { + resp, err := client.GetPolicyWithResponse(ctx) + if err != nil { + return fmt.Errorf("loading ACL policy: %w", err) + } - response, err := client.GetPolicy(ctx, &v1.GetPolicyRequest{}) - if err != nil { - return fmt.Errorf("loading ACL policy: %w", err) - } + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } - policyData = response.GetPolicy() + policyData = resp.JSON200.Policy + + return nil + }) + if err != nil { + return err + } } // This does not pass output format as we don't support yaml, json or @@ -122,17 +133,13 @@ var setPolicy = &cobra.Command{ } if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass { - if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") { - return errAborted - } - - d, err := bypassDatabase() + d, err := openBypassDB(cmd) if err != nil { return err } defer d.Close() - users, err := d.ListUsers() + users, err := d.ListUsers(nil) if err != nil { return fmt.Errorf("loading users for policy validation: %w", err) } @@ -147,18 +154,24 @@ var setPolicy = &cobra.Command{ return fmt.Errorf("setting ACL policy: %w", err) } } else { - request := &v1.SetPolicyRequest{Policy: string(policyBytes)} + policyStr := string(policyBytes) - ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig() - if err != nil { - return fmt.Errorf("connecting to headscale: %w", err) - } - defer cancel() - defer conn.Close() + err := withClient(func(ctx context.Context, client *clientv1.ClientWithResponses) error { + resp, err := client.SetPolicyWithResponse(ctx, clientv1.SetPolicyJSONRequestBody{ + Policy: &policyStr, + }) + if err != nil { + return fmt.Errorf("setting ACL policy: %w", err) + } - _, err = client.SetPolicy(ctx, request) + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return nil + }) if err != nil { - return fmt.Errorf("setting ACL policy: %w", err) + return err } } @@ -173,8 +186,8 @@ var checkPolicy = &cobra.Command{ Short: "Check the Policy file for errors", Long: ` Check validates the policy against the server's live users and nodes, - running any "tests" or "sshTests" block. By default the command is a - thin frontend for a gRPC call to a running headscale; pass --` + bypassFlag + ` to + running any "tests" or "sshTests" block. By default the command calls a + running headscale over its API; pass --` + bypassFlag + ` to open the database directly when headscale is not running.`, RunE: func(cmd *cobra.Command, args []string) error { policyPath, _ := cmd.Flags().GetString("file") @@ -185,17 +198,13 @@ var checkPolicy = &cobra.Command{ } if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass { - if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") { - return errAborted - } - - d, err := bypassDatabase() + d, err := openBypassDB(cmd) if err != nil { return err } defer d.Close() - users, err := d.ListUsers() + users, err := d.ListUsers(nil) if err != nil { return fmt.Errorf("loading users: %w", err) } @@ -224,14 +233,22 @@ var checkPolicy = &cobra.Command{ return nil } - ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig() - if err != nil { - return fmt.Errorf("connecting to headscale: %w", err) - } - defer cancel() - defer conn.Close() + policyStr := string(policyBytes) - _, err = client.CheckPolicy(ctx, &v1.CheckPolicyRequest{Policy: string(policyBytes)}) + err = withClient(func(ctx context.Context, client *clientv1.ClientWithResponses) error { + resp, err := client.CheckPolicyWithResponse(ctx, clientv1.CheckPolicyJSONRequestBody{ + Policy: &policyStr, + }) + if err != nil { + return err + } + + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return nil + }) if err != nil { return err } diff --git a/cmd/headscale/cli/preauthkeys.go b/cmd/headscale/cli/preauthkeys.go index f88df70c4..f6d4ad2b0 100644 --- a/cmd/headscale/cli/preauthkeys.go +++ b/cmd/headscale/cli/preauthkeys.go @@ -3,12 +3,12 @@ package cli import ( "context" "fmt" + "net/http" "strconv" "strings" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/util" - "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -45,15 +45,45 @@ var listPreAuthKeys = &cobra.Command{ Use: cmdList, Short: "List all preauthkeys", Aliases: []string{"ls", cmdShow}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - response, err := client.ListPreAuthKeys(ctx, &v1.ListPreAuthKeysRequest{}) + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + resp, err := client.ListPreAuthKeysWithResponse(ctx) if err != nil { return fmt.Errorf("listing preauthkeys: %w", err) } - return printListOutput(cmd, response.GetPreAuthKeys(), func() error { - tableData := make(pterm.TableData, 1, 1+len(response.GetPreAuthKeys())) - tableData[0] = []string{ + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + preAuthKeys := resp.JSON200.PreAuthKeys + + return printListOutput(cmd, preAuthKeys, func() error { + rows := make([][]string, 0, len(preAuthKeys)) + for _, key := range preAuthKeys { + expiration := ColourTime(key.Expiration) + + owner := "-" + + switch { + case len(key.AclTags) > 0: + owner = strings.Join(key.AclTags, "\n") + case key.User.Id != "": + owner = key.User.Name + } + + rows = append(rows, []string{ + key.Id, + key.Key, + strconv.FormatBool(key.Reusable), + strconv.FormatBool(key.Ephemeral), + strconv.FormatBool(key.Used), + expiration, + key.CreatedAt.Format(HeadscaleDateTimeFormat), + owner, + }) + } + + return renderTable([]string{ "ID", "Key/Prefix", "Reusable", @@ -62,36 +92,7 @@ var listPreAuthKeys = &cobra.Command{ colExpiration, colCreated, "Owner", - } - - for _, key := range response.GetPreAuthKeys() { - expiration := "-" - if key.GetExpiration() != nil { - expiration = ColourTime(key.GetExpiration().AsTime()) - } - - var owner string - if len(key.GetAclTags()) > 0 { - owner = strings.Join(key.GetAclTags(), "\n") - } else if key.GetUser() != nil { - owner = key.GetUser().GetName() - } else { - owner = "-" - } - - tableData = append(tableData, []string{ - strconv.FormatUint(key.GetId(), util.Base10), - key.GetKey(), - strconv.FormatBool(key.GetReusable()), - strconv.FormatBool(key.GetEphemeral()), - strconv.FormatBool(key.GetUsed()), - expiration, - key.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat), - owner, - }) - } - - return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + }, rows) }) }), } @@ -100,55 +101,76 @@ var createPreAuthKeyCmd = &cobra.Command{ Use: "create", Short: "Creates a new preauthkey", Aliases: []string{"c", cmdNew}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetUint64("user") reusable, _ := cmd.Flags().GetBool("reusable") ephemeral, _ := cmd.Flags().GetBool("ephemeral") tags, _ := cmd.Flags().GetStringSlice("tags") - expiration, err := expirationFromFlag(cmd) + expiryTime, err := expirationFromFlag(cmd) if err != nil { return err } - request := &v1.CreatePreAuthKeyRequest{ - User: user, - Reusable: reusable, - Ephemeral: ephemeral, - AclTags: tags, - Expiration: expiration, + userStr := strconv.FormatUint(user, util.Base10) + + request := clientv1.CreatePreAuthKeyJSONRequestBody{ + User: &userStr, + Reusable: &reusable, + Ephemeral: &ephemeral, + AclTags: &tags, + Expiration: &expiryTime, } - response, err := client.CreatePreAuthKey(ctx, request) + resp, err := client.CreatePreAuthKeyWithResponse(ctx, request) if err != nil { return fmt.Errorf("creating preauthkey: %w", err) } - return printOutput(cmd, response.GetPreAuthKey(), response.GetPreAuthKey().GetKey()) + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + preAuthKey := resp.JSON200.PreAuthKey + + return printOutput(cmd, preAuthKey, preAuthKey.Key) }), } +// preAuthKeyID reads the required --id flag for preauthkey commands. +func preAuthKeyID(cmd *cobra.Command) (uint64, error) { + id, _ := cmd.Flags().GetUint64("id") + if id == 0 { + return 0, fmt.Errorf("missing --id parameter: %w", errMissingParameter) + } + + return id, nil +} + var expirePreAuthKeyCmd = &cobra.Command{ Use: cmdExpire, Short: "Expire a preauthkey", Aliases: []string{"revoke", aliasExp, "e"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - id, _ := cmd.Flags().GetUint64("id") - - if id == 0 { - return fmt.Errorf("missing --id parameter: %w", errMissingParameter) + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + id, err := preAuthKeyID(cmd) + if err != nil { + return err } - request := &v1.ExpirePreAuthKeyRequest{ - Id: id, - } + idStr := strconv.FormatUint(id, util.Base10) - response, err := client.ExpirePreAuthKey(ctx, request) + resp, err := client.ExpirePreAuthKeyWithResponse(ctx, clientv1.ExpirePreAuthKeyJSONRequestBody{ + Id: &idStr, + }) if err != nil { return fmt.Errorf("expiring preauthkey: %w", err) } - return printOutput(cmd, response, "Key expired") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "Key expired") }), } @@ -156,22 +178,25 @@ var deletePreAuthKeyCmd = &cobra.Command{ Use: cmdDelete, Short: "Delete a preauthkey", Aliases: []string{aliasDel, "rm", "d"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - id, _ := cmd.Flags().GetUint64("id") - - if id == 0 { - return fmt.Errorf("missing --id parameter: %w", errMissingParameter) + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + id, err := preAuthKeyID(cmd) + if err != nil { + return err } - request := &v1.DeletePreAuthKeyRequest{ - Id: id, - } + idStr := strconv.FormatUint(id, util.Base10) - response, err := client.DeletePreAuthKey(ctx, request) + resp, err := client.DeletePreAuthKeyWithResponse(ctx, &clientv1.DeletePreAuthKeyParams{ + Id: &idStr, + }) if err != nil { return fmt.Errorf("deleting preauthkey: %w", err) } - return printOutput(cmd, response, "Key deleted") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "Key deleted") }), } diff --git a/cmd/headscale/cli/root.go b/cmd/headscale/cli/root.go index 36967ed55..ff06ea24b 100644 --- a/cmd/headscale/cli/root.go +++ b/cmd/headscale/cli/root.go @@ -3,6 +3,7 @@ package cli import ( "os" "runtime" + "slices" "strings" "github.com/juanfont/headscale/hscontrol/types" @@ -95,13 +96,9 @@ func initConfig() { var prereleases = []string{"alpha", "beta", "rc", "dev"} func isPreReleaseVersion(version string) bool { - for _, unstable := range prereleases { - if strings.Contains(version, unstable) { - return true - } - } - - return false + return slices.ContainsFunc(prereleases, func(unstable string) bool { + return strings.Contains(version, unstable) + }) } // filterPreReleasesIfStable returns a function that filters out @@ -120,13 +117,7 @@ func filterPreReleasesIfStable(versionFunc func() string) func(string) bool { } // If we are on a stable release, filter out pre-releases. - for _, ignore := range prereleases { - if strings.Contains(tag, ignore) { - return true - } - } - - return false + return isPreReleaseVersion(tag) } } diff --git a/cmd/headscale/cli/root_test.go b/cmd/headscale/cli/root_test.go index 68d1ae523..fb1fefd11 100644 --- a/cmd/headscale/cli/root_test.go +++ b/cmd/headscale/cli/root_test.go @@ -213,7 +213,8 @@ func TestFilterPreReleasesIfStable(t *testing.T) { t.Run(tt.name, func(t *testing.T) { result := filterPreReleasesIfStable(func() string { return tt.currentVersion })(tt.tag) if result != tt.expectedFilter { - t.Errorf("%s: got %v, want %v\nDescription: %s\nCurrent version: %s, Tag: %s", + t.Errorf( + "%s: got %v, want %v\nDescription: %s\nCurrent version: %s, Tag: %s", tt.name, result, tt.expectedFilter, @@ -293,7 +294,8 @@ func TestIsPreReleaseVersion(t *testing.T) { t.Run(tt.name, func(t *testing.T) { result := isPreReleaseVersion(tt.version) if result != tt.expected { - t.Errorf("%s: got %v, want %v\nDescription: %s\nVersion: %s", + t.Errorf( + "%s: got %v, want %v\nDescription: %s\nVersion: %s", tt.name, result, tt.expected, diff --git a/cmd/headscale/cli/users.go b/cmd/headscale/cli/users.go index bcc8c9139..71be49cf4 100644 --- a/cmd/headscale/cli/users.go +++ b/cmd/headscale/cli/users.go @@ -4,13 +4,13 @@ import ( "context" "errors" "fmt" + "net/http" "net/url" "strconv" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util/zlog/zf" - "github.com/pterm/pterm" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -37,13 +37,50 @@ func usernameAndIDFromFlag(cmd *cobra.Command) (uint64, string, error) { // Normalise unset/negative identifiers to 0 so the uint64 // conversion does not produce a bogus large value. - if identifier < 0 { - identifier = 0 - } + identifier = max(identifier, 0) return uint64(identifier), username, nil //nolint:gosec // identifier is clamped to >= 0 above } +// resolveSingleUser resolves exactly one user from the --name/--id flags, +// returning the raw flag id and the matched user. +func resolveSingleUser( + ctx context.Context, + client *clientv1.ClientWithResponses, + cmd *cobra.Command, +) (uint64, *clientv1.User, error) { + id, username, err := usernameAndIDFromFlag(cmd) + if err != nil { + return 0, nil, err + } + + params := &clientv1.ListUsersParams{} + if username != "" { + params.Name = &username + } + + if id != 0 { + idStr := strconv.FormatUint(id, util.Base10) + params.Id = &idStr + } + + resp, err := client.ListUsersWithResponse(ctx, params) + if err != nil { + return 0, nil, fmt.Errorf("listing users: %w", err) + } + + if resp.StatusCode() != http.StatusOK { + return 0, nil, apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + users := resp.JSON200.Users + if len(users) != 1 { + return 0, nil, errMultipleUsersMatch + } + + return id, &users[0], nil +} + func init() { rootCmd.AddCommand(userCmd) userCmd.AddCommand(createUserCmd) @@ -78,19 +115,19 @@ var createUserCmd = &cobra.Command{ return nil }, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { userName := args[0] - log.Trace().Interface(zf.Client, client).Msg("obtained gRPC client") + log.Trace().Interface(zf.Client, client).Msg("obtained API client") - request := &v1.CreateUserRequest{Name: userName} + request := clientv1.CreateUserJSONRequestBody{Name: &userName} if displayName, _ := cmd.Flags().GetString("display-name"); displayName != "" { - request.DisplayName = displayName + request.DisplayName = &displayName } if email, _ := cmd.Flags().GetString("email"); email != "" { - request.Email = email + request.Email = &email } if pictureURL, _ := cmd.Flags().GetString("picture-url"); pictureURL != "" { @@ -98,17 +135,21 @@ var createUserCmd = &cobra.Command{ return fmt.Errorf("invalid picture URL: %w", err) } - request.PictureUrl = pictureURL + request.PictureUrl = &pictureURL } log.Trace().Interface(zf.Request, request).Msg("sending CreateUser request") - response, err := client.CreateUser(ctx, request) + resp, err := client.CreateUserWithResponse(ctx, request) if err != nil { return fmt.Errorf("creating user: %w", err) } - return printOutput(cmd, response.GetUser(), "User created") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.User, "User created") }), } @@ -116,43 +157,29 @@ var destroyUserCmd = &cobra.Command{ Use: "destroy --identifier ID or --name NAME", Short: "Destroys a user", Aliases: []string{cmdDelete}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - id, username, err := usernameAndIDFromFlag(cmd) + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + _, user, err := resolveSingleUser(ctx, client, cmd) if err != nil { return err } - request := &v1.ListUsersRequest{ - Name: username, - Id: id, - } - - users, err := client.ListUsers(ctx, request) - if err != nil { - return fmt.Errorf("listing users: %w", err) - } - - if len(users.GetUsers()) != 1 { - return errMultipleUsersMatch - } - - user := users.GetUsers()[0] - if !confirmAction(cmd, fmt.Sprintf( - "Do you want to remove the user %q (%d) and any associated preauthkeys?", - user.GetName(), user.GetId(), + "Do you want to remove the user %q (%s) and any associated preauthkeys?", + user.Name, user.Id, )) { return printOutput(cmd, map[string]string{colResult: "User not destroyed"}, "User not destroyed") } - deleteRequest := &v1.DeleteUserRequest{Id: user.GetId()} - - response, err := client.DeleteUser(ctx, deleteRequest) + resp, err := client.DeleteUserWithResponse(ctx, user.Id) if err != nil { return fmt.Errorf("destroying user: %w", err) } - return printOutput(cmd, response, "User destroyed") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "User destroyed") }), } @@ -160,8 +187,8 @@ var listUsersCmd = &cobra.Command{ Use: cmdList, Short: "List all the users", Aliases: []string{"ls", cmdShow}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - request := &v1.ListUsersRequest{} + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + params := &clientv1.ListUsersParams{} id, _ := cmd.Flags().GetInt64("identifier") username, _ := cmd.Flags().GetString("name") @@ -170,36 +197,41 @@ var listUsersCmd = &cobra.Command{ // filter by one param at most switch { case id > 0: - request.Id = uint64(id) + idStr := strconv.FormatInt(id, util.Base10) + params.Id = &idStr case username != "": - request.Name = username + params.Name = &username case email != "": - request.Email = email + params.Email = &email } - response, err := client.ListUsers(ctx, request) + resp, err := client.ListUsersWithResponse(ctx, params) if err != nil { return fmt.Errorf("listing users: %w", err) } - return printListOutput(cmd, response.GetUsers(), func() error { - tableData := make(pterm.TableData, 1, 1+len(response.GetUsers())) + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } - tableData[0] = []string{"ID", "Name", "Username", "Email", colCreated} - for _, user := range response.GetUsers() { - tableData = append( - tableData, + users := resp.JSON200.Users + + return printListOutput(cmd, users, func() error { + rows := make([][]string, 0, len(users)) + for _, user := range users { + rows = append( + rows, []string{ - strconv.FormatUint(user.GetId(), util.Base10), - user.GetDisplayName(), - user.GetName(), - user.GetEmail(), - user.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat), + user.Id, + user.DisplayName, + user.Name, + user.Email, + user.CreatedAt.Format(HeadscaleDateTimeFormat), }, ) } - return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + return renderTable([]string{"ID", "Name", "Username", "Email", colCreated}, rows) }) }), } @@ -208,38 +240,23 @@ var renameUserCmd = &cobra.Command{ Use: "rename", Short: "Renames a user", Aliases: []string{"mv"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - id, username, err := usernameAndIDFromFlag(cmd) + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + id, _, err := resolveSingleUser(ctx, client, cmd) if err != nil { return err } - listReq := &v1.ListUsersRequest{ - Name: username, - Id: id, - } - - users, err := client.ListUsers(ctx, listReq) - if err != nil { - return fmt.Errorf("listing users: %w", err) - } - - if len(users.GetUsers()) != 1 { - return errMultipleUsersMatch - } - newName, _ := cmd.Flags().GetString("new-name") - renameReq := &v1.RenameUserRequest{ - OldId: id, - NewName: newName, - } - - response, err := client.RenameUser(ctx, renameReq) + resp, err := client.RenameUserWithResponse(ctx, strconv.FormatUint(id, util.Base10), newName) if err != nil { return fmt.Errorf("renaming user: %w", err) } - return printOutput(cmd, response.GetUser(), "User renamed") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.User, "User renamed") }), } diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index 364779ad5..9a5c311c2 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -6,21 +6,23 @@ import ( "encoding/json" "errors" "fmt" + "net" + "net/http" "os" + "slices" + "strings" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/cenkalti/backoff/v5" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util/zlog/zf" "github.com/prometheus/common/model" + "github.com/pterm/pterm" "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/protobuf/types/known/timestamppb" "gopkg.in/yaml.v3" ) @@ -36,11 +38,45 @@ const ( var ( errAPIKeyNotSet = errors.New("HEADSCALE_CLI_API_KEY environment variable needs to be set") errMissingParameter = errors.New("missing parameters") + errResponseStatus = errors.New("unexpected response status") ) -// mustMarkRequired marks the named flags as required on cmd, panicking -// if any name does not match a registered flag. This is only called -// from init() where a failure indicates a programming error. +// apiError turns a non-2xx response into an error, surfacing the server's +// RFC7807 problem detail. detail holds the operation context and errors[] the +// wrapped cause (e.g. "name is too long"); both are joined so the server's +// message text is not lost. +func apiError(statusCode int, problem *clientv1.ErrorModel) error { + if problem == nil { + return fmt.Errorf("%w: %d %s", errResponseStatus, statusCode, http.StatusText(statusCode)) + } + + parts := make([]string, 0, 2) + + if problem.Detail != nil && *problem.Detail != "" { + parts = append(parts, *problem.Detail) + } + + if problem.Errors != nil { + for _, e := range *problem.Errors { + if e.Message != nil && *e.Message != "" { + parts = append(parts, *e.Message) + } + } + } + + if len(parts) == 0 && problem.Title != nil && *problem.Title != "" { + parts = append(parts, *problem.Title) + } + + if len(parts) == 0 { + return fmt.Errorf("%w: %d %s", errResponseStatus, statusCode, http.StatusText(statusCode)) + } + + return fmt.Errorf("%w: %s", errResponseStatus, strings.Join(parts, ": ")) +} + +// mustMarkRequired marks the named flags as required, panicking on an unknown +// flag. Only called from init(), where a failure is a programming error. func mustMarkRequired(cmd *cobra.Command, names ...string) { for _, n := range names { err := cmd.MarkFlagRequired(n) @@ -67,28 +103,47 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) { return app, nil } -// grpcRunE wraps a cobra [cobra.Command.RunE] func, injecting a ready -// gRPC client and context. Connection lifecycle is managed by the -// wrapper — callers never see the underlying conn or cancel func. -func grpcRunE( - fn func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error, +// clientRunE wraps a [cobra.Command.RunE] func, injecting a ready API client +// and a context whose timeout/cancel the wrapper owns. +func clientRunE( + fn func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error, ) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { - ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig() + ctx, client, cancel, err := newHeadscaleCLIWithConfig() if err != nil { return fmt.Errorf("connecting to headscale: %w", err) } defer cancel() - defer conn.Close() return fn(ctx, client, cmd, args) } } -func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *grpc.ClientConn, context.CancelFunc, error) { +// withClient runs fn with an API client. For commands that branch on a flag +// before talking to the server, where clientRunE's whole-RunE wrapping does +// not fit. +func withClient( + fn func(ctx context.Context, client *clientv1.ClientWithResponses) error, +) error { + ctx, client, cancel, err := newHeadscaleCLIWithConfig() + if err != nil { + return fmt.Errorf("connecting to headscale: %w", err) + } + defer cancel() + + return fn(ctx, client) +} + +// newHeadscaleCLIWithConfig builds an HTTP client for the Headscale v1 API. +// +// When cfg.CLI.Address is unset the CLI is assumed to run on the server host +// and talks to the unix socket over HTTP without authentication (local trust). +// Otherwise it talks to the remote TCP address over HTTPS and injects the +// configured API key as a bearer token. +func newHeadscaleCLIWithConfig() (context.Context, *clientv1.ClientWithResponses, context.CancelFunc, error) { cfg, err := types.LoadCLIConfig() if err != nil { - return nil, nil, nil, nil, fmt.Errorf("loading configuration: %w", err) + return nil, nil, nil, fmt.Errorf("loading configuration: %w", err) } log.Debug(). @@ -97,10 +152,6 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout) - grpcOptions := []grpc.DialOption{ - grpc.WithBlock(), //nolint:staticcheck // SA1019: deprecated but supported in 1.x - } - address := cfg.CLI.Address // If the address is not set, we assume that we are on the server hosting [hscontrol]. @@ -109,77 +160,123 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g Str("socket", cfg.UnixSocket). Msgf("HEADSCALE_CLI_ADDRESS environment is not set, connecting to unix socket.") - address = cfg.UnixSocket - - // Try to give the user better feedback if we cannot write to the headscale - // socket. Note: [os.OpenFile] on a Unix domain socket returns ENXIO on - // Linux which is expected — only permission errors are actionable here. - // The actual gRPC connection uses [net.Dial] which handles sockets properly. - socket, err := os.OpenFile(cfg.UnixSocket, os.O_WRONLY, SocketWritePermissions) //nolint + client, err := newSocketClient(cfg.UnixSocket) if err != nil { - if os.IsPermission(err) { - cancel() - - return nil, nil, nil, nil, fmt.Errorf( - "unable to read/write to headscale socket %q, do you have the correct permissions? %w", - cfg.UnixSocket, - err, - ) - } - } else { - socket.Close() - } - - grpcOptions = append( - grpcOptions, - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithContextDialer(util.GrpcSocketDialer), - ) - } else { - // If we are not connecting to a local server, require an API key for authentication - apiKey := cfg.CLI.APIKey - if apiKey == "" { cancel() - return nil, nil, nil, nil, errAPIKeyNotSet + return nil, nil, nil, err } - grpcOptions = append(grpcOptions, - grpc.WithPerRPCCredentials(tokenAuth{ - token: apiKey, - }), - ) + log.Trace().Caller().Str(zf.Address, cfg.UnixSocket).Msg("connecting via unix socket") - if cfg.CLI.Insecure { - tlsConfig := &tls.Config{ - // turn of gosec as we are intentionally setting - // insecure. - //nolint:gosec - InsecureSkipVerify: true, - } - - grpcOptions = append(grpcOptions, - grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), - ) - } else { - grpcOptions = append(grpcOptions, - grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, "")), - ) - } + return ctx, client, cancel, nil } - log.Trace().Caller().Str(zf.Address, address).Msg("connecting via gRPC") + // Remote connections require an API key for authentication. + apiKey := cfg.CLI.APIKey + if apiKey == "" { + cancel() - conn, err := grpc.DialContext(ctx, address, grpcOptions...) //nolint:staticcheck // SA1019: deprecated but supported in 1.x + return nil, nil, nil, errAPIKeyNotSet + } + + client, err := newRemoteClient(address, apiKey, cfg.CLI.Insecure) if err != nil { cancel() - return nil, nil, nil, nil, fmt.Errorf("connecting to %s: %w", address, err) + return nil, nil, nil, err } - client := v1.NewHeadscaleServiceClient(conn) + log.Trace().Caller().Str(zf.Address, address).Msg("connecting via HTTPS") - return ctx, client, conn, cancel, nil + return ctx, client, cancel, nil +} + +// newSocketClient builds an API client that dials the local unix socket. The +// base-URL host is irrelevant; the custom dialer routes every request to the +// socket. +func newSocketClient(socketPath string) (*clientv1.ClientWithResponses, error) { + // Probe for a clearer permission error up front. [os.OpenFile] on a unix + // socket returns ENXIO on Linux (expected); only permission errors are + // actionable. The real connection goes through [net.Dial]. + socket, err := os.OpenFile(socketPath, os.O_WRONLY, SocketWritePermissions) //nolint + if err != nil { + if os.IsPermission(err) { + return nil, fmt.Errorf( + "unable to read/write to headscale socket %q, do you have the correct permissions? %w", + socketPath, + err, + ) + } + } else { + socket.Close() + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialHeadscaleSocket(ctx, socketPath) + }, + }, + } + + return clientv1.NewClientWithResponses( + "http://local", + clientv1.WithHTTPClient(httpClient), + ) +} + +// dialHeadscaleSocket connects to the unix socket, retrying until it appears or +// ctx (the CLI timeout) expires. The socket is created late in startup (after +// noise key, database, migrations), so a command run right after the server +// starts can race its creation; retrying preserves the old gRPC client's +// blocking-dial tolerance rather than failing on a not-yet-present socket. +func dialHeadscaleSocket(ctx context.Context, socketPath string) (net.Conn, error) { + b := backoff.NewExponentialBackOff() + b.InitialInterval = 50 * time.Millisecond + b.MaxInterval = 1 * time.Second + + return backoff.Retry(ctx, func() (net.Conn, error) { + return util.SocketDialer(ctx, socketPath) + }, backoff.WithBackOff(b)) +} + +// clientBaseURL turns a configured CLI address into a client base URL. A bare +// host[:port] (the historical form) defaults to https; an address that already +// carries a scheme is used as-is, so an explicit http:// or https:// is honoured +// rather than doubled into https://https://... +func clientBaseURL(address string) string { + if strings.Contains(address, "://") { + return address + } + + return "https://" + address +} + +// newRemoteClient builds an API client for a remote Headscale over HTTPS, +// honouring insecure (skip TLS verification) and injecting the API key as a +// bearer token on every request. +func newRemoteClient(address, apiKey string, insecure bool) (*clientv1.ClientWithResponses, error) { + transport := &http.Transport{} + if insecure { + transport.TLSClientConfig = &tls.Config{ + // turn off gosec as we are intentionally setting insecure. + //nolint:gosec + InsecureSkipVerify: true, + } + } + + httpClient := &http.Client{Transport: transport} + + return clientv1.NewClientWithResponses( + clientBaseURL(address), + clientv1.WithHTTPClient(httpClient), + clientv1.WithRequestEditorFn(func(_ context.Context, req *http.Request) error { + req.Header.Set("Authorization", "Bearer "+apiKey) + + return nil + }), + ) } // formatOutput serialises result into the requested format. For the @@ -228,16 +325,16 @@ func printOutput(cmd *cobra.Command, result any, override string) error { } // expirationFromFlag parses the --expiration flag as a Prometheus-style -// duration (e.g. "90d", "1h") and returns an absolute timestamp. -func expirationFromFlag(cmd *cobra.Command) (*timestamppb.Timestamp, error) { +// duration (e.g. "90d", "1h") and returns an absolute time. +func expirationFromFlag(cmd *cobra.Command) (time.Time, error) { durationStr, _ := cmd.Flags().GetString("expiration") duration, err := model.ParseDuration(durationStr) if err != nil { - return nil, fmt.Errorf("parsing duration: %w", err) + return time.Time{}, fmt.Errorf("parsing duration: %w", err) } - return timestamppb.New(time.Now().UTC().Add(time.Duration(duration))), nil + return time.Now().UTC().Add(time.Duration(duration)), nil } // confirmAction returns true when the user confirms a prompt, or when @@ -251,9 +348,19 @@ func confirmAction(cmd *cobra.Command, prompt string) bool { return util.YesNo(prompt) } +// renderTable prints a human-readable pterm table with the given header row +// and data rows, using the shared header styling. +func renderTable(header []string, rows [][]string) error { + tableData := make(pterm.TableData, 0, 1+len(rows)) + tableData = append(tableData, header) + tableData = append(tableData, rows...) + + return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() +} + // printListOutput checks the --output flag: when a machine-readable format is -// requested it serialises data as JSON/YAML; otherwise it calls renderTable -// to produce the human-readable pterm table. +// requested it serialises data as JSON/YAML; otherwise it calls the render +// callback to produce the human-readable pterm table. func printListOutput( cmd *cobra.Command, data any, @@ -275,50 +382,19 @@ func printError(err error, outputFormat string) { Error string `json:"error"` } - e := errOutput{Error: err.Error()} - - var formatted []byte - - switch outputFormat { - case outputFormatJSON: - formatted, _ = json.MarshalIndent(e, "", "\t") //nolint:errchkjson // errOutput contains only a string field - case outputFormatJSONLine: - formatted, _ = json.Marshal(e) //nolint:errchkjson // errOutput contains only a string field - case outputFormatYAML: - formatted, _ = yaml.Marshal(e) - default: + if outputFormat == "" { fmt.Fprintf(os.Stderr, "Error: %s\n", err) return } - fmt.Fprintf(os.Stderr, "%s\n", formatted) + // formatOutput cannot fail here: errOutput is a single string field. + out, _ := formatOutput(errOutput{Error: err.Error()}, "", outputFormat) + fmt.Fprintf(os.Stderr, "%s\n", out) } func hasMachineOutputFlag() bool { - for _, arg := range os.Args { - if arg == outputFormatJSON || arg == outputFormatJSONLine || arg == outputFormatYAML { - return true - } - } - - return false -} - -type tokenAuth struct { - token string -} - -// Return value is mapped to request headers. -func (t tokenAuth) GetRequestMetadata( - ctx context.Context, - in ...string, -) (map[string]string, error) { - return map[string]string{ - "authorization": "Bearer " + t.token, - }, nil -} - -func (tokenAuth) RequireTransportSecurity() bool { - return true + return slices.ContainsFunc(os.Args, func(arg string) bool { + return arg == outputFormatJSON || arg == outputFormatJSONLine || arg == outputFormatYAML + }) } diff --git a/cmd/headscale/cli/utils_socket_test.go b/cmd/headscale/cli/utils_socket_test.go new file mode 100644 index 000000000..cc9b22ed6 --- /dev/null +++ b/cmd/headscale/cli/utils_socket_test.go @@ -0,0 +1,72 @@ +package cli + +import ( + "context" + "net" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDialHeadscaleSocketRetriesUntilPresent proves the CLI socket dialer +// tolerates a not-yet-created socket (the server-still-starting race) by +// retrying until it appears, rather than failing immediately like a bare dial. +func TestDialHeadscaleSocketRetriesUntilPresent(t *testing.T) { + sock := filepath.Join(t.TempDir(), "headscale.sock") + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + type result struct { + conn net.Conn + err error + } + + done := make(chan result, 1) + + go func() { + conn, err := dialHeadscaleSocket(ctx, sock) + done <- result{conn, err} + }() + + // Listen only after the dialer has begun, so its backoff must retry the + // absent socket and connect once it exists. + var lc net.ListenConfig + + ln, err := lc.Listen(ctx, "unix", sock) + require.NoError(t, err) + + defer ln.Close() + + go func() { + if conn, _ := ln.Accept(); conn != nil { + conn.Close() + } + }() + + res := <-done + require.NoError(t, res.err) + require.NotNil(t, res.conn) + + res.conn.Close() +} + +// TestDialHeadscaleSocketRespectsDeadline proves the retry is bounded by the +// context: when the socket never appears, the dialer returns an error around the +// deadline instead of hanging. +func TestDialHeadscaleSocketRespectsDeadline(t *testing.T) { + sock := filepath.Join(t.TempDir(), "absent.sock") + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + start := time.Now() + + conn, err := dialHeadscaleSocket(ctx, sock) + require.Error(t, err) + assert.Nil(t, conn) + assert.Less(t, time.Since(start), 5*time.Second, "should stop near the deadline, not hang") +} diff --git a/cmd/headscale/cli/utils_test.go b/cmd/headscale/cli/utils_test.go new file mode 100644 index 000000000..845d676c5 --- /dev/null +++ b/cmd/headscale/cli/utils_test.go @@ -0,0 +1,35 @@ +package cli + +import "testing" + +func TestClientBaseURL(t *testing.T) { + tests := []struct { + name string + address string + want string + }{ + { + name: "bare host defaults to https", + address: "headscale.example.com:50443", + want: "https://headscale.example.com:50443", + }, + { + name: "explicit https scheme is kept", + address: "https://headscale.example.com", + want: "https://headscale.example.com", + }, + { + name: "explicit http scheme is kept", + address: "http://localhost:8080", + want: "http://localhost:8080", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := clientBaseURL(tt.address); got != tt.want { + t.Errorf("clientBaseURL(%q) = %q, want %q", tt.address, got, tt.want) + } + }) + } +} diff --git a/cmd/headscale/headscale.go b/cmd/headscale/headscale.go index 679f082ee..933e78e06 100644 --- a/cmd/headscale/headscale.go +++ b/cmd/headscale/headscale.go @@ -11,21 +11,7 @@ import ( ) func main() { - var colors bool - - switch l := termcolor.SupportLevel(os.Stderr); l { - case termcolor.Level16M: - colors = true - case termcolor.Level256: - colors = true - case termcolor.LevelBasic: - colors = true - case termcolor.LevelNone: - colors = false - default: - // no color, return text as is. - colors = false - } + colors := termcolor.SupportLevel(os.Stderr) != termcolor.LevelNone // Adhere to no-color.org manifesto of allowing users to // turn off color in cli/services diff --git a/cmd/hi/cleanup.go b/cmd/hi/cleanup.go index 26db49ae9..d58ab779e 100644 --- a/cmd/hi/cleanup.go +++ b/cmd/hi/cleanup.go @@ -71,26 +71,8 @@ func killTestContainers(ctx context.Context) error { removed := 0 for _, cont := range containers { - shouldRemove := false - - for _, name := range cont.Names { - if strings.Contains(name, "headscale-test-suite") || - strings.Contains(name, "hs-") || - strings.Contains(name, "ts-") || - strings.Contains(name, "derp-") { - shouldRemove = true - break - } - } - - if shouldRemove { - // First kill the container if it's running - if cont.State == "running" { - _ = cli.ContainerKill(ctx, cont.ID, "KILL") - } - - // Then remove the container with retry logic - if removeContainerWithRetry(ctx, cli, cont.ID) { + if isTestContainerName(cont.Names) { + if killAndRemove(ctx, cli, cont) { removed++ } } @@ -129,13 +111,7 @@ func killTestContainersByRunID(ctx context.Context, runID string) error { removed := 0 for _, cont := range containers { - // Kill the container if it's running - if cont.State == "running" { - _ = cli.ContainerKill(ctx, cont.ID, "KILL") - } - - // Remove the container with retry logic - if removeContainerWithRetry(ctx, cli, cont.ID) { + if killAndRemove(ctx, cli, cont) { removed++ } } @@ -173,20 +149,8 @@ func cleanupStaleTestContainers(ctx context.Context) error { for _, cont := range containers { // Only remove containers that look like test containers - shouldRemove := false - - for _, name := range cont.Names { - if strings.Contains(name, "headscale-test-suite") || - strings.Contains(name, "hs-") || - strings.Contains(name, "ts-") || - strings.Contains(name, "derp-") { - shouldRemove = true - break - } - } - - if shouldRemove { - if removeContainerWithRetry(ctx, cli, cont.ID) { + if isTestContainerName(cont.Names) { + if killAndRemove(ctx, cli, cont) { removed++ } } @@ -223,6 +187,46 @@ func removeContainerWithRetry(ctx context.Context, cli *client.Client, container return err == nil } +// testContainerNamePrefixes are the name prefixes used by containers that the +// integration test harness creates (headscale, tailscale, DERP, and k3s). +var testContainerNamePrefixes = []string{"hs-", "ts-", "derp-", "k3s-"} + +// matchesTestContainerPrefix reports whether name belongs to an integration +// test container, ignoring any leading "/" that Docker prefixes names with. +func matchesTestContainerPrefix(name string) bool { + name = strings.TrimPrefix(name, "/") + for _, prefix := range testContainerNamePrefixes { + if strings.HasPrefix(name, prefix) { + return true + } + } + + return false +} + +// isTestContainerName reports whether any of the container names belong to an +// integration test container. +func isTestContainerName(names []string) bool { + for _, name := range names { + if strings.Contains(name, "headscale-test-suite") || + matchesTestContainerPrefix(name) { + return true + } + } + + return false +} + +// killAndRemove kills a running container then removes it with retry logic, +// reporting whether the removal succeeded. +func killAndRemove(ctx context.Context, cli *client.Client, cont container.Summary) bool { + if cont.State == "running" { + _ = cli.ContainerKill(ctx, cont.ID, "KILL") + } + + return removeContainerWithRetry(ctx, cli, cont.ID) +} + // pruneDockerNetworks removes unused Docker networks. func pruneDockerNetworks(ctx context.Context) error { cli, err := createDockerClient(ctx) diff --git a/cmd/hi/docker.go b/cmd/hi/docker.go index 1ed8709ce..102ee526e 100644 --- a/cmd/hi/docker.go +++ b/cmd/hi/docker.go @@ -735,7 +735,7 @@ func getCurrentTestContainers(containers []container.Summary, testContainerID st for _, cont := range containers { for _, name := range cont.Names { containerName := strings.TrimPrefix(name, "/") - if strings.HasPrefix(containerName, "hs-") || strings.HasPrefix(containerName, "ts-") { + if matchesTestContainerPrefix(containerName) { // Check if container has matching run ID label if cont.Labels != nil && cont.Labels["hi.run-id"] == runID { testRunContainers = append(testRunContainers, testContainer{ @@ -769,17 +769,6 @@ func extractContainerArtifacts(ctx context.Context, cli *client.Client, containe return fmt.Errorf("extracting logs: %w", err) } - // Extract tar files for headscale containers only - if strings.HasPrefix(containerName, "hs-") { - err := extractContainerFiles(ctx, cli, containerID, containerName, logsDir, verbose) - if err != nil { - if verbose { - log.Printf("Warning: failed to extract files from %s: %v", containerName, err) - } - // Don't fail the whole extraction if files are missing - } - } - return nil } @@ -827,12 +816,3 @@ func extractContainerLogs(ctx context.Context, cli *client.Client, containerID, return nil } - -// extractContainerFiles extracts database file and directories from headscale containers. -// Note: The actual file extraction is now handled by the integration tests themselves -// via [SaveProfile], [SaveMapResponses], and [SaveDatabase] functions in hsic.go. -func extractContainerFiles(ctx context.Context, cli *client.Client, containerID, containerName, logsDir string, verbose bool) error { - // Files are now extracted directly by the integration tests - // This function is kept for potential future use or other file types - return nil -} diff --git a/cmd/hi/doctor.go b/cmd/hi/doctor.go index 9291674d8..7bd42fb90 100644 --- a/cmd/hi/doctor.go +++ b/cmd/hi/doctor.go @@ -5,10 +5,12 @@ import ( "errors" "fmt" "log" + "os" "os/exec" "strings" "github.com/juanfont/headscale/integration/dockertestutil" + "github.com/juanfont/headscale/integration/k3sic" ) const ( @@ -20,6 +22,7 @@ const ( nameDockerContext = "Docker Context" nameDockerSocket = "Docker Socket" nameGolangImage = "Golang Image" + nameK3sImage = "K3s Image" nameGoInstall = "Go Installation" ) @@ -33,6 +36,21 @@ type DoctorResult struct { Suggestions []string } +// pass builds a passing DoctorResult. +func pass(name, message string) DoctorResult { + return DoctorResult{Name: name, Status: statusPass, Message: message} +} + +// warn builds a warning DoctorResult with optional suggestions. +func warn(name, message string, suggestions ...string) DoctorResult { + return DoctorResult{Name: name, Status: statusWarn, Message: message, Suggestions: suggestions} +} + +// fail builds a failing DoctorResult with optional suggestions. +func fail(name, message string, suggestions ...string) DoctorResult { + return DoctorResult{Name: name, Status: statusFail, Message: message, Suggestions: suggestions} +} + // runDoctorCheck performs comprehensive pre-flight checks for integration testing. func runDoctorCheck(ctx context.Context) error { results := []DoctorResult{} @@ -50,6 +68,7 @@ func runDoctorCheck(ctx context.Context) error { results = append(results, checkDockerSocket(ctx)) results = append(results, checkDockerHubCredentials()) results = append(results, checkGolangImage(ctx)) + results = append(results, checkK3sImage(ctx)) } // Check 3: Go installation @@ -80,129 +99,91 @@ func runDoctorCheck(ctx context.Context) error { func checkDockerBinary() DoctorResult { _, err := exec.LookPath("docker") if err != nil { - return DoctorResult{ - Name: "Docker Binary", - Status: statusFail, - Message: "Docker binary not found in PATH", - Suggestions: []string{ - "Install Docker: https://docs.docker.com/get-docker/", - "For macOS: consider using colima or Docker Desktop", - "Ensure docker is in your PATH", - }, - } + return fail( + "Docker Binary", + "Docker binary not found in PATH", + "Install Docker: https://docs.docker.com/get-docker/", + "For macOS: consider using colima or Docker Desktop", + "Ensure docker is in your PATH", + ) } - return DoctorResult{ - Name: "Docker Binary", - Status: statusPass, - Message: "Docker binary found", - } + return pass("Docker Binary", "Docker binary found") } // checkDockerDaemon verifies Docker daemon is running and accessible. func checkDockerDaemon(ctx context.Context) DoctorResult { cli, err := createDockerClient(ctx) if err != nil { - return DoctorResult{ - Name: nameDockerDaemon, - Status: statusFail, - Message: fmt.Sprintf("Cannot create Docker client: %v", err), - Suggestions: []string{ - "Start Docker daemon/service", - "Check Docker Desktop is running (if using Docker Desktop)", - "For colima: run 'colima start'", - "Verify DOCKER_HOST environment variable if set", - }, - } + return fail( + nameDockerDaemon, + fmt.Sprintf("Cannot create Docker client: %v", err), + "Start Docker daemon/service", + "Check Docker Desktop is running (if using Docker Desktop)", + "For colima: run 'colima start'", + "Verify DOCKER_HOST environment variable if set", + ) } defer cli.Close() _, err = cli.Ping(ctx) if err != nil { - return DoctorResult{ - Name: nameDockerDaemon, - Status: statusFail, - Message: fmt.Sprintf("Cannot ping Docker daemon: %v", err), - Suggestions: []string{ - "Ensure Docker daemon is running", - "Check Docker socket permissions", - "Try: docker info", - }, - } + return fail( + nameDockerDaemon, + fmt.Sprintf("Cannot ping Docker daemon: %v", err), + "Ensure Docker daemon is running", + "Check Docker socket permissions", + "Try: docker info", + ) } - return DoctorResult{ - Name: nameDockerDaemon, - Status: statusPass, - Message: "Docker daemon is running and accessible", - } + return pass(nameDockerDaemon, "Docker daemon is running and accessible") } // checkDockerContext verifies Docker context configuration. func checkDockerContext(ctx context.Context) DoctorResult { contextInfo, err := getCurrentDockerContext(ctx) if err != nil { - return DoctorResult{ - Name: nameDockerContext, - Status: statusWarn, - Message: "Could not detect Docker context, using default settings", - Suggestions: []string{ - "Check: docker context ls", - "Consider setting up a specific context if needed", - }, - } + return warn( + nameDockerContext, + "Could not detect Docker context, using default settings", + "Check: docker context ls", + "Consider setting up a specific context if needed", + ) } if contextInfo == nil { - return DoctorResult{ - Name: nameDockerContext, - Status: statusPass, - Message: "Using default Docker context", - } + return pass(nameDockerContext, "Using default Docker context") } - return DoctorResult{ - Name: nameDockerContext, - Status: statusPass, - Message: "Using Docker context: " + contextInfo.Name, - } + return pass(nameDockerContext, "Using Docker context: "+contextInfo.Name) } // checkDockerSocket verifies Docker socket accessibility. func checkDockerSocket(ctx context.Context) DoctorResult { cli, err := createDockerClient(ctx) if err != nil { - return DoctorResult{ - Name: nameDockerSocket, - Status: statusFail, - Message: fmt.Sprintf("Cannot access Docker socket: %v", err), - Suggestions: []string{ - "Check Docker socket permissions", - "Add user to docker group: sudo usermod -aG docker $USER", - "For colima: ensure socket is accessible", - }, - } + return fail( + nameDockerSocket, + fmt.Sprintf("Cannot access Docker socket: %v", err), + "Check Docker socket permissions", + "Add user to docker group: sudo usermod -aG docker $USER", + "For colima: ensure socket is accessible", + ) } defer cli.Close() info, err := cli.Info(ctx) if err != nil { - return DoctorResult{ - Name: nameDockerSocket, - Status: statusFail, - Message: fmt.Sprintf("Cannot get Docker info: %v", err), - Suggestions: []string{ - "Check Docker daemon status", - "Verify socket permissions", - }, - } + return fail( + nameDockerSocket, + fmt.Sprintf("Cannot get Docker info: %v", err), + "Check Docker daemon status", + "Verify socket permissions", + ) } - return DoctorResult{ - Name: nameDockerSocket, - Status: statusPass, - Message: fmt.Sprintf("Docker socket accessible (Server: %s)", info.ServerVersion), - } + return pass(nameDockerSocket, fmt.Sprintf("Docker socket accessible (Server: %s)", info.ServerVersion)) } // checkDockerHubCredentials warns when pulls would be anonymous and @@ -210,34 +191,23 @@ func checkDockerSocket(ctx context.Context) DoctorResult { func checkDockerHubCredentials() DoctorResult { _, _, source := dockertestutil.Credentials() if source == dockertestutil.CredentialSourceAnonymous { - return DoctorResult{ - Name: "Docker Hub Credentials", - Status: "WARN", - Message: "No Docker Hub credentials found — pulls will be rate-limited (100/6h per IP)", - Suggestions: []string{ - "Run: docker login", - "Or export DOCKERHUB_USERNAME and DOCKERHUB_TOKEN", - "In CI: ensure the docker/login-action step is configured with secrets", - }, - } + return warn( + "Docker Hub Credentials", + "No Docker Hub credentials found — pulls will be rate-limited (100/6h per IP)", + "Run: docker login", + "Or export DOCKERHUB_USERNAME and DOCKERHUB_TOKEN", + "In CI: ensure the docker/login-action step is configured with secrets", + ) } - return DoctorResult{ - Name: "Docker Hub Credentials", - Status: "PASS", - Message: fmt.Sprintf("Credentials available (source: %s)", source), - } + return pass("Docker Hub Credentials", fmt.Sprintf("Credentials available (source: %s)", source)) } // checkGolangImage verifies the golang Docker image is available locally or can be pulled. func checkGolangImage(ctx context.Context) DoctorResult { cli, err := createDockerClient(ctx) if err != nil { - return DoctorResult{ - Name: nameGolangImage, - Status: statusFail, - Message: "Cannot create Docker client for image check", - } + return fail(nameGolangImage, "Cannot create Docker client for image check") } defer cli.Close() @@ -247,81 +217,94 @@ func checkGolangImage(ctx context.Context) DoctorResult { // First check if image is available locally available, err := checkImageAvailableLocally(ctx, cli, imageName) if err != nil { - return DoctorResult{ - Name: nameGolangImage, - Status: statusFail, - Message: fmt.Sprintf("Cannot check golang image %s: %v", imageName, err), - Suggestions: []string{ - "Check Docker daemon status", - "Try: docker images | grep golang", - }, - } + return fail( + nameGolangImage, + fmt.Sprintf("Cannot check golang image %s: %v", imageName, err), + "Check Docker daemon status", + "Try: docker images | grep golang", + ) } if available { - return DoctorResult{ - Name: nameGolangImage, - Status: statusPass, - Message: fmt.Sprintf("Golang image %s is available locally", imageName), - } + return pass(nameGolangImage, fmt.Sprintf("Golang image %s is available locally", imageName)) } // Image not available locally, try to pull it err = ensureImageAvailable(ctx, cli, imageName, false) if err != nil { - return DoctorResult{ - Name: nameGolangImage, - Status: statusFail, - Message: fmt.Sprintf("Golang image %s not available locally and cannot pull: %v", imageName, err), - Suggestions: []string{ - "Check internet connectivity", - "Verify Docker Hub access", - "Try: docker pull " + imageName, - "Or run tests offline if image was pulled previously", - }, - } + return fail( + nameGolangImage, + fmt.Sprintf("Golang image %s not available locally and cannot pull: %v", imageName, err), + "Check internet connectivity", + "Verify Docker Hub access", + "Try: docker pull "+imageName, + "Or run tests offline if image was pulled previously", + ) } - return DoctorResult{ - Name: nameGolangImage, - Status: statusPass, - Message: fmt.Sprintf("Golang image %s is now available", imageName), + return pass(nameGolangImage, fmt.Sprintf("Golang image %s is now available", imageName)) +} + +// checkK3sImage verifies the ghcr k3s image used by TestK8sOperator is available +// locally or can be pulled. The image is pinned (see [k3sic.K3sImage]). +func checkK3sImage(ctx context.Context) DoctorResult { + cli, err := createDockerClient(ctx) + if err != nil { + return fail(nameK3sImage, "Cannot create Docker client for image check") } + defer cli.Close() + + imageName := k3sic.K3sImage + + available, err := checkImageAvailableLocally(ctx, cli, imageName) + if err != nil { + return fail( + nameK3sImage, + fmt.Sprintf("Cannot check k3s image %s: %v", imageName, err), + "Check Docker daemon status", + "Try: docker images | grep k3s", + ) + } + + if available { + return pass(nameK3sImage, fmt.Sprintf("K3s image %s is available locally", imageName)) + } + + err = ensureImageAvailable(ctx, cli, imageName, false) + if err != nil { + return warn( + nameK3sImage, + fmt.Sprintf("K3s image %s not available locally and could not pull: %v", imageName, err), + "Only TestK8sOperator needs this image; other tests are unaffected", + "Try: docker pull "+imageName, + ) + } + + return pass(nameK3sImage, fmt.Sprintf("K3s image %s is now available", imageName)) } // checkGoInstallation verifies Go is installed and working. func checkGoInstallation(ctx context.Context) DoctorResult { _, err := exec.LookPath("go") if err != nil { - return DoctorResult{ - Name: nameGoInstall, - Status: statusFail, - Message: "Go binary not found in PATH", - Suggestions: []string{ - "Install Go: https://golang.org/dl/", - "Ensure go is in your PATH", - }, - } + return fail( + nameGoInstall, + "Go binary not found in PATH", + "Install Go: https://golang.org/dl/", + "Ensure go is in your PATH", + ) } cmd := exec.CommandContext(ctx, "go", "version") output, err := cmd.Output() if err != nil { - return DoctorResult{ - Name: nameGoInstall, - Status: statusFail, - Message: fmt.Sprintf("Cannot get Go version: %v", err), - } + return fail(nameGoInstall, fmt.Sprintf("Cannot get Go version: %v", err)) } version := strings.TrimSpace(string(output)) - return DoctorResult{ - Name: nameGoInstall, - Status: statusPass, - Message: version, - } + return pass(nameGoInstall, version) } // checkGitRepository verifies we're in a git repository. @@ -330,26 +313,19 @@ func checkGitRepository(ctx context.Context) DoctorResult { err := cmd.Run() if err != nil { - return DoctorResult{ - Name: "Git Repository", - Status: statusFail, - Message: "Not in a Git repository", - Suggestions: []string{ - "Run from within the headscale git repository", - "Clone the repository: git clone https://github.com/juanfont/headscale.git", - }, - } + return fail( + "Git Repository", + "Not in a Git repository", + "Run from within the headscale git repository", + "Clone the repository: git clone https://github.com/juanfont/headscale.git", + ) } - return DoctorResult{ - Name: "Git Repository", - Status: statusPass, - Message: "Running in Git repository", - } + return pass("Git Repository", "Running in Git repository") } // checkRequiredFiles verifies required files exist. -func checkRequiredFiles(ctx context.Context) DoctorResult { +func checkRequiredFiles(_ context.Context) DoctorResult { requiredFiles := []string{ "go.mod", "integration/", @@ -359,32 +335,23 @@ func checkRequiredFiles(ctx context.Context) DoctorResult { var missingFiles []string for _, file := range requiredFiles { - cmd := exec.CommandContext(ctx, "test", "-e", file) - - err := cmd.Run() + _, err := os.Stat(file) if err != nil { missingFiles = append(missingFiles, file) } } if len(missingFiles) > 0 { - return DoctorResult{ - Name: "Required Files", - Status: statusFail, - Message: "Missing required files: " + strings.Join(missingFiles, ", "), - Suggestions: []string{ - "Ensure you're in the headscale project root directory", - "Check that integration/ directory exists", - "Verify this is a complete headscale repository", - }, - } + return fail( + "Required Files", + "Missing required files: "+strings.Join(missingFiles, ", "), + "Ensure you're in the headscale project root directory", + "Check that integration/ directory exists", + "Verify this is a complete headscale repository", + ) } - return DoctorResult{ - Name: "Required Files", - Status: statusPass, - Message: "All required files found", - } + return pass("Required Files", "All required files found") } // displayDoctorResults shows the results in a formatted way. diff --git a/cmd/hi/main.go b/cmd/hi/main.go index 39d4c4106..903d77fa1 100644 --- a/cmd/hi/main.go +++ b/cmd/hi/main.go @@ -86,20 +86,17 @@ func main() { } func cleanAll(ctx context.Context) error { - err := killTestContainers(ctx) - if err != nil { - return err + for _, step := range []func(context.Context) error{ + killTestContainers, + pruneDockerNetworks, + cleanOldImages, + cleanCacheVolume, + } { + err := step(ctx) + if err != nil { + return err + } } - err = pruneDockerNetworks(ctx) - if err != nil { - return err - } - - err = cleanOldImages(ctx) - if err != nil { - return err - } - - return cleanCacheVolume(ctx) + return nil } diff --git a/cmd/hi/run.go b/cmd/hi/run.go index 300022088..35214e3cf 100644 --- a/cmd/hi/run.go +++ b/cmd/hi/run.go @@ -6,6 +6,7 @@ import ( "log" "os" "path/filepath" + "strings" "time" "github.com/creachadair/command" @@ -66,64 +67,21 @@ func runIntegrationTest(env *command.Env) error { // detectGoVersion reads the Go version from go.mod file. func detectGoVersion() string { - goModPath := filepath.Join("..", "..", "go.mod") - - if _, err := os.Stat("go.mod"); err == nil { //nolint:noinlineerr - goModPath = "go.mod" - } else if _, err := os.Stat("../../go.mod"); err == nil { //nolint:noinlineerr - goModPath = "../../go.mod" - } - - content, err := os.ReadFile(goModPath) + content, err := os.ReadFile("go.mod") if err != nil { - return "1.26.1" + content, err = os.ReadFile(filepath.Join("..", "..", "go.mod")) + if err != nil { + return "1.26.1" + } } - lines := splitLines(string(content)) - for _, line := range lines { - if len(line) > 3 && line[:3] == "go " { - version := line[3:] - if idx := indexOf(version, " "); idx != -1 { - version = version[:idx] + for line := range strings.Lines(string(content)) { + if rest, ok := strings.CutPrefix(line, "go "); ok { + if f := strings.Fields(rest); len(f) > 0 { + return f[0] } - - return version } } return "1.26.1" } - -// splitLines splits a string into lines without using [strings.Split]. -func splitLines(s string) []string { - var ( - lines []string - current string - ) - - for _, char := range s { - if char == '\n' { - lines = append(lines, current) - current = "" - } else { - current += string(char) - } - } - - if current != "" { - lines = append(lines, current) - } - - return lines -} - -// indexOf finds the first occurrence of substr in s. -func indexOf(s, substr string) int { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return i - } - } - - return -1 -} diff --git a/cmd/hi/stats.go b/cmd/hi/stats.go index e8b8e28cf..f56043d96 100644 --- a/cmd/hi/stats.go +++ b/cmd/hi/stats.go @@ -1,12 +1,13 @@ package main import ( + "cmp" "context" "encoding/json" "errors" "fmt" "log" - "sort" + "slices" "strings" "sync" "time" @@ -374,24 +375,24 @@ func (sc *StatsCollector) GetSummary() []ContainerStatsSummary { SampleCount: len(stats), } - // Calculate CPU stats - cpuValues := make([]float64, len(stats)) - memoryValues := make([]float64, len(stats)) + extract := func(get func(StatsSample) float64) []float64 { + values := make([]float64, len(stats)) + for i, sample := range stats { + values[i] = get(sample) + } - for i, sample := range stats { - cpuValues[i] = sample.CPUUsage - memoryValues[i] = sample.MemoryMB + return values } - summary.CPU = calculateStatsSummary(cpuValues) - summary.Memory = calculateStatsSummary(memoryValues) + summary.CPU = calculateStatsSummary(extract(func(s StatsSample) float64 { return s.CPUUsage })) + summary.Memory = calculateStatsSummary(extract(func(s StatsSample) float64 { return s.MemoryMB })) summaries = append(summaries, summary) } // Sort by container name for consistent output - sort.Slice(summaries, func(i, j int) bool { - return summaries[i].ContainerName < summaries[j].ContainerName + slices.SortFunc(summaries, func(a, b ContainerStatsSummary) int { + return cmp.Compare(a.ContainerName, b.ContainerName) }) return summaries @@ -408,14 +409,8 @@ func calculateStatsSummary(values []float64) StatsSummary { sum := 0.0 for _, value := range values { - if value < minVal { - minVal = value - } - - if value > maxVal { - maxVal = value - } - + minVal = min(minVal, value) + maxVal = max(maxVal, value) sum += value } diff --git a/config-example.yaml b/config-example.yaml index 22b9f3496..09f0356b8 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -23,22 +23,6 @@ listen_addr: 127.0.0.1:8080 # Use an empty value to disable the metrics listener. metrics_listen_addr: 127.0.0.1:9090 -# Address to listen for gRPC. -# gRPC is used for controlling a headscale server -# remotely with the CLI -# Note: Remote access _only_ works if you have -# valid certificates. -# -# For production: -# grpc_listen_addr: 0.0.0.0:50443 -grpc_listen_addr: 127.0.0.1:50443 - -# Allow the gRPC admin interface to run in INSECURE -# mode. This is not recommended as the traffic will -# be unencrypted. Only enable if you know what you -# are doing. -grpc_allow_insecure: false - # CIDR(s) of reverse proxies (e.g. 127.0.0.1/32) whose # True-Client-IP, X-Real-IP and X-Forwarded-For headers should # be honoured. Empty (default) ignores those headers; setting @@ -246,12 +230,12 @@ database: # ssl: false ### TLS configuration -# -## Let's encrypt / ACME -# -# headscale supports automatically requesting and setting up +# See: https://headscale.net/stable/ref/tls/ + +## Let's Encrypt / ACME +# Headscale supports automatically requesting and setting up # TLS for a domain with Let's Encrypt. -# + # URL to ACME directory acme_url: https://acme-v02.api.letsencrypt.org/directory @@ -261,15 +245,13 @@ acme_email: "" # Domain name to request a TLS certificate for: tls_letsencrypt_hostname: "" -# Path to store certificates and metadata needed by -# letsencrypt -# For production: +# Path to store certificates and metadata needed by letsencrypt tls_letsencrypt_cache_dir: /var/lib/headscale/cache # Type of ACME challenge to use, currently supported types: # HTTP-01 or TLS-ALPN-01 -# See: https://headscale.net/stable/ref/tls/ tls_letsencrypt_challenge_type: HTTP-01 + # When HTTP-01 challenge is chosen, letsencrypt must set up a # verification endpoint, and it will be listening on: # :http = port 80 @@ -295,6 +277,7 @@ policy: # The mode can be "file" or "database" that defines # where the policies are stored and read from. mode: file + # If the mode is set to "file", the path to a HuJSON file containing policies. path: "" @@ -476,6 +459,7 @@ taildrop: # choice. auto_update: enabled: false + # Advanced performance tuning parameters. # The defaults are carefully chosen and should rarely need adjustment. # Only modify these if you have identified a specific performance issue. diff --git a/docs/about/faq.md b/docs/about/faq.md index 09792653d..25cdb1123 100644 --- a/docs/about/faq.md +++ b/docs/about/faq.md @@ -152,15 +152,15 @@ See also . Headscale checks if the policy is valid during startup and refuses to start if it detects an error. The error message indicates which part of the policy is invalid. Follow these steps to fix your policy: -- Dump the policy to a file: `headscale policy get --bypass-grpc-and-access-database-directly > policy.json` +- Dump the policy to a file: `headscale policy get --bypass-server-and-access-database-directly > policy.json` - Edit and fixup `policy.json`. Use the command `headscale policy check --file policy.json` to validate the policy. -- Load the modified policy: `headscale policy set --bypass-grpc-and-access-database-directly --file policy.json` +- Load the modified policy: `headscale policy set --bypass-server-and-access-database-directly --file policy.json` - Start Headscale as usual. !!! warning "Full server configuration required" The above commands to get/set the policy require a complete server configuration file including database settings. A - minimal config to [control Headscale via remote CLI](../ref/api.md#grpc) is not sufficient. You may use + minimal config to [control Headscale via remote CLI](../ref/api.md#remote-control) is not sufficient. You may use `headscale -c /path/to/config.yaml` to specify the path to an alternative configuration file. ## How can I migrate back to the recommended IP prefixes? diff --git a/docs/ref/api.md b/docs/ref/api.md index 4337da137..8f492dfd3 100644 --- a/docs/ref/api.md +++ b/docs/ref/api.md @@ -1,10 +1,10 @@ # API -Headscale provides a [HTTP REST API](#rest-api) and a [gRPC interface](#grpc) which may be used to integrate a [web -interface](integration/web-ui.md), [remote control Headscale](#setup-remote-control) or provide a base for custom +Headscale provides a [HTTP REST API](#rest-api) which may be used to integrate a [web +interface](integration/web-ui.md), [remote control Headscale](#remote-control) or provide a base for custom integration and tooling. -Both interfaces require a valid API key before use. To create an API key, log into your Headscale server and generate +The API requires a valid API key before use. To create an API key, log into your Headscale server and generate one with the default expiration of 90 days: ```shell @@ -29,12 +29,12 @@ headscale apikeys expire --prefix ## REST API - API endpoint: `/api/v1`, e.g. `https://headscale.example.com/api/v1` -- Documentation: `/swagger`, e.g. `https://headscale.example.com/swagger` +- Documentation: `/api/v1/docs`, e.g. `https://headscale.example.com/api/v1/docs` - Headscale Version: `/version`, e.g. `https://headscale.example.com/version` - Authenticate using HTTP Bearer authentication by sending the [API key](#api) with the HTTP `Authorization: Bearer ` header. Start by [creating an API key](#api) and test it with the examples below. Read the API documentation provided by your -Headscale server at `/swagger` for details. +Headscale server at `/api/v1/docs` for details. === "Get details for all users" @@ -54,20 +54,18 @@ Headscale server at `/swagger` for details. ```console curl -H "Authorization: Bearer " \ - --json '{"user": "", "authId": "AUTH_ID>"}' \ + --json '{"user": "", "authId": ""}' \ https://headscale.example.com/api/v1/auth/register ``` -## gRPC +## Remote control -The gRPC interface can be used to control a Headscale instance from a remote machine with the `headscale` binary. +The `headscale` binary can control a Headscale instance from a remote machine over the HTTP API. ### Prerequisite - A workstation to run `headscale` (any supported platform, e.g. Linux). -- A Headscale server with gRPC enabled. -- Connections to the gRPC port (default: `50443`) are allowed. -- Remote access requires an encrypted connection via TLS. +- The Headscale server reachable over HTTP(S). - An [API key](#api) to authenticate with the Headscale server. ### Setup remote control @@ -88,19 +86,20 @@ The gRPC interface can be used to control a Headscale instance from a remote mac ```yaml title="config.yaml" cli: - address: : + address: api_key: ``` === "Environment variables" ```shell - export HEADSCALE_CLI_ADDRESS=":" + export HEADSCALE_CLI_ADDRESS="" export HEADSCALE_CLI_API_KEY="" ``` - This instructs the `headscale` binary to connect to a remote instance at `:`, instead of - connecting to the local instance. + This instructs the `headscale` binary to connect to a remote instance at `` (e.g. + `https://headscale.example.com`), instead of connecting to the local instance. A bare host without a scheme is + assumed to be `https`. 1. Test the connection by listing all nodes: @@ -113,15 +112,12 @@ The gRPC interface can be used to control a Headscale instance from a remote mac ### Behind a proxy -It's possible to run the gRPC remote endpoint behind a reverse proxy, like Nginx, and have it run on the _same_ port as Headscale. - -While this is _not a supported_ feature, an example on how this can be set up on -[NixOS is shown here](https://github.com/kradalby/dotfiles/blob/4489cdbb19cddfbfae82cd70448a38fde5a76711/machines/headscale.oracldn/headscale.nix#L61-L91). +The remote CLI uses the same HTTP API as everything else, so it works through the reverse proxy already in front of +Headscale with no extra setup. ### Troubleshooting - Make sure you have the _same_ Headscale version on your server and workstation. -- Ensure that connections to the gRPC port are allowed. - Verify that your TLS certificate is valid and trusted. - If you don't have access to a trusted certificate (e.g. from Let's Encrypt), either: - Add your self-signed certificate to the trust store of your OS _or_ diff --git a/docs/ref/integration/reverse-proxy.md b/docs/ref/integration/reverse-proxy.md index 723ef6342..2729786a5 100644 --- a/docs/ref/integration/reverse-proxy.md +++ b/docs/ref/integration/reverse-proxy.md @@ -69,7 +69,6 @@ on inbound requests with sanitized values. Headscale picks the first valid IP ad - A reverse proxy adds another layer of complexity that needs to be able to handle the [Tailscale Control Protocol](#websocket) properly. Be sure to test your setup without a reverse proxy before raising an issue. - STUN (used along with the [embedded DERP server](../derp.md)) requires udp/3478 to be served publicly. -- [gRPC](../api.md#grpc) (used to remote control Headscale) may not be proxied. ## Reverse proxy specific configuration @@ -84,14 +83,12 @@ is [assumed](../../setup/requirements.md): - Service for Tailscale clients is served via HTTPS on port 443. - The reverse proxy redirects HTTP to HTTPS and is terminating TLS. - Both Headscale and the reverse proxy are running on the same host. -- [Metrics](../debug.md#metrics-and-debug-endpoint) and [gRPC](../api.md#grpc) are not proxied, those are available via - localhost. +- [Metrics](../debug.md#metrics-and-debug-endpoint) are not proxied, those are available via localhost. ```yaml title="config.yaml" hl_lines="1" server_url: https:// listen_addr: 127.0.0.1:8080 metrics_listen_addr: 127.0.0.1:9090 -grpc_listen_addr: 127.0.0.1:50443 trusted_proxies: - 127.0.0.1/32 - ::1/128 diff --git a/docs/ref/integration/web-ui.md b/docs/ref/integration/web-ui.md index 8f00695a8..12aa6d373 100644 --- a/docs/ref/integration/web-ui.md +++ b/docs/ref/integration/web-ui.md @@ -23,5 +23,7 @@ Headscale doesn't provide a built-in web interface but users may pick one from t - [HeadControl](https://github.com/ahmadzip/HeadControl) - Minimal Headscale admin dashboard, built with Go and HTMX - [Headscale Manager](https://github.com/hkdone/headscalemanager) - Headscale UI for Android - [Headscale UI](https://github.com/MunMunMiao/headscale-ui) - Headscale UI online and Self-hosting +- [Headscale Panel](https://github.com/headscale-panel/panel) - A modern Headscale management panel with a clean, + network-operations-focused UI You can ask for support on our [Discord server](https://discord.gg/c84AZQhmpx) in the "web-interfaces" channel. diff --git a/docs/ref/policy.md b/docs/ref/policy.md index 3529a81ee..f326d7ecb 100644 --- a/docs/ref/policy.md +++ b/docs/ref/policy.md @@ -158,17 +158,17 @@ devices. Can only be used in policy destinations. { "src": ["boss@"], "dst": ["boss@"], - "ip": "*" + "ip": ["*"] }, { "src": ["dev1@"], "dst": ["dev1@"], - "ip": "*" + "ip": ["*"] }, { "src": ["intern1@"], "dst": ["intern1@"], - "ip": "*" + "ip": ["*"] } ] } diff --git a/docs/setup/requirements.md b/docs/setup/requirements.md index 6c11386c3..da84ae856 100644 --- a/docs/setup/requirements.md +++ b/docs/setup/requirements.md @@ -26,9 +26,6 @@ The ports in use vary with the intended scenario and enabled features. Some of t - udp/3478 - Expose publicly: yes - STUN, required if the [embedded DERP server](../ref/derp.md) is enabled -- tcp/50443 - - Expose publicly: yes - - Only required if the gRPC interface is used to [remote-control Headscale](../ref/api.md#grpc). - tcp/9090 - Expose publicly: no - [Metrics and debug endpoint](../ref/debug.md#metrics-and-debug-endpoint) diff --git a/docs/setup/upgrade.md b/docs/setup/upgrade.md index e12359cf3..8176e933a 100644 --- a/docs/setup/upgrade.md +++ b/docs/setup/upgrade.md @@ -2,7 +2,7 @@ !!! tip "Required update path" - Its required to update from one stable version to the next (e.g. 0.26.0 → 0.27.1 → 0.28.0) without skipping minor + It's required to update from one stable version to the next (e.g. 0.26.0 → 0.27.1 → 0.28.0) without skipping minor versions in between. You should always pick the latest available patch release. Update an existing Headscale installation to a new version: @@ -23,7 +23,7 @@ upgrading. A full backup of Headscale depends on your individual setup, but belo === "Standard installation" - A installation that follows our [official releases](install/official.md) setup guide uses the following paths: + An installation that follows our [official releases](install/official.md) setup guide uses the following paths: - [Configuration file](../ref/configuration.md): `/etc/headscale/config.yaml` - Data directory: `/var/lib/headscale` @@ -37,7 +37,7 @@ upgrading. A full backup of Headscale depends on your individual setup, but belo === "Container" - A installation that follows our [container](install/container.md) setup guide uses a single source volume directory + An installation that follows our [container](install/container.md) setup guide uses a single source volume directory that contains the configuration file, data directory and the SQLite database. ```console diff --git a/flake.lock b/flake.lock index 81b438068..c723d287a 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,27 @@ { "nodes": { + "flake-checks": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "nixpkgs" + ], + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1781951072, + "narHash": "sha256-hA9u6hB4QzpReP8hudxqiaa4vLhoRvtXi6YmUgJRGEs=", + "owner": "kradalby", + "repo": "flake-checks", + "rev": "3d2882efec5cf10f8b5a8a035d93ba6ba9f21717", + "type": "github" + }, + "original": { + "owner": "kradalby", + "repo": "flake-checks", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -18,6 +40,24 @@ "type": "github" } }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1781153106, @@ -36,7 +76,8 @@ }, "root": { "inputs": { - "flake-utils": "flake-utils", + "flake-checks": "flake-checks", + "flake-utils": "flake-utils_2", "nixpkgs": "nixpkgs" } }, @@ -54,6 +95,42 @@ "repo": "default", "type": "github" } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "flake-checks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1780220602, + "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "db947814a175b7ca6ded66e21383d938df01c227", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index b948dde39..96786def3 100644 --- a/flake.nix +++ b/flake.nix @@ -9,12 +9,17 @@ # once it ships go_1_26 >= 1.26.4. nixpkgs.url = "github:NixOS/nixpkgs/staging-next-26.05"; flake-utils.url = "github:numtide/flake-utils"; + # Reusable Go flake checks (build/test/lint/format); CI runs them via + # `nix build .#checks..` instead of bespoke per-tool steps. + flake-checks.url = "github:kradalby/flake-checks"; + flake-checks.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = { self , nixpkgs , flake-utils + , flake-checks , ... }: let @@ -66,40 +71,6 @@ subPackages = [ "cmd/hi" ]; }; - protoc-gen-grpc-gateway = buildGo rec { - pname = "grpc-gateway"; - version = "2.29.0"; - - src = pkgs.fetchFromGitHub { - owner = "grpc-ecosystem"; - repo = "grpc-gateway"; - rev = "v${version}"; - sha256 = "sha256-d9OIIGttyMBSNgpS6mbR5JEIm13qGu2gFHJazJAexdw="; - }; - - vendorHash = "sha256-p51yD+v8+rPs+ztlX7r0VQ4XlwUkxu+PxgknKEvH00k="; - - nativeBuildInputs = [ pkgs.installShellFiles ]; - - subPackages = [ "protoc-gen-grpc-gateway" "protoc-gen-openapiv2" ]; - }; - - protobuf-language-server = buildGo rec { - pname = "protobuf-language-server"; - version = "ab4c128"; - - src = pkgs.fetchFromGitHub { - owner = "lasorda"; - repo = "protobuf-language-server"; - rev = "ab4c128f00774d51bd6d1f4cfa735f4b7c8619e3"; - sha256 = "sha256-yF6kG+qTRxVO/qp2V9HgTyFBeOm5RQzeqdZFrdidwxM="; - }; - - vendorHash = "sha256-4nTpKBe7ekJsfQf+P6edT/9Vp2SBYbKz1ITawD3bhkI="; - - subPackages = [ "." ]; - }; - # Build golangci-lint with stock Go 1.26 (upstream uses hardcoded Go # version); it does not build against the pinned 1.26.4. golangci-lint = buildGo rec { @@ -186,6 +157,11 @@ yq-go ripgrep postgresql + + # External clients exercised by the Tailscale-compatible v2 API + # roundtrip tests (TestAPIv2). Binaries: tofu, tscli. + opentofu + tscli python314Packages.mdformat python314Packages.mdformat-footnote python314Packages.mdformat-frontmatter @@ -195,15 +171,6 @@ # 'dot' is needed for pprof graphs # go tool pprof -http=: graphviz - - # Protobuf dependencies - protobuf - protoc-gen-go - protoc-gen-go-grpc - protoc-gen-grpc-gateway - buf - clang-tools # clang-format - protobuf-language-server ] ++ lib.optionals pkgs.stdenv.isLinux [ traceroute ]; @@ -219,6 +186,59 @@ contents = [ pkgs.headscale ]; config.Entrypoint = [ (pkgs.headscale + "/bin/headscale") ]; }; + + # Go flake checks from the flake-checks library. CI gates on + # `nix build .#checks..`; the logic lives here, not in + # bespoke workflow steps. Linux-only: parts of the tree are + # Linux-specific and the pure unit subset is validated by CI. + fc = flake-checks.lib; + common = { + inherit pkgs; + root = ./.; + pname = "headscale"; + version = headscaleVersion; + vendorHash = (builtins.fromJSON (builtins.readFile ./flakehashes.json)).vendor.sri; + goPkg = pkgs.go_1_26; + # //go:embed targets and test-read files outside the default whitelist. + embedDirs = [ ./hscontrol/assets ./hscontrol/db/schema.sql ./config-example.yaml ]; + extraSrc = [ + ./hscontrol/testdata + ./hscontrol/types/testdata + ./hscontrol/db/testdata + ./hscontrol/policy/v2/testdata + ]; + }; + goChecks = { + build = fc.goBuild (common // { subPackages = [ "cmd/headscale" ]; }); + + # The pure unit subset. ./integration (Docker) and + # ./hscontrol/servertest (slow: 10s+ convergence plus race/stress/HA + # property tests — run by the servertest workflow instead) are dropped + # from the test set but kept in source so cmd/hi and friends still + # compile; TestPostgres* needs a server (the SQLite equivalents still + # run). CGO off matches the build. + gotest = fc.goTest (common // { + testExclude = [ "/integration" "/hscontrol/servertest" ]; + goSkip = [ "TestPostgres" ]; + testEnv = "export CGO_ENABLED=0"; + }); + + # Full-tree golangci-lint (golines, gofumpt, etc.); uses the overlay's + # golangci-lint built against the pinned Go. + golangci-lint = fc.goLint common; + + # nixpkgs-fmt + prettier, excluding generated output. goFmt = "off": + # Go formatting (golines, gofumpt) is enforced by the golangci-lint + # check, not treefmt. prettierExts matches the old prettier-lint glob + # (no json: testdata fixtures are hand-formatted). + formatting = fc.goFormat (common // { + goFmt = "off"; + prettier = true; + prettierExts = [ "ts" "js" "md" "yaml" "yml" "sass" "css" "scss" "html" ]; + # Mirror .prettierignore (docs/ are mkdocs-flavoured; gen/ generated). + fmtExclude = [ ./gen ./docs ]; + }); + }; in { # `nix develop` @@ -264,6 +284,9 @@ checks = { headscale = pkgs.testers.nixosTest (import ./nix/tests/headscale.nix); - }; + } + # The Go build/test checks are gated to Linux: parts of the tree are + # Linux-specific and the pure unit subset is validated by CI. + // pkgs.lib.optionalAttrs pkgs.stdenv.isLinux goChecks; }); } diff --git a/flakehashes.json b/flakehashes.json index 37888e992..ea971a38e 100644 --- a/flakehashes.json +++ b/flakehashes.json @@ -1,6 +1,6 @@ { "vendor": { - "goModSum": "sha256-csVm5v6HZ49PBp/FCX+yK1sjV8/nuUQz3GKN21Ne1mg=", - "sri": "sha256-fzKyXNMw/2yAEhaTZu0n1NXatPO2IP0HFA2ey1vZIYM=" + "goModSum": "sha256-SJml8RXGmb2p0g1nOsHn86FA1hwgd5ZffLSkUj5zek8=", + "sri": "sha256-pjGNuVtgFFzWNq/2cK7a4iyF13AfcHz098nk92a9Ido=" } } diff --git a/gen/client/v1/client.gen.go b/gen/client/v1/client.gen.go new file mode 100644 index 000000000..f2b120a46 --- /dev/null +++ b/gen/client/v1/client.gen.go @@ -0,0 +1,4728 @@ +// Package clientv1 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT. +package clientv1 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/oapi-codegen/runtime" +) + +const ( + BearerScopes bearerContextKey = "bearer.Scopes" +) + +// Defines values for NodeRegisterMethod. +const ( + REGISTERMETHODAUTHKEY NodeRegisterMethod = "REGISTER_METHOD_AUTH_KEY" + REGISTERMETHODCLI NodeRegisterMethod = "REGISTER_METHOD_CLI" + REGISTERMETHODOIDC NodeRegisterMethod = "REGISTER_METHOD_OIDC" + REGISTERMETHODUNSPECIFIED NodeRegisterMethod = "REGISTER_METHOD_UNSPECIFIED" +) + +// Valid indicates whether the value is a known member of the NodeRegisterMethod enum. +func (e NodeRegisterMethod) Valid() bool { + switch e { + case REGISTERMETHODAUTHKEY: + return true + case REGISTERMETHODCLI: + return true + case REGISTERMETHODOIDC: + return true + case REGISTERMETHODUNSPECIFIED: + return true + default: + return false + } +} + +// ApiKey defines model for ApiKey. +type ApiKey struct { + CreatedAt *time.Time `json:"createdAt"` + Expiration *time.Time `json:"expiration"` + Id string `json:"id"` + LastSeen *time.Time `json:"lastSeen"` + Prefix string `json:"prefix"` +} + +// AuthApproveOutputBody defines model for AuthApproveOutputBody. +type AuthApproveOutputBody = map[string]interface{} + +// AuthApproveRequestBody defines model for AuthApproveRequestBody. +type AuthApproveRequestBody struct { + AuthId *string `json:"authId,omitempty"` +} + +// AuthRegisterOutputBody defines model for AuthRegisterOutputBody. +type AuthRegisterOutputBody struct { + Node Node `json:"node"` +} + +// AuthRegisterRequestBody defines model for AuthRegisterRequestBody. +type AuthRegisterRequestBody struct { + AuthId *string `json:"authId,omitempty"` + User *string `json:"user,omitempty"` +} + +// AuthRejectOutputBody defines model for AuthRejectOutputBody. +type AuthRejectOutputBody = map[string]interface{} + +// AuthRejectRequestBody defines model for AuthRejectRequestBody. +type AuthRejectRequestBody struct { + AuthId *string `json:"authId,omitempty"` +} + +// BackfillNodeIPsOutputBody defines model for BackfillNodeIPsOutputBody. +type BackfillNodeIPsOutputBody struct { + Changes []string `json:"changes"` +} + +// CheckPolicyOutputBody defines model for CheckPolicyOutputBody. +type CheckPolicyOutputBody = map[string]interface{} + +// CreateApiKeyOutputBody defines model for CreateApiKeyOutputBody. +type CreateApiKeyOutputBody struct { + ApiKey string `json:"apiKey"` +} + +// CreateApiKeyRequestBody defines model for CreateApiKeyRequestBody. +type CreateApiKeyRequestBody struct { + Expiration *time.Time `json:"expiration,omitempty"` +} + +// CreatePreAuthKeyRequestBody defines model for CreatePreAuthKeyRequestBody. +type CreatePreAuthKeyRequestBody struct { + AclTags *[]string `json:"aclTags,omitempty"` + Ephemeral *bool `json:"ephemeral,omitempty"` + Expiration *time.Time `json:"expiration,omitempty"` + Reusable *bool `json:"reusable,omitempty"` + User *string `json:"user,omitempty"` +} + +// CreateUserRequestBody defines model for CreateUserRequestBody. +type CreateUserRequestBody struct { + DisplayName *string `json:"displayName,omitempty"` + Email *string `json:"email,omitempty"` + Name *string `json:"name,omitempty"` + PictureUrl *string `json:"pictureUrl,omitempty"` +} + +// DebugCreateNodeRequestBody defines model for DebugCreateNodeRequestBody. +type DebugCreateNodeRequestBody struct { + Key *string `json:"key,omitempty"` + Name *string `json:"name,omitempty"` + Routes *[]string `json:"routes,omitempty"` + User *string `json:"user,omitempty"` +} + +// DeleteApiKeyOutputBody defines model for DeleteApiKeyOutputBody. +type DeleteApiKeyOutputBody = map[string]interface{} + +// DeleteNodeOutputBody defines model for DeleteNodeOutputBody. +type DeleteNodeOutputBody = map[string]interface{} + +// DeletePreAuthKeyOutputBody defines model for DeletePreAuthKeyOutputBody. +type DeletePreAuthKeyOutputBody = map[string]interface{} + +// DeleteUserOutputBody defines model for DeleteUserOutputBody. +type DeleteUserOutputBody = map[string]interface{} + +// ErrorDetail defines model for ErrorDetail. +type ErrorDetail struct { + // Location Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id' + Location *string `json:"location,omitempty"` + + // Message Error message text + Message *string `json:"message,omitempty"` + + // Value The value at the given location + Value interface{} `json:"value,omitempty"` +} + +// ErrorModel defines model for ErrorModel. +type ErrorModel struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail *string `json:"detail,omitempty"` + + // Errors Optional list of individual error details + Errors *[]ErrorDetail `json:"errors,omitempty"` + + // Instance A URI reference that identifies the specific occurrence of the problem. + Instance *string `json:"instance,omitempty"` + + // Status HTTP status code + Status *int64 `json:"status,omitempty"` + + // Title A short, human-readable summary of the problem type. This value should not change between occurrences of the error. + Title *string `json:"title,omitempty"` + + // Type A URI reference to human-readable documentation for the error. + Type *string `json:"type,omitempty"` +} + +// ExpireApiKeyOutputBody defines model for ExpireApiKeyOutputBody. +type ExpireApiKeyOutputBody = map[string]interface{} + +// ExpireApiKeyRequestBody defines model for ExpireApiKeyRequestBody. +type ExpireApiKeyRequestBody struct { + Id *string `json:"id,omitempty"` + Prefix *string `json:"prefix,omitempty"` +} + +// ExpireNodeRequestBody defines model for ExpireNodeRequestBody. +type ExpireNodeRequestBody struct { + DisableExpiry *bool `json:"disableExpiry,omitempty"` + Expiry *time.Time `json:"expiry,omitempty"` +} + +// ExpirePreAuthKeyOutputBody defines model for ExpirePreAuthKeyOutputBody. +type ExpirePreAuthKeyOutputBody = map[string]interface{} + +// ExpirePreAuthKeyRequestBody defines model for ExpirePreAuthKeyRequestBody. +type ExpirePreAuthKeyRequestBody struct { + Id *string `json:"id,omitempty"` +} + +// HealthResponseBody defines model for HealthResponseBody. +type HealthResponseBody struct { + DatabaseConnectivity bool `json:"databaseConnectivity"` +} + +// ListApiKeysOutputBody defines model for ListApiKeysOutputBody. +type ListApiKeysOutputBody struct { + ApiKeys []ApiKey `json:"apiKeys"` +} + +// ListNodesOutputBody defines model for ListNodesOutputBody. +type ListNodesOutputBody struct { + Nodes []Node `json:"nodes"` +} + +// ListPreAuthKeysOutputBody defines model for ListPreAuthKeysOutputBody. +type ListPreAuthKeysOutputBody struct { + PreAuthKeys []PreAuthKey `json:"preAuthKeys"` +} + +// ListUsersOutputBody defines model for ListUsersOutputBody. +type ListUsersOutputBody struct { + Users []User `json:"users"` +} + +// Node defines model for Node. +type Node struct { + ApprovedRoutes []string `json:"approvedRoutes"` + AvailableRoutes []string `json:"availableRoutes"` + CreatedAt time.Time `json:"createdAt"` + DiscoKey string `json:"discoKey"` + Expiry *time.Time `json:"expiry"` + GivenName string `json:"givenName"` + Id string `json:"id"` + IpAddresses []string `json:"ipAddresses"` + LastSeen *time.Time `json:"lastSeen"` + MachineKey string `json:"machineKey"` + Name string `json:"name"` + NodeKey string `json:"nodeKey"` + Online bool `json:"online"` + PreAuthKey NodePreAuthKey `json:"preAuthKey"` + RegisterMethod NodeRegisterMethod `json:"registerMethod"` + SubnetRoutes []string `json:"subnetRoutes"` + Tags []string `json:"tags"` + User User `json:"user"` +} + +// NodeRegisterMethod defines model for Node.RegisterMethod. +type NodeRegisterMethod string + +// NodeOutputBody defines model for NodeOutputBody. +type NodeOutputBody struct { + Node Node `json:"node"` +} + +// NodePreAuthKey defines model for NodePreAuthKey. +type NodePreAuthKey struct { + AclTags []string `json:"aclTags"` + CreatedAt *time.Time `json:"createdAt"` + Ephemeral bool `json:"ephemeral"` + Expiration *time.Time `json:"expiration"` + Id string `json:"id"` + Key string `json:"key"` + Reusable bool `json:"reusable"` + Used bool `json:"used"` + User User `json:"user"` +} + +// PolicyRequestBody defines model for PolicyRequestBody. +type PolicyRequestBody struct { + Policy *string `json:"policy,omitempty"` +} + +// PolicyResponseBody defines model for PolicyResponseBody. +type PolicyResponseBody struct { + Policy string `json:"policy"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// PreAuthKey defines model for PreAuthKey. +type PreAuthKey struct { + AclTags []string `json:"aclTags"` + CreatedAt time.Time `json:"createdAt"` + Ephemeral bool `json:"ephemeral"` + Expiration time.Time `json:"expiration"` + Id string `json:"id"` + Key string `json:"key"` + Reusable bool `json:"reusable"` + Used bool `json:"used"` + User User `json:"user"` +} + +// PreAuthKeyOutputBody defines model for PreAuthKeyOutputBody. +type PreAuthKeyOutputBody struct { + PreAuthKey PreAuthKey `json:"preAuthKey"` +} + +// SetApprovedRoutesRequestBody defines model for SetApprovedRoutesRequestBody. +type SetApprovedRoutesRequestBody struct { + Routes *[]string `json:"routes,omitempty"` +} + +// SetTagsRequestBody defines model for SetTagsRequestBody. +type SetTagsRequestBody struct { + Tags *[]string `json:"tags,omitempty"` +} + +// User defines model for User. +type User struct { + CreatedAt time.Time `json:"createdAt"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Id string `json:"id"` + Name string `json:"name"` + ProfilePicUrl string `json:"profilePicUrl"` + Provider string `json:"provider"` + ProviderId string `json:"providerId"` +} + +// UserOutputBody defines model for UserOutputBody. +type UserOutputBody struct { + User User `json:"user"` +} + +// bearerContextKey is the context key for bearer security scheme +type bearerContextKey string + +// DeleteApiKeyParams defines parameters for DeleteApiKey. +type DeleteApiKeyParams struct { + Id *string `form:"id,omitempty" json:"id,omitempty"` +} + +// ListNodesParams defines parameters for ListNodes. +type ListNodesParams struct { + User *string `form:"user,omitempty" json:"user,omitempty"` +} + +// BackfillNodeIPsParams defines parameters for BackfillNodeIPs. +type BackfillNodeIPsParams struct { + Confirmed *bool `form:"confirmed,omitempty" json:"confirmed,omitempty"` +} + +// RegisterNodeParams defines parameters for RegisterNode. +type RegisterNodeParams struct { + User *string `form:"user,omitempty" json:"user,omitempty"` + Key *string `form:"key,omitempty" json:"key,omitempty"` +} + +// DeletePreAuthKeyParams defines parameters for DeletePreAuthKey. +type DeletePreAuthKeyParams struct { + Id *string `form:"id,omitempty" json:"id,omitempty"` +} + +// ListUsersParams defines parameters for ListUsers. +type ListUsersParams struct { + Id *string `form:"id,omitempty" json:"id,omitempty"` + Name *string `form:"name,omitempty" json:"name,omitempty"` + Email *string `form:"email,omitempty" json:"email,omitempty"` +} + +// CreateApiKeyJSONRequestBody defines body for CreateApiKey for application/json ContentType. +type CreateApiKeyJSONRequestBody = CreateApiKeyRequestBody + +// ExpireApiKeyJSONRequestBody defines body for ExpireApiKey for application/json ContentType. +type ExpireApiKeyJSONRequestBody = ExpireApiKeyRequestBody + +// AuthApproveJSONRequestBody defines body for AuthApprove for application/json ContentType. +type AuthApproveJSONRequestBody = AuthApproveRequestBody + +// AuthRegisterJSONRequestBody defines body for AuthRegister for application/json ContentType. +type AuthRegisterJSONRequestBody = AuthRegisterRequestBody + +// AuthRejectJSONRequestBody defines body for AuthReject for application/json ContentType. +type AuthRejectJSONRequestBody = AuthRejectRequestBody + +// DebugCreateNodeJSONRequestBody defines body for DebugCreateNode for application/json ContentType. +type DebugCreateNodeJSONRequestBody = DebugCreateNodeRequestBody + +// SetApprovedRoutesJSONRequestBody defines body for SetApprovedRoutes for application/json ContentType. +type SetApprovedRoutesJSONRequestBody = SetApprovedRoutesRequestBody + +// ExpireNodeJSONRequestBody defines body for ExpireNode for application/json ContentType. +type ExpireNodeJSONRequestBody = ExpireNodeRequestBody + +// SetTagsJSONRequestBody defines body for SetTags for application/json ContentType. +type SetTagsJSONRequestBody = SetTagsRequestBody + +// SetPolicyJSONRequestBody defines body for SetPolicy for application/json ContentType. +type SetPolicyJSONRequestBody = PolicyRequestBody + +// CheckPolicyJSONRequestBody defines body for CheckPolicy for application/json ContentType. +type CheckPolicyJSONRequestBody = PolicyRequestBody + +// CreatePreAuthKeyJSONRequestBody defines body for CreatePreAuthKey for application/json ContentType. +type CreatePreAuthKeyJSONRequestBody = CreatePreAuthKeyRequestBody + +// ExpirePreAuthKeyJSONRequestBody defines body for ExpirePreAuthKey for application/json ContentType. +type ExpirePreAuthKeyJSONRequestBody = ExpirePreAuthKeyRequestBody + +// CreateUserJSONRequestBody defines body for CreateUser for application/json ContentType. +type CreateUserJSONRequestBody = CreateUserRequestBody + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // ListApiKeys request + ListApiKeys(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CreateApiKeyWithBody request with any body + CreateApiKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreateApiKey(ctx context.Context, body CreateApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ExpireApiKeyWithBody request with any body + ExpireApiKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ExpireApiKey(ctx context.Context, body ExpireApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteApiKey request + DeleteApiKey(ctx context.Context, prefix string, params *DeleteApiKeyParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // AuthApproveWithBody request with any body + AuthApproveWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + AuthApprove(ctx context.Context, body AuthApproveJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // AuthRegisterWithBody request with any body + AuthRegisterWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + AuthRegister(ctx context.Context, body AuthRegisterJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // AuthRejectWithBody request with any body + AuthRejectWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + AuthReject(ctx context.Context, body AuthRejectJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DebugCreateNodeWithBody request with any body + DebugCreateNodeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + DebugCreateNode(ctx context.Context, body DebugCreateNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // Health request + Health(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListNodes request + ListNodes(ctx context.Context, params *ListNodesParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // BackfillNodeIPs request + BackfillNodeIPs(ctx context.Context, params *BackfillNodeIPsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RegisterNode request + RegisterNode(ctx context.Context, params *RegisterNodeParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteNode request + DeleteNode(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetNode request + GetNode(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetApprovedRoutesWithBody request with any body + SetApprovedRoutesWithBody(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetApprovedRoutes(ctx context.Context, nodeId string, body SetApprovedRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ExpireNodeWithBody request with any body + ExpireNodeWithBody(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ExpireNode(ctx context.Context, nodeId string, body ExpireNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RenameNode request + RenameNode(ctx context.Context, nodeId string, newName string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetTagsWithBody request with any body + SetTagsWithBody(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetTags(ctx context.Context, nodeId string, body SetTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetPolicy request + GetPolicy(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetPolicyWithBody request with any body + SetPolicyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetPolicy(ctx context.Context, body SetPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CheckPolicyWithBody request with any body + CheckPolicyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CheckPolicy(ctx context.Context, body CheckPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeletePreAuthKey request + DeletePreAuthKey(ctx context.Context, params *DeletePreAuthKeyParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListPreAuthKeys request + ListPreAuthKeys(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CreatePreAuthKeyWithBody request with any body + CreatePreAuthKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreatePreAuthKey(ctx context.Context, body CreatePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ExpirePreAuthKeyWithBody request with any body + ExpirePreAuthKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ExpirePreAuthKey(ctx context.Context, body ExpirePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListUsers request + ListUsers(ctx context.Context, params *ListUsersParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CreateUserWithBody request with any body + CreateUserWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreateUser(ctx context.Context, body CreateUserJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteUser request + DeleteUser(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RenameUser request + RenameUser(ctx context.Context, oldId string, newName string, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) ListApiKeys(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListApiKeysRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateApiKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateApiKeyRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateApiKey(ctx context.Context, body CreateApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateApiKeyRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ExpireApiKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExpireApiKeyRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ExpireApiKey(ctx context.Context, body ExpireApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExpireApiKeyRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteApiKey(ctx context.Context, prefix string, params *DeleteApiKeyParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteApiKeyRequest(c.Server, prefix, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthApproveWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthApproveRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthApprove(ctx context.Context, body AuthApproveJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthApproveRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthRegisterWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthRegisterRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthRegister(ctx context.Context, body AuthRegisterJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthRegisterRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthRejectWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthRejectRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthReject(ctx context.Context, body AuthRejectJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthRejectRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DebugCreateNodeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDebugCreateNodeRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DebugCreateNode(ctx context.Context, body DebugCreateNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDebugCreateNodeRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) Health(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewHealthRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListNodes(ctx context.Context, params *ListNodesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListNodesRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) BackfillNodeIPs(ctx context.Context, params *BackfillNodeIPsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewBackfillNodeIPsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RegisterNode(ctx context.Context, params *RegisterNodeParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRegisterNodeRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteNode(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteNodeRequest(c.Server, nodeId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetNode(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeRequest(c.Server, nodeId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetApprovedRoutesWithBody(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetApprovedRoutesRequestWithBody(c.Server, nodeId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetApprovedRoutes(ctx context.Context, nodeId string, body SetApprovedRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetApprovedRoutesRequest(c.Server, nodeId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ExpireNodeWithBody(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExpireNodeRequestWithBody(c.Server, nodeId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ExpireNode(ctx context.Context, nodeId string, body ExpireNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExpireNodeRequest(c.Server, nodeId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RenameNode(ctx context.Context, nodeId string, newName string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRenameNodeRequest(c.Server, nodeId, newName) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetTagsWithBody(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetTagsRequestWithBody(c.Server, nodeId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetTags(ctx context.Context, nodeId string, body SetTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetTagsRequest(c.Server, nodeId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetPolicy(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetPolicyRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetPolicyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetPolicyRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetPolicy(ctx context.Context, body SetPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetPolicyRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CheckPolicyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCheckPolicyRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CheckPolicy(ctx context.Context, body CheckPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCheckPolicyRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeletePreAuthKey(ctx context.Context, params *DeletePreAuthKeyParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeletePreAuthKeyRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListPreAuthKeys(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListPreAuthKeysRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreatePreAuthKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreatePreAuthKeyRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreatePreAuthKey(ctx context.Context, body CreatePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreatePreAuthKeyRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ExpirePreAuthKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExpirePreAuthKeyRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ExpirePreAuthKey(ctx context.Context, body ExpirePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExpirePreAuthKeyRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListUsers(ctx context.Context, params *ListUsersParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListUsersRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateUserWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateUserRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateUser(ctx context.Context, body CreateUserJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateUserRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteUser(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteUserRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RenameUser(ctx context.Context, oldId string, newName string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRenameUserRequest(c.Server, oldId, newName) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewListApiKeysRequest generates requests for ListApiKeys +func NewListApiKeysRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/apikey") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewCreateApiKeyRequest calls the generic CreateApiKey builder with application/json body +func NewCreateApiKeyRequest(server string, body CreateApiKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreateApiKeyRequestWithBody(server, "application/json", bodyReader) +} + +// NewCreateApiKeyRequestWithBody generates requests for CreateApiKey with any type of body +func NewCreateApiKeyRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/apikey") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewExpireApiKeyRequest calls the generic ExpireApiKey builder with application/json body +func NewExpireApiKeyRequest(server string, body ExpireApiKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewExpireApiKeyRequestWithBody(server, "application/json", bodyReader) +} + +// NewExpireApiKeyRequestWithBody generates requests for ExpireApiKey with any type of body +func NewExpireApiKeyRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/apikey/expire") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteApiKeyRequest generates requests for DeleteApiKey +func NewDeleteApiKeyRequest(server string, prefix string, params *DeleteApiKeyParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "prefix", prefix, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/apikey/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Id != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "id", *params.Id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "uint64"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewAuthApproveRequest calls the generic AuthApprove builder with application/json body +func NewAuthApproveRequest(server string, body AuthApproveJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewAuthApproveRequestWithBody(server, "application/json", bodyReader) +} + +// NewAuthApproveRequestWithBody generates requests for AuthApprove with any type of body +func NewAuthApproveRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/auth/approve") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewAuthRegisterRequest calls the generic AuthRegister builder with application/json body +func NewAuthRegisterRequest(server string, body AuthRegisterJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewAuthRegisterRequestWithBody(server, "application/json", bodyReader) +} + +// NewAuthRegisterRequestWithBody generates requests for AuthRegister with any type of body +func NewAuthRegisterRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/auth/register") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewAuthRejectRequest calls the generic AuthReject builder with application/json body +func NewAuthRejectRequest(server string, body AuthRejectJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewAuthRejectRequestWithBody(server, "application/json", bodyReader) +} + +// NewAuthRejectRequestWithBody generates requests for AuthReject with any type of body +func NewAuthRejectRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/auth/reject") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDebugCreateNodeRequest calls the generic DebugCreateNode builder with application/json body +func NewDebugCreateNodeRequest(server string, body DebugCreateNodeJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewDebugCreateNodeRequestWithBody(server, "application/json", bodyReader) +} + +// NewDebugCreateNodeRequestWithBody generates requests for DebugCreateNode with any type of body +func NewDebugCreateNodeRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/debug/node") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewHealthRequest generates requests for Health +func NewHealthRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/health") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewListNodesRequest generates requests for ListNodes +func NewListNodesRequest(server string, params *ListNodesParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.User != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "user", *params.User, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewBackfillNodeIPsRequest generates requests for BackfillNodeIPs +func NewBackfillNodeIPsRequest(server string, params *BackfillNodeIPsParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/backfillips") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Confirmed != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "confirmed", *params.Confirmed, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewRegisterNodeRequest generates requests for RegisterNode +func NewRegisterNodeRequest(server string, params *RegisterNodeParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/register") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.User != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "user", *params.User, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Key != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "key", *params.Key, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewDeleteNodeRequest generates requests for DeleteNode +func NewDeleteNodeRequest(server string, nodeId string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "nodeId", nodeId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetNodeRequest generates requests for GetNode +func NewGetNodeRequest(server string, nodeId string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "nodeId", nodeId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewSetApprovedRoutesRequest calls the generic SetApprovedRoutes builder with application/json body +func NewSetApprovedRoutesRequest(server string, nodeId string, body SetApprovedRoutesJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetApprovedRoutesRequestWithBody(server, nodeId, "application/json", bodyReader) +} + +// NewSetApprovedRoutesRequestWithBody generates requests for SetApprovedRoutes with any type of body +func NewSetApprovedRoutesRequestWithBody(server string, nodeId string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "nodeId", nodeId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/%s/approve_routes", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewExpireNodeRequest calls the generic ExpireNode builder with application/json body +func NewExpireNodeRequest(server string, nodeId string, body ExpireNodeJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewExpireNodeRequestWithBody(server, nodeId, "application/json", bodyReader) +} + +// NewExpireNodeRequestWithBody generates requests for ExpireNode with any type of body +func NewExpireNodeRequestWithBody(server string, nodeId string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "nodeId", nodeId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/%s/expire", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewRenameNodeRequest generates requests for RenameNode +func NewRenameNodeRequest(server string, nodeId string, newName string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "nodeId", nodeId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "newName", newName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/%s/rename/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewSetTagsRequest calls the generic SetTags builder with application/json body +func NewSetTagsRequest(server string, nodeId string, body SetTagsJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetTagsRequestWithBody(server, nodeId, "application/json", bodyReader) +} + +// NewSetTagsRequestWithBody generates requests for SetTags with any type of body +func NewSetTagsRequestWithBody(server string, nodeId string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "nodeId", nodeId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/%s/tags", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetPolicyRequest generates requests for GetPolicy +func NewGetPolicyRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/policy") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewSetPolicyRequest calls the generic SetPolicy builder with application/json body +func NewSetPolicyRequest(server string, body SetPolicyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetPolicyRequestWithBody(server, "application/json", bodyReader) +} + +// NewSetPolicyRequestWithBody generates requests for SetPolicy with any type of body +func NewSetPolicyRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/policy") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewCheckPolicyRequest calls the generic CheckPolicy builder with application/json body +func NewCheckPolicyRequest(server string, body CheckPolicyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCheckPolicyRequestWithBody(server, "application/json", bodyReader) +} + +// NewCheckPolicyRequestWithBody generates requests for CheckPolicy with any type of body +func NewCheckPolicyRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/policy/check") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeletePreAuthKeyRequest generates requests for DeletePreAuthKey +func NewDeletePreAuthKeyRequest(server string, params *DeletePreAuthKeyParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/preauthkey") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Id != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "id", *params.Id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "uint64"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewListPreAuthKeysRequest generates requests for ListPreAuthKeys +func NewListPreAuthKeysRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/preauthkey") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewCreatePreAuthKeyRequest calls the generic CreatePreAuthKey builder with application/json body +func NewCreatePreAuthKeyRequest(server string, body CreatePreAuthKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreatePreAuthKeyRequestWithBody(server, "application/json", bodyReader) +} + +// NewCreatePreAuthKeyRequestWithBody generates requests for CreatePreAuthKey with any type of body +func NewCreatePreAuthKeyRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/preauthkey") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewExpirePreAuthKeyRequest calls the generic ExpirePreAuthKey builder with application/json body +func NewExpirePreAuthKeyRequest(server string, body ExpirePreAuthKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewExpirePreAuthKeyRequestWithBody(server, "application/json", bodyReader) +} + +// NewExpirePreAuthKeyRequestWithBody generates requests for ExpirePreAuthKey with any type of body +func NewExpirePreAuthKeyRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/preauthkey/expire") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewListUsersRequest generates requests for ListUsers +func NewListUsersRequest(server string, params *ListUsersParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/user") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Id != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "id", *params.Id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "uint64"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Name != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "name", *params.Name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Email != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "email", *params.Email, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewCreateUserRequest calls the generic CreateUser builder with application/json body +func NewCreateUserRequest(server string, body CreateUserJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreateUserRequestWithBody(server, "application/json", bodyReader) +} + +// NewCreateUserRequestWithBody generates requests for CreateUser with any type of body +func NewCreateUserRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/user") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteUserRequest generates requests for DeleteUser +func NewDeleteUserRequest(server string, id string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/user/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewRenameUserRequest generates requests for RenameUser +func NewRenameUserRequest(server string, oldId string, newName string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "oldId", oldId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "newName", newName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/user/%s/rename/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // ListApiKeysWithResponse request + ListApiKeysWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListApiKeysResponse, error) + + // CreateApiKeyWithBodyWithResponse request with any body + CreateApiKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateApiKeyResponse, error) + + CreateApiKeyWithResponse(ctx context.Context, body CreateApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateApiKeyResponse, error) + + // ExpireApiKeyWithBodyWithResponse request with any body + ExpireApiKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ExpireApiKeyResponse, error) + + ExpireApiKeyWithResponse(ctx context.Context, body ExpireApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*ExpireApiKeyResponse, error) + + // DeleteApiKeyWithResponse request + DeleteApiKeyWithResponse(ctx context.Context, prefix string, params *DeleteApiKeyParams, reqEditors ...RequestEditorFn) (*DeleteApiKeyResponse, error) + + // AuthApproveWithBodyWithResponse request with any body + AuthApproveWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthApproveResponse, error) + + AuthApproveWithResponse(ctx context.Context, body AuthApproveJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthApproveResponse, error) + + // AuthRegisterWithBodyWithResponse request with any body + AuthRegisterWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthRegisterResponse, error) + + AuthRegisterWithResponse(ctx context.Context, body AuthRegisterJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthRegisterResponse, error) + + // AuthRejectWithBodyWithResponse request with any body + AuthRejectWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthRejectResponse, error) + + AuthRejectWithResponse(ctx context.Context, body AuthRejectJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthRejectResponse, error) + + // DebugCreateNodeWithBodyWithResponse request with any body + DebugCreateNodeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DebugCreateNodeResponse, error) + + DebugCreateNodeWithResponse(ctx context.Context, body DebugCreateNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*DebugCreateNodeResponse, error) + + // HealthWithResponse request + HealthWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*HealthResponse, error) + + // ListNodesWithResponse request + ListNodesWithResponse(ctx context.Context, params *ListNodesParams, reqEditors ...RequestEditorFn) (*ListNodesResponse, error) + + // BackfillNodeIPsWithResponse request + BackfillNodeIPsWithResponse(ctx context.Context, params *BackfillNodeIPsParams, reqEditors ...RequestEditorFn) (*BackfillNodeIPsResponse, error) + + // RegisterNodeWithResponse request + RegisterNodeWithResponse(ctx context.Context, params *RegisterNodeParams, reqEditors ...RequestEditorFn) (*RegisterNodeResponse, error) + + // DeleteNodeWithResponse request + DeleteNodeWithResponse(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*DeleteNodeResponse, error) + + // GetNodeWithResponse request + GetNodeWithResponse(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*GetNodeResponse, error) + + // SetApprovedRoutesWithBodyWithResponse request with any body + SetApprovedRoutesWithBodyWithResponse(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetApprovedRoutesResponse, error) + + SetApprovedRoutesWithResponse(ctx context.Context, nodeId string, body SetApprovedRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*SetApprovedRoutesResponse, error) + + // ExpireNodeWithBodyWithResponse request with any body + ExpireNodeWithBodyWithResponse(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ExpireNodeResponse, error) + + ExpireNodeWithResponse(ctx context.Context, nodeId string, body ExpireNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*ExpireNodeResponse, error) + + // RenameNodeWithResponse request + RenameNodeWithResponse(ctx context.Context, nodeId string, newName string, reqEditors ...RequestEditorFn) (*RenameNodeResponse, error) + + // SetTagsWithBodyWithResponse request with any body + SetTagsWithBodyWithResponse(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetTagsResponse, error) + + SetTagsWithResponse(ctx context.Context, nodeId string, body SetTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*SetTagsResponse, error) + + // GetPolicyWithResponse request + GetPolicyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetPolicyResponse, error) + + // SetPolicyWithBodyWithResponse request with any body + SetPolicyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetPolicyResponse, error) + + SetPolicyWithResponse(ctx context.Context, body SetPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*SetPolicyResponse, error) + + // CheckPolicyWithBodyWithResponse request with any body + CheckPolicyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CheckPolicyResponse, error) + + CheckPolicyWithResponse(ctx context.Context, body CheckPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*CheckPolicyResponse, error) + + // DeletePreAuthKeyWithResponse request + DeletePreAuthKeyWithResponse(ctx context.Context, params *DeletePreAuthKeyParams, reqEditors ...RequestEditorFn) (*DeletePreAuthKeyResponse, error) + + // ListPreAuthKeysWithResponse request + ListPreAuthKeysWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListPreAuthKeysResponse, error) + + // CreatePreAuthKeyWithBodyWithResponse request with any body + CreatePreAuthKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreatePreAuthKeyResponse, error) + + CreatePreAuthKeyWithResponse(ctx context.Context, body CreatePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*CreatePreAuthKeyResponse, error) + + // ExpirePreAuthKeyWithBodyWithResponse request with any body + ExpirePreAuthKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ExpirePreAuthKeyResponse, error) + + ExpirePreAuthKeyWithResponse(ctx context.Context, body ExpirePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*ExpirePreAuthKeyResponse, error) + + // ListUsersWithResponse request + ListUsersWithResponse(ctx context.Context, params *ListUsersParams, reqEditors ...RequestEditorFn) (*ListUsersResponse, error) + + // CreateUserWithBodyWithResponse request with any body + CreateUserWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateUserResponse, error) + + CreateUserWithResponse(ctx context.Context, body CreateUserJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateUserResponse, error) + + // DeleteUserWithResponse request + DeleteUserWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*DeleteUserResponse, error) + + // RenameUserWithResponse request + RenameUserWithResponse(ctx context.Context, oldId string, newName string, reqEditors ...RequestEditorFn) (*RenameUserResponse, error) +} + +type ListApiKeysResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListApiKeysOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ListApiKeysResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListApiKeysResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListApiKeysResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type CreateApiKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *CreateApiKeyOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r CreateApiKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateApiKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r CreateApiKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ExpireApiKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ExpireApiKeyOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ExpireApiKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ExpireApiKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ExpireApiKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DeleteApiKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeleteApiKeyOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r DeleteApiKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteApiKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeleteApiKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type AuthApproveResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AuthApproveOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r AuthApproveResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r AuthApproveResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r AuthApproveResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type AuthRegisterResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AuthRegisterOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r AuthRegisterResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r AuthRegisterResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r AuthRegisterResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type AuthRejectResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AuthRejectOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r AuthRejectResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r AuthRejectResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r AuthRejectResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DebugCreateNodeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r DebugCreateNodeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DebugCreateNodeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DebugCreateNodeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type HealthResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *HealthResponseBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r HealthResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r HealthResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r HealthResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ListNodesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListNodesOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ListNodesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListNodesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListNodesResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type BackfillNodeIPsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *BackfillNodeIPsOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r BackfillNodeIPsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r BackfillNodeIPsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r BackfillNodeIPsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type RegisterNodeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r RegisterNodeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RegisterNodeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RegisterNodeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DeleteNodeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeleteNodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r DeleteNodeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteNodeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeleteNodeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetNodeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetNodeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetNodeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetApprovedRoutesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetApprovedRoutesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetApprovedRoutesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetApprovedRoutesResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ExpireNodeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ExpireNodeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ExpireNodeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ExpireNodeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type RenameNodeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r RenameNodeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RenameNodeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RenameNodeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetTagsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetTagsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetTagsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetTagsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetPolicyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *PolicyResponseBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetPolicyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetPolicyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetPolicyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetPolicyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *PolicyResponseBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetPolicyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetPolicyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetPolicyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type CheckPolicyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *CheckPolicyOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r CheckPolicyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CheckPolicyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r CheckPolicyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DeletePreAuthKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeletePreAuthKeyOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r DeletePreAuthKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeletePreAuthKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeletePreAuthKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ListPreAuthKeysResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListPreAuthKeysOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ListPreAuthKeysResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListPreAuthKeysResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListPreAuthKeysResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type CreatePreAuthKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *PreAuthKeyOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r CreatePreAuthKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreatePreAuthKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r CreatePreAuthKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ExpirePreAuthKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ExpirePreAuthKeyOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ExpirePreAuthKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ExpirePreAuthKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ExpirePreAuthKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ListUsersResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListUsersOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ListUsersResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListUsersResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListUsersResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type CreateUserResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *UserOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r CreateUserResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateUserResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r CreateUserResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DeleteUserResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeleteUserOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r DeleteUserResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteUserResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeleteUserResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type RenameUserResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *UserOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r RenameUserResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RenameUserResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RenameUserResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// ListApiKeysWithResponse request returning *ListApiKeysResponse +func (c *ClientWithResponses) ListApiKeysWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListApiKeysResponse, error) { + rsp, err := c.ListApiKeys(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseListApiKeysResponse(rsp) +} + +// CreateApiKeyWithBodyWithResponse request with arbitrary body returning *CreateApiKeyResponse +func (c *ClientWithResponses) CreateApiKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateApiKeyResponse, error) { + rsp, err := c.CreateApiKeyWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateApiKeyResponse(rsp) +} + +func (c *ClientWithResponses) CreateApiKeyWithResponse(ctx context.Context, body CreateApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateApiKeyResponse, error) { + rsp, err := c.CreateApiKey(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateApiKeyResponse(rsp) +} + +// ExpireApiKeyWithBodyWithResponse request with arbitrary body returning *ExpireApiKeyResponse +func (c *ClientWithResponses) ExpireApiKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ExpireApiKeyResponse, error) { + rsp, err := c.ExpireApiKeyWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseExpireApiKeyResponse(rsp) +} + +func (c *ClientWithResponses) ExpireApiKeyWithResponse(ctx context.Context, body ExpireApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*ExpireApiKeyResponse, error) { + rsp, err := c.ExpireApiKey(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseExpireApiKeyResponse(rsp) +} + +// DeleteApiKeyWithResponse request returning *DeleteApiKeyResponse +func (c *ClientWithResponses) DeleteApiKeyWithResponse(ctx context.Context, prefix string, params *DeleteApiKeyParams, reqEditors ...RequestEditorFn) (*DeleteApiKeyResponse, error) { + rsp, err := c.DeleteApiKey(ctx, prefix, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteApiKeyResponse(rsp) +} + +// AuthApproveWithBodyWithResponse request with arbitrary body returning *AuthApproveResponse +func (c *ClientWithResponses) AuthApproveWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthApproveResponse, error) { + rsp, err := c.AuthApproveWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthApproveResponse(rsp) +} + +func (c *ClientWithResponses) AuthApproveWithResponse(ctx context.Context, body AuthApproveJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthApproveResponse, error) { + rsp, err := c.AuthApprove(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthApproveResponse(rsp) +} + +// AuthRegisterWithBodyWithResponse request with arbitrary body returning *AuthRegisterResponse +func (c *ClientWithResponses) AuthRegisterWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthRegisterResponse, error) { + rsp, err := c.AuthRegisterWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthRegisterResponse(rsp) +} + +func (c *ClientWithResponses) AuthRegisterWithResponse(ctx context.Context, body AuthRegisterJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthRegisterResponse, error) { + rsp, err := c.AuthRegister(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthRegisterResponse(rsp) +} + +// AuthRejectWithBodyWithResponse request with arbitrary body returning *AuthRejectResponse +func (c *ClientWithResponses) AuthRejectWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthRejectResponse, error) { + rsp, err := c.AuthRejectWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthRejectResponse(rsp) +} + +func (c *ClientWithResponses) AuthRejectWithResponse(ctx context.Context, body AuthRejectJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthRejectResponse, error) { + rsp, err := c.AuthReject(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthRejectResponse(rsp) +} + +// DebugCreateNodeWithBodyWithResponse request with arbitrary body returning *DebugCreateNodeResponse +func (c *ClientWithResponses) DebugCreateNodeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DebugCreateNodeResponse, error) { + rsp, err := c.DebugCreateNodeWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDebugCreateNodeResponse(rsp) +} + +func (c *ClientWithResponses) DebugCreateNodeWithResponse(ctx context.Context, body DebugCreateNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*DebugCreateNodeResponse, error) { + rsp, err := c.DebugCreateNode(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDebugCreateNodeResponse(rsp) +} + +// HealthWithResponse request returning *HealthResponse +func (c *ClientWithResponses) HealthWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*HealthResponse, error) { + rsp, err := c.Health(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseHealthResponse(rsp) +} + +// ListNodesWithResponse request returning *ListNodesResponse +func (c *ClientWithResponses) ListNodesWithResponse(ctx context.Context, params *ListNodesParams, reqEditors ...RequestEditorFn) (*ListNodesResponse, error) { + rsp, err := c.ListNodes(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListNodesResponse(rsp) +} + +// BackfillNodeIPsWithResponse request returning *BackfillNodeIPsResponse +func (c *ClientWithResponses) BackfillNodeIPsWithResponse(ctx context.Context, params *BackfillNodeIPsParams, reqEditors ...RequestEditorFn) (*BackfillNodeIPsResponse, error) { + rsp, err := c.BackfillNodeIPs(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseBackfillNodeIPsResponse(rsp) +} + +// RegisterNodeWithResponse request returning *RegisterNodeResponse +func (c *ClientWithResponses) RegisterNodeWithResponse(ctx context.Context, params *RegisterNodeParams, reqEditors ...RequestEditorFn) (*RegisterNodeResponse, error) { + rsp, err := c.RegisterNode(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseRegisterNodeResponse(rsp) +} + +// DeleteNodeWithResponse request returning *DeleteNodeResponse +func (c *ClientWithResponses) DeleteNodeWithResponse(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*DeleteNodeResponse, error) { + rsp, err := c.DeleteNode(ctx, nodeId, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteNodeResponse(rsp) +} + +// GetNodeWithResponse request returning *GetNodeResponse +func (c *ClientWithResponses) GetNodeWithResponse(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*GetNodeResponse, error) { + rsp, err := c.GetNode(ctx, nodeId, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeResponse(rsp) +} + +// SetApprovedRoutesWithBodyWithResponse request with arbitrary body returning *SetApprovedRoutesResponse +func (c *ClientWithResponses) SetApprovedRoutesWithBodyWithResponse(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetApprovedRoutesResponse, error) { + rsp, err := c.SetApprovedRoutesWithBody(ctx, nodeId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetApprovedRoutesResponse(rsp) +} + +func (c *ClientWithResponses) SetApprovedRoutesWithResponse(ctx context.Context, nodeId string, body SetApprovedRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*SetApprovedRoutesResponse, error) { + rsp, err := c.SetApprovedRoutes(ctx, nodeId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetApprovedRoutesResponse(rsp) +} + +// ExpireNodeWithBodyWithResponse request with arbitrary body returning *ExpireNodeResponse +func (c *ClientWithResponses) ExpireNodeWithBodyWithResponse(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ExpireNodeResponse, error) { + rsp, err := c.ExpireNodeWithBody(ctx, nodeId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseExpireNodeResponse(rsp) +} + +func (c *ClientWithResponses) ExpireNodeWithResponse(ctx context.Context, nodeId string, body ExpireNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*ExpireNodeResponse, error) { + rsp, err := c.ExpireNode(ctx, nodeId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseExpireNodeResponse(rsp) +} + +// RenameNodeWithResponse request returning *RenameNodeResponse +func (c *ClientWithResponses) RenameNodeWithResponse(ctx context.Context, nodeId string, newName string, reqEditors ...RequestEditorFn) (*RenameNodeResponse, error) { + rsp, err := c.RenameNode(ctx, nodeId, newName, reqEditors...) + if err != nil { + return nil, err + } + return ParseRenameNodeResponse(rsp) +} + +// SetTagsWithBodyWithResponse request with arbitrary body returning *SetTagsResponse +func (c *ClientWithResponses) SetTagsWithBodyWithResponse(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetTagsResponse, error) { + rsp, err := c.SetTagsWithBody(ctx, nodeId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetTagsResponse(rsp) +} + +func (c *ClientWithResponses) SetTagsWithResponse(ctx context.Context, nodeId string, body SetTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*SetTagsResponse, error) { + rsp, err := c.SetTags(ctx, nodeId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetTagsResponse(rsp) +} + +// GetPolicyWithResponse request returning *GetPolicyResponse +func (c *ClientWithResponses) GetPolicyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetPolicyResponse, error) { + rsp, err := c.GetPolicy(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetPolicyResponse(rsp) +} + +// SetPolicyWithBodyWithResponse request with arbitrary body returning *SetPolicyResponse +func (c *ClientWithResponses) SetPolicyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetPolicyResponse, error) { + rsp, err := c.SetPolicyWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetPolicyResponse(rsp) +} + +func (c *ClientWithResponses) SetPolicyWithResponse(ctx context.Context, body SetPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*SetPolicyResponse, error) { + rsp, err := c.SetPolicy(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetPolicyResponse(rsp) +} + +// CheckPolicyWithBodyWithResponse request with arbitrary body returning *CheckPolicyResponse +func (c *ClientWithResponses) CheckPolicyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CheckPolicyResponse, error) { + rsp, err := c.CheckPolicyWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCheckPolicyResponse(rsp) +} + +func (c *ClientWithResponses) CheckPolicyWithResponse(ctx context.Context, body CheckPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*CheckPolicyResponse, error) { + rsp, err := c.CheckPolicy(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCheckPolicyResponse(rsp) +} + +// DeletePreAuthKeyWithResponse request returning *DeletePreAuthKeyResponse +func (c *ClientWithResponses) DeletePreAuthKeyWithResponse(ctx context.Context, params *DeletePreAuthKeyParams, reqEditors ...RequestEditorFn) (*DeletePreAuthKeyResponse, error) { + rsp, err := c.DeletePreAuthKey(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeletePreAuthKeyResponse(rsp) +} + +// ListPreAuthKeysWithResponse request returning *ListPreAuthKeysResponse +func (c *ClientWithResponses) ListPreAuthKeysWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListPreAuthKeysResponse, error) { + rsp, err := c.ListPreAuthKeys(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseListPreAuthKeysResponse(rsp) +} + +// CreatePreAuthKeyWithBodyWithResponse request with arbitrary body returning *CreatePreAuthKeyResponse +func (c *ClientWithResponses) CreatePreAuthKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreatePreAuthKeyResponse, error) { + rsp, err := c.CreatePreAuthKeyWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreatePreAuthKeyResponse(rsp) +} + +func (c *ClientWithResponses) CreatePreAuthKeyWithResponse(ctx context.Context, body CreatePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*CreatePreAuthKeyResponse, error) { + rsp, err := c.CreatePreAuthKey(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreatePreAuthKeyResponse(rsp) +} + +// ExpirePreAuthKeyWithBodyWithResponse request with arbitrary body returning *ExpirePreAuthKeyResponse +func (c *ClientWithResponses) ExpirePreAuthKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ExpirePreAuthKeyResponse, error) { + rsp, err := c.ExpirePreAuthKeyWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseExpirePreAuthKeyResponse(rsp) +} + +func (c *ClientWithResponses) ExpirePreAuthKeyWithResponse(ctx context.Context, body ExpirePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*ExpirePreAuthKeyResponse, error) { + rsp, err := c.ExpirePreAuthKey(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseExpirePreAuthKeyResponse(rsp) +} + +// ListUsersWithResponse request returning *ListUsersResponse +func (c *ClientWithResponses) ListUsersWithResponse(ctx context.Context, params *ListUsersParams, reqEditors ...RequestEditorFn) (*ListUsersResponse, error) { + rsp, err := c.ListUsers(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListUsersResponse(rsp) +} + +// CreateUserWithBodyWithResponse request with arbitrary body returning *CreateUserResponse +func (c *ClientWithResponses) CreateUserWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateUserResponse, error) { + rsp, err := c.CreateUserWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateUserResponse(rsp) +} + +func (c *ClientWithResponses) CreateUserWithResponse(ctx context.Context, body CreateUserJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateUserResponse, error) { + rsp, err := c.CreateUser(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateUserResponse(rsp) +} + +// DeleteUserWithResponse request returning *DeleteUserResponse +func (c *ClientWithResponses) DeleteUserWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*DeleteUserResponse, error) { + rsp, err := c.DeleteUser(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteUserResponse(rsp) +} + +// RenameUserWithResponse request returning *RenameUserResponse +func (c *ClientWithResponses) RenameUserWithResponse(ctx context.Context, oldId string, newName string, reqEditors ...RequestEditorFn) (*RenameUserResponse, error) { + rsp, err := c.RenameUser(ctx, oldId, newName, reqEditors...) + if err != nil { + return nil, err + } + return ParseRenameUserResponse(rsp) +} + +// ParseListApiKeysResponse parses an HTTP response from a ListApiKeysWithResponse call +func ParseListApiKeysResponse(rsp *http.Response) (*ListApiKeysResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListApiKeysResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListApiKeysOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseCreateApiKeyResponse parses an HTTP response from a CreateApiKeyWithResponse call +func ParseCreateApiKeyResponse(rsp *http.Response) (*CreateApiKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateApiKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest CreateApiKeyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseExpireApiKeyResponse parses an HTTP response from a ExpireApiKeyWithResponse call +func ParseExpireApiKeyResponse(rsp *http.Response) (*ExpireApiKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ExpireApiKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ExpireApiKeyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseDeleteApiKeyResponse parses an HTTP response from a DeleteApiKeyWithResponse call +func ParseDeleteApiKeyResponse(rsp *http.Response) (*DeleteApiKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteApiKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeleteApiKeyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseAuthApproveResponse parses an HTTP response from a AuthApproveWithResponse call +func ParseAuthApproveResponse(rsp *http.Response) (*AuthApproveResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &AuthApproveResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AuthApproveOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseAuthRegisterResponse parses an HTTP response from a AuthRegisterWithResponse call +func ParseAuthRegisterResponse(rsp *http.Response) (*AuthRegisterResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &AuthRegisterResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AuthRegisterOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseAuthRejectResponse parses an HTTP response from a AuthRejectWithResponse call +func ParseAuthRejectResponse(rsp *http.Response) (*AuthRejectResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &AuthRejectResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AuthRejectOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseDebugCreateNodeResponse parses an HTTP response from a DebugCreateNodeWithResponse call +func ParseDebugCreateNodeResponse(rsp *http.Response) (*DebugCreateNodeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DebugCreateNodeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseHealthResponse parses an HTTP response from a HealthWithResponse call +func ParseHealthResponse(rsp *http.Response) (*HealthResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &HealthResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest HealthResponseBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseListNodesResponse parses an HTTP response from a ListNodesWithResponse call +func ParseListNodesResponse(rsp *http.Response) (*ListNodesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListNodesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListNodesOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseBackfillNodeIPsResponse parses an HTTP response from a BackfillNodeIPsWithResponse call +func ParseBackfillNodeIPsResponse(rsp *http.Response) (*BackfillNodeIPsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &BackfillNodeIPsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest BackfillNodeIPsOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseRegisterNodeResponse parses an HTTP response from a RegisterNodeWithResponse call +func ParseRegisterNodeResponse(rsp *http.Response) (*RegisterNodeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RegisterNodeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseDeleteNodeResponse parses an HTTP response from a DeleteNodeWithResponse call +func ParseDeleteNodeResponse(rsp *http.Response) (*DeleteNodeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteNodeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeleteNodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseGetNodeResponse parses an HTTP response from a GetNodeWithResponse call +func ParseGetNodeResponse(rsp *http.Response) (*GetNodeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseSetApprovedRoutesResponse parses an HTTP response from a SetApprovedRoutesWithResponse call +func ParseSetApprovedRoutesResponse(rsp *http.Response) (*SetApprovedRoutesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetApprovedRoutesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseExpireNodeResponse parses an HTTP response from a ExpireNodeWithResponse call +func ParseExpireNodeResponse(rsp *http.Response) (*ExpireNodeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ExpireNodeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseRenameNodeResponse parses an HTTP response from a RenameNodeWithResponse call +func ParseRenameNodeResponse(rsp *http.Response) (*RenameNodeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RenameNodeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseSetTagsResponse parses an HTTP response from a SetTagsWithResponse call +func ParseSetTagsResponse(rsp *http.Response) (*SetTagsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetTagsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseGetPolicyResponse parses an HTTP response from a GetPolicyWithResponse call +func ParseGetPolicyResponse(rsp *http.Response) (*GetPolicyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetPolicyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PolicyResponseBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseSetPolicyResponse parses an HTTP response from a SetPolicyWithResponse call +func ParseSetPolicyResponse(rsp *http.Response) (*SetPolicyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetPolicyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PolicyResponseBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseCheckPolicyResponse parses an HTTP response from a CheckPolicyWithResponse call +func ParseCheckPolicyResponse(rsp *http.Response) (*CheckPolicyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CheckPolicyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest CheckPolicyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseDeletePreAuthKeyResponse parses an HTTP response from a DeletePreAuthKeyWithResponse call +func ParseDeletePreAuthKeyResponse(rsp *http.Response) (*DeletePreAuthKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeletePreAuthKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeletePreAuthKeyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseListPreAuthKeysResponse parses an HTTP response from a ListPreAuthKeysWithResponse call +func ParseListPreAuthKeysResponse(rsp *http.Response) (*ListPreAuthKeysResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListPreAuthKeysResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListPreAuthKeysOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseCreatePreAuthKeyResponse parses an HTTP response from a CreatePreAuthKeyWithResponse call +func ParseCreatePreAuthKeyResponse(rsp *http.Response) (*CreatePreAuthKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreatePreAuthKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PreAuthKeyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseExpirePreAuthKeyResponse parses an HTTP response from a ExpirePreAuthKeyWithResponse call +func ParseExpirePreAuthKeyResponse(rsp *http.Response) (*ExpirePreAuthKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ExpirePreAuthKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ExpirePreAuthKeyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseListUsersResponse parses an HTTP response from a ListUsersWithResponse call +func ParseListUsersResponse(rsp *http.Response) (*ListUsersResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListUsersResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListUsersOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseCreateUserResponse parses an HTTP response from a CreateUserWithResponse call +func ParseCreateUserResponse(rsp *http.Response) (*CreateUserResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateUserResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest UserOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseDeleteUserResponse parses an HTTP response from a DeleteUserWithResponse call +func ParseDeleteUserResponse(rsp *http.Response) (*DeleteUserResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteUserResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeleteUserOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseRenameUserResponse parses an HTTP response from a RenameUserWithResponse call +func ParseRenameUserResponse(rsp *http.Response) (*RenameUserResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RenameUserResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest UserOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} diff --git a/gen/client/v2/client.gen.go b/gen/client/v2/client.gen.go new file mode 100644 index 000000000..c31b47ab1 --- /dev/null +++ b/gen/client/v2/client.gen.go @@ -0,0 +1,4013 @@ +// Package clientv2 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT. +package clientv2 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/oapi-codegen/runtime" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +const ( + BasicAuthScopes basicAuthContextKey = "basicAuth.Scopes" + BearerAuthScopes bearerAuthContextKey = "bearerAuth.Scopes" +) + +// CreateKeyRequest defines model for CreateKeyRequest. +type CreateKeyRequest struct { + Capabilities *KeyCapabilities `json:"capabilities,omitempty"` + Description *string `json:"description,omitempty"` + + // ExpirySeconds Lifetime in seconds; defaults to 90 days. Auth keys only. + ExpirySeconds *int64 `json:"expirySeconds,omitempty"` + + // KeyType Key kind: "auth" (default) or "client" (OAuth client). + KeyType *string `json:"keyType,omitempty"` + + // Scopes OAuth scopes granted to the client. keyType=client only. + Scopes *[]string `json:"scopes,omitempty"` + + // Tags Tags the client may assign. keyType=client only. + Tags *[]string `json:"tags,omitempty"` +} + +// DeleteKeyOutputBody defines model for DeleteKeyOutputBody. +type DeleteKeyOutputBody = map[string]interface{} + +// Device defines model for Device. +type Device struct { + Addresses []string `json:"addresses"` + AdvertisedRoutes *[]string `json:"advertisedRoutes,omitempty"` + Authorized bool `json:"authorized"` + ClientVersion string `json:"clientVersion"` + Created time.Time `json:"created"` + EnabledRoutes *[]string `json:"enabledRoutes,omitempty"` + Expires *time.Time `json:"expires,omitempty"` + Hostname string `json:"hostname"` + Id string `json:"id"` + IsEphemeral bool `json:"isEphemeral"` + KeyExpiryDisabled bool `json:"keyExpiryDisabled"` + LastSeen *time.Time `json:"lastSeen,omitempty"` + MachineKey string `json:"machineKey"` + Name string `json:"name"` + NodeId string `json:"nodeId"` + NodeKey string `json:"nodeKey"` + Os string `json:"os"` + Tags []string `json:"tags"` + UpdateAvailable bool `json:"updateAvailable"` + User string `json:"user"` +} + +// DeviceRoutes defines model for DeviceRoutes. +type DeviceRoutes struct { + AdvertisedRoutes []string `json:"advertisedRoutes"` + EnabledRoutes []string `json:"enabledRoutes"` +} + +// EmptyOutputBody defines model for EmptyOutputBody. +type EmptyOutputBody = map[string]interface{} + +// ErrorDetail defines model for ErrorDetail. +type ErrorDetail struct { + // Location Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id' + Location *string `json:"location,omitempty"` + + // Message Error message text + Message *string `json:"message,omitempty"` + + // Value The value at the given location + Value interface{} `json:"value,omitempty"` +} + +// ErrorModel defines model for ErrorModel. +type ErrorModel struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail *string `json:"detail,omitempty"` + + // Errors Optional list of individual error details + Errors *[]ErrorDetail `json:"errors,omitempty"` + + // Instance A URI reference that identifies the specific occurrence of the problem. + Instance *string `json:"instance,omitempty"` + + // Status HTTP status code + Status *int64 `json:"status,omitempty"` + + // Title A short, human-readable summary of the problem type. This value should not change between occurrences of the error. + Title *string `json:"title,omitempty"` + + // Type A URI reference to human-readable documentation for the error. + Type *string `json:"type,omitempty"` +} + +// Key defines model for Key. +type Key struct { + Capabilities KeyCapabilities `json:"capabilities"` + Created time.Time `json:"created"` + Description *string `json:"description,omitempty"` + Expires *time.Time `json:"expires,omitempty"` + ExpirySeconds *int64 `json:"expirySeconds,omitempty"` + Id string `json:"id"` + Invalid bool `json:"invalid"` + Key *string `json:"key,omitempty"` + KeyType string `json:"keyType"` + Revoked *time.Time `json:"revoked,omitempty"` + Scopes *[]string `json:"scopes,omitempty"` + Tags *[]string `json:"tags,omitempty"` + UserId *string `json:"userId,omitempty"` +} + +// KeyCapabilities defines model for KeyCapabilities. +type KeyCapabilities struct { + Devices KeyDeviceCapabilities `json:"devices"` +} + +// KeyDeviceCapabilities defines model for KeyDeviceCapabilities. +type KeyDeviceCapabilities struct { + Create KeyDeviceCreateCapabilities `json:"create"` +} + +// KeyDeviceCreateCapabilities defines model for KeyDeviceCreateCapabilities. +type KeyDeviceCreateCapabilities struct { + Ephemeral bool `json:"ephemeral"` + Preauthorized bool `json:"preauthorized"` + Reusable bool `json:"reusable"` + Tags *[]string `json:"tags"` +} + +// ListDevicesOutputBody defines model for ListDevicesOutputBody. +type ListDevicesOutputBody struct { + Devices []Device `json:"devices"` +} + +// ListKeysOutputBody defines model for ListKeysOutputBody. +type ListKeysOutputBody struct { + Keys []Key `json:"keys"` +} + +// ListUsersOutputBody defines model for ListUsersOutputBody. +type ListUsersOutputBody struct { + Users []User `json:"users"` +} + +// SetAuthorizedRequest defines model for SetAuthorizedRequest. +type SetAuthorizedRequest struct { + Authorized bool `json:"authorized"` +} + +// SetKeyRequest defines model for SetKeyRequest. +type SetKeyRequest struct { + KeyExpiryDisabled bool `json:"keyExpiryDisabled"` +} + +// SetNameRequest defines model for SetNameRequest. +type SetNameRequest struct { + Name string `json:"name"` +} + +// SetSubnetRoutesRequest defines model for SetSubnetRoutesRequest. +type SetSubnetRoutesRequest struct { + Routes []string `json:"routes"` +} + +// SetTagsRequest defines model for SetTagsRequest. +type SetTagsRequest struct { + Tags *[]string `json:"tags"` +} + +// TailnetSettings defines model for TailnetSettings. +type TailnetSettings struct { + AclsExternalLink string `json:"aclsExternalLink"` + AclsExternallyManagedOn bool `json:"aclsExternallyManagedOn"` + DevicesApprovalOn bool `json:"devicesApprovalOn"` + DevicesAutoUpdatesOn bool `json:"devicesAutoUpdatesOn"` + DevicesKeyDurationDays int64 `json:"devicesKeyDurationDays"` + HttpsEnabled bool `json:"httpsEnabled"` + NetworkFlowLoggingOn bool `json:"networkFlowLoggingOn"` + PostureIdentityCollectionOn bool `json:"postureIdentityCollectionOn"` + RegionalRoutingOn bool `json:"regionalRoutingOn"` + UsersApprovalOn bool `json:"usersApprovalOn"` + UsersRoleAllowedToJoinExternalTailnets string `json:"usersRoleAllowedToJoinExternalTailnets"` +} + +// User defines model for User. +type User struct { + Created time.Time `json:"created"` + CurrentlyConnected bool `json:"currentlyConnected"` + DeviceCount int64 `json:"deviceCount"` + DisplayName string `json:"displayName"` + Id string `json:"id"` + LastSeen time.Time `json:"lastSeen"` + LoginName string `json:"loginName"` + ProfilePicUrl string `json:"profilePicUrl"` + Role string `json:"role"` + Status string `json:"status"` + TailnetId string `json:"tailnetId"` + Type string `json:"type"` +} + +// basicAuthContextKey is the context key for basicAuth security scheme +type basicAuthContextKey string + +// bearerAuthContextKey is the context key for bearerAuth security scheme +type bearerAuthContextKey string + +// DeleteDeviceParams defines parameters for DeleteDevice. +type DeleteDeviceParams struct { + // Fields Set to "all" for route fields. + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetDeviceParams defines parameters for GetDevice. +type GetDeviceParams struct { + // Fields Set to "all" for route fields. + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetDeviceRoutesParams defines parameters for GetDeviceRoutes. +type GetDeviceRoutesParams struct { + // Fields Set to "all" for route fields. + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetACLParams defines parameters for GetACL. +type GetACLParams struct { + // Details Accepted for compatibility; ignored. + Details *bool `form:"details,omitempty" json:"details,omitempty"` + Accept *string `json:"Accept,omitempty"` +} + +// SetACLJSONBody defines parameters for SetACL. +type SetACLJSONBody = openapi_types.File + +// SetACLParams defines parameters for SetACL. +type SetACLParams struct { + IfMatch *string `json:"If-Match,omitempty"` + Accept *string `json:"Accept,omitempty"` +} + +// ListDevicesParams defines parameters for ListDevices. +type ListDevicesParams struct { + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// ListKeysParams defines parameters for ListKeys. +type ListKeysParams struct { + // All Accepted for compatibility; Headscale returns all keys. + All *bool `form:"all,omitempty" json:"all,omitempty"` +} + +// UpdateTailnetSettingsJSONBody defines parameters for UpdateTailnetSettings. +type UpdateTailnetSettingsJSONBody = interface{} + +// ListUsersParams defines parameters for ListUsers. +type ListUsersParams struct { + // Type Filter by user type; Headscale users are all "member". + Type *string `form:"type,omitempty" json:"type,omitempty"` + + // Role Filter by user role; Headscale users are all "member". + Role *string `form:"role,omitempty" json:"role,omitempty"` +} + +// AuthorizeDeviceJSONRequestBody defines body for AuthorizeDevice for application/json ContentType. +type AuthorizeDeviceJSONRequestBody = SetAuthorizedRequest + +// SetDeviceKeyJSONRequestBody defines body for SetDeviceKey for application/json ContentType. +type SetDeviceKeyJSONRequestBody = SetKeyRequest + +// SetDeviceNameJSONRequestBody defines body for SetDeviceName for application/json ContentType. +type SetDeviceNameJSONRequestBody = SetNameRequest + +// SetDeviceRoutesJSONRequestBody defines body for SetDeviceRoutes for application/json ContentType. +type SetDeviceRoutesJSONRequestBody = SetSubnetRoutesRequest + +// SetDeviceTagsJSONRequestBody defines body for SetDeviceTags for application/json ContentType. +type SetDeviceTagsJSONRequestBody = SetTagsRequest + +// SetACLJSONRequestBody defines body for SetACL for application/json ContentType. +type SetACLJSONRequestBody = SetACLJSONBody + +// CreateKeyJSONRequestBody defines body for CreateKey for application/json ContentType. +type CreateKeyJSONRequestBody = CreateKeyRequest + +// UpdateTailnetSettingsJSONRequestBody defines body for UpdateTailnetSettings for application/json ContentType. +type UpdateTailnetSettingsJSONRequestBody = UpdateTailnetSettingsJSONBody + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // DeleteDevice request + DeleteDevice(ctx context.Context, id string, params *DeleteDeviceParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetDevice request + GetDevice(ctx context.Context, id string, params *GetDeviceParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // AuthorizeDeviceWithBody request with any body + AuthorizeDeviceWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + AuthorizeDevice(ctx context.Context, id string, body AuthorizeDeviceJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetDeviceKeyWithBody request with any body + SetDeviceKeyWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetDeviceKey(ctx context.Context, id string, body SetDeviceKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetDeviceNameWithBody request with any body + SetDeviceNameWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetDeviceName(ctx context.Context, id string, body SetDeviceNameJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetDeviceRoutes request + GetDeviceRoutes(ctx context.Context, id string, params *GetDeviceRoutesParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetDeviceRoutesWithBody request with any body + SetDeviceRoutesWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetDeviceRoutes(ctx context.Context, id string, body SetDeviceRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetDeviceTagsWithBody request with any body + SetDeviceTagsWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetDeviceTags(ctx context.Context, id string, body SetDeviceTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetACL request + GetACL(ctx context.Context, tailnet string, params *GetACLParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetACLWithBody request with any body + SetACLWithBody(ctx context.Context, tailnet string, params *SetACLParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetACL(ctx context.Context, tailnet string, params *SetACLParams, body SetACLJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListDevices request + ListDevices(ctx context.Context, tailnet string, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListKeys request + ListKeys(ctx context.Context, tailnet string, params *ListKeysParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CreateKeyWithBody request with any body + CreateKeyWithBody(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreateKey(ctx context.Context, tailnet string, body CreateKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteKey request + DeleteKey(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetKey request + GetKey(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetTailnetSettings request + GetTailnetSettings(ctx context.Context, tailnet string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // UpdateTailnetSettingsWithBody request with any body + UpdateTailnetSettingsWithBody(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + UpdateTailnetSettings(ctx context.Context, tailnet string, body UpdateTailnetSettingsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListUsers request + ListUsers(ctx context.Context, tailnet string, params *ListUsersParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetUser request + GetUser(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) DeleteDevice(ctx context.Context, id string, params *DeleteDeviceParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteDeviceRequest(c.Server, id, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetDevice(ctx context.Context, id string, params *GetDeviceParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetDeviceRequest(c.Server, id, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthorizeDeviceWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthorizeDeviceRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthorizeDevice(ctx context.Context, id string, body AuthorizeDeviceJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthorizeDeviceRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceKeyWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceKeyRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceKey(ctx context.Context, id string, body SetDeviceKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceKeyRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceNameWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceNameRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceName(ctx context.Context, id string, body SetDeviceNameJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceNameRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetDeviceRoutes(ctx context.Context, id string, params *GetDeviceRoutesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetDeviceRoutesRequest(c.Server, id, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceRoutesWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceRoutesRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceRoutes(ctx context.Context, id string, body SetDeviceRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceRoutesRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceTagsWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceTagsRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceTags(ctx context.Context, id string, body SetDeviceTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceTagsRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetACL(ctx context.Context, tailnet string, params *GetACLParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetACLRequest(c.Server, tailnet, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetACLWithBody(ctx context.Context, tailnet string, params *SetACLParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetACLRequestWithBody(c.Server, tailnet, params, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetACL(ctx context.Context, tailnet string, params *SetACLParams, body SetACLJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetACLRequest(c.Server, tailnet, params, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListDevices(ctx context.Context, tailnet string, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListDevicesRequest(c.Server, tailnet, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListKeys(ctx context.Context, tailnet string, params *ListKeysParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListKeysRequest(c.Server, tailnet, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateKeyWithBody(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateKeyRequestWithBody(c.Server, tailnet, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateKey(ctx context.Context, tailnet string, body CreateKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateKeyRequest(c.Server, tailnet, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteKey(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteKeyRequest(c.Server, tailnet, keyId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetKey(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetKeyRequest(c.Server, tailnet, keyId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetTailnetSettings(ctx context.Context, tailnet string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetTailnetSettingsRequest(c.Server, tailnet) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) UpdateTailnetSettingsWithBody(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUpdateTailnetSettingsRequestWithBody(c.Server, tailnet, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) UpdateTailnetSettings(ctx context.Context, tailnet string, body UpdateTailnetSettingsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUpdateTailnetSettingsRequest(c.Server, tailnet, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListUsers(ctx context.Context, tailnet string, params *ListUsersParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListUsersRequest(c.Server, tailnet, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetUser(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetUserRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewDeleteDeviceRequest generates requests for DeleteDevice +func NewDeleteDeviceRequest(server string, id string, params *DeleteDeviceParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Fields != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "fields", *params.Fields, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetDeviceRequest generates requests for GetDevice +func NewGetDeviceRequest(server string, id string, params *GetDeviceParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Fields != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "fields", *params.Fields, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewAuthorizeDeviceRequest calls the generic AuthorizeDevice builder with application/json body +func NewAuthorizeDeviceRequest(server string, id string, body AuthorizeDeviceJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewAuthorizeDeviceRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewAuthorizeDeviceRequestWithBody generates requests for AuthorizeDevice with any type of body +func NewAuthorizeDeviceRequestWithBody(server string, id string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s/authorized", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewSetDeviceKeyRequest calls the generic SetDeviceKey builder with application/json body +func NewSetDeviceKeyRequest(server string, id string, body SetDeviceKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetDeviceKeyRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewSetDeviceKeyRequestWithBody generates requests for SetDeviceKey with any type of body +func NewSetDeviceKeyRequestWithBody(server string, id string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s/key", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewSetDeviceNameRequest calls the generic SetDeviceName builder with application/json body +func NewSetDeviceNameRequest(server string, id string, body SetDeviceNameJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetDeviceNameRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewSetDeviceNameRequestWithBody generates requests for SetDeviceName with any type of body +func NewSetDeviceNameRequestWithBody(server string, id string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s/name", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetDeviceRoutesRequest generates requests for GetDeviceRoutes +func NewGetDeviceRoutesRequest(server string, id string, params *GetDeviceRoutesParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s/routes", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Fields != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "fields", *params.Fields, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewSetDeviceRoutesRequest calls the generic SetDeviceRoutes builder with application/json body +func NewSetDeviceRoutesRequest(server string, id string, body SetDeviceRoutesJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetDeviceRoutesRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewSetDeviceRoutesRequestWithBody generates requests for SetDeviceRoutes with any type of body +func NewSetDeviceRoutesRequestWithBody(server string, id string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s/routes", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewSetDeviceTagsRequest calls the generic SetDeviceTags builder with application/json body +func NewSetDeviceTagsRequest(server string, id string, body SetDeviceTagsJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetDeviceTagsRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewSetDeviceTagsRequestWithBody generates requests for SetDeviceTags with any type of body +func NewSetDeviceTagsRequestWithBody(server string, id string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s/tags", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetACLRequest generates requests for GetACL +func NewGetACLRequest(server string, tailnet string, params *GetACLParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/acl", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Details != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "details", *params.Details, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + if params != nil { + + if params.Accept != nil { + var headerParam0 string + + headerParam0, err = runtime.StyleParamWithOptions("simple", false, "Accept", *params.Accept, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", headerParam0) + } + + } + + return req, nil +} + +// NewSetACLRequest calls the generic SetACL builder with application/json body +func NewSetACLRequest(server string, tailnet string, params *SetACLParams, body SetACLJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetACLRequestWithBody(server, tailnet, params, "application/json", bodyReader) +} + +// NewSetACLRequestWithBody generates requests for SetACL with any type of body +func NewSetACLRequestWithBody(server string, tailnet string, params *SetACLParams, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/acl", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + if params != nil { + + if params.IfMatch != nil { + var headerParam0 string + + headerParam0, err = runtime.StyleParamWithOptions("simple", false, "If-Match", *params.IfMatch, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + req.Header.Set("If-Match", headerParam0) + } + + if params.Accept != nil { + var headerParam1 string + + headerParam1, err = runtime.StyleParamWithOptions("simple", false, "Accept", *params.Accept, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", headerParam1) + } + + } + + return req, nil +} + +// NewListDevicesRequest generates requests for ListDevices +func NewListDevicesRequest(server string, tailnet string, params *ListDevicesParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/devices", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Fields != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "fields", *params.Fields, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewListKeysRequest generates requests for ListKeys +func NewListKeysRequest(server string, tailnet string, params *ListKeysParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/keys", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.All != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "all", *params.All, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewCreateKeyRequest calls the generic CreateKey builder with application/json body +func NewCreateKeyRequest(server string, tailnet string, body CreateKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreateKeyRequestWithBody(server, tailnet, "application/json", bodyReader) +} + +// NewCreateKeyRequestWithBody generates requests for CreateKey with any type of body +func NewCreateKeyRequestWithBody(server string, tailnet string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/keys", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteKeyRequest generates requests for DeleteKey +func NewDeleteKeyRequest(server string, tailnet string, keyId string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "keyId", keyId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/keys/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetKeyRequest generates requests for GetKey +func NewGetKeyRequest(server string, tailnet string, keyId string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "keyId", keyId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/keys/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetTailnetSettingsRequest generates requests for GetTailnetSettings +func NewGetTailnetSettingsRequest(server string, tailnet string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/settings", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewUpdateTailnetSettingsRequest calls the generic UpdateTailnetSettings builder with application/json body +func NewUpdateTailnetSettingsRequest(server string, tailnet string, body UpdateTailnetSettingsJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewUpdateTailnetSettingsRequestWithBody(server, tailnet, "application/json", bodyReader) +} + +// NewUpdateTailnetSettingsRequestWithBody generates requests for UpdateTailnetSettings with any type of body +func NewUpdateTailnetSettingsRequestWithBody(server string, tailnet string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/settings", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewListUsersRequest generates requests for ListUsers +func NewListUsersRequest(server string, tailnet string, params *ListUsersParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/users", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Type != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "type", *params.Type, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Role != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "role", *params.Role, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetUserRequest generates requests for GetUser +func NewGetUserRequest(server string, id string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/users/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // DeleteDeviceWithResponse request + DeleteDeviceWithResponse(ctx context.Context, id string, params *DeleteDeviceParams, reqEditors ...RequestEditorFn) (*DeleteDeviceResponse, error) + + // GetDeviceWithResponse request + GetDeviceWithResponse(ctx context.Context, id string, params *GetDeviceParams, reqEditors ...RequestEditorFn) (*GetDeviceResponse, error) + + // AuthorizeDeviceWithBodyWithResponse request with any body + AuthorizeDeviceWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthorizeDeviceResponse, error) + + AuthorizeDeviceWithResponse(ctx context.Context, id string, body AuthorizeDeviceJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthorizeDeviceResponse, error) + + // SetDeviceKeyWithBodyWithResponse request with any body + SetDeviceKeyWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceKeyResponse, error) + + SetDeviceKeyWithResponse(ctx context.Context, id string, body SetDeviceKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceKeyResponse, error) + + // SetDeviceNameWithBodyWithResponse request with any body + SetDeviceNameWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceNameResponse, error) + + SetDeviceNameWithResponse(ctx context.Context, id string, body SetDeviceNameJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceNameResponse, error) + + // GetDeviceRoutesWithResponse request + GetDeviceRoutesWithResponse(ctx context.Context, id string, params *GetDeviceRoutesParams, reqEditors ...RequestEditorFn) (*GetDeviceRoutesResponse, error) + + // SetDeviceRoutesWithBodyWithResponse request with any body + SetDeviceRoutesWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceRoutesResponse, error) + + SetDeviceRoutesWithResponse(ctx context.Context, id string, body SetDeviceRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceRoutesResponse, error) + + // SetDeviceTagsWithBodyWithResponse request with any body + SetDeviceTagsWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceTagsResponse, error) + + SetDeviceTagsWithResponse(ctx context.Context, id string, body SetDeviceTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceTagsResponse, error) + + // GetACLWithResponse request + GetACLWithResponse(ctx context.Context, tailnet string, params *GetACLParams, reqEditors ...RequestEditorFn) (*GetACLResponse, error) + + // SetACLWithBodyWithResponse request with any body + SetACLWithBodyWithResponse(ctx context.Context, tailnet string, params *SetACLParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetACLResponse, error) + + SetACLWithResponse(ctx context.Context, tailnet string, params *SetACLParams, body SetACLJSONRequestBody, reqEditors ...RequestEditorFn) (*SetACLResponse, error) + + // ListDevicesWithResponse request + ListDevicesWithResponse(ctx context.Context, tailnet string, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*ListDevicesResponse, error) + + // ListKeysWithResponse request + ListKeysWithResponse(ctx context.Context, tailnet string, params *ListKeysParams, reqEditors ...RequestEditorFn) (*ListKeysResponse, error) + + // CreateKeyWithBodyWithResponse request with any body + CreateKeyWithBodyWithResponse(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateKeyResponse, error) + + CreateKeyWithResponse(ctx context.Context, tailnet string, body CreateKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateKeyResponse, error) + + // DeleteKeyWithResponse request + DeleteKeyWithResponse(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*DeleteKeyResponse, error) + + // GetKeyWithResponse request + GetKeyWithResponse(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*GetKeyResponse, error) + + // GetTailnetSettingsWithResponse request + GetTailnetSettingsWithResponse(ctx context.Context, tailnet string, reqEditors ...RequestEditorFn) (*GetTailnetSettingsResponse, error) + + // UpdateTailnetSettingsWithBodyWithResponse request with any body + UpdateTailnetSettingsWithBodyWithResponse(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateTailnetSettingsResponse, error) + + UpdateTailnetSettingsWithResponse(ctx context.Context, tailnet string, body UpdateTailnetSettingsJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateTailnetSettingsResponse, error) + + // ListUsersWithResponse request + ListUsersWithResponse(ctx context.Context, tailnet string, params *ListUsersParams, reqEditors ...RequestEditorFn) (*ListUsersResponse, error) + + // GetUserWithResponse request + GetUserWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetUserResponse, error) +} + +type DeleteDeviceResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *EmptyOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r DeleteDeviceResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteDeviceResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeleteDeviceResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetDeviceResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Device + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetDeviceResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetDeviceResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetDeviceResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type AuthorizeDeviceResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *EmptyOutputBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r AuthorizeDeviceResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r AuthorizeDeviceResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r AuthorizeDeviceResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetDeviceKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *EmptyOutputBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetDeviceKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetDeviceKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetDeviceKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetDeviceNameResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *EmptyOutputBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetDeviceNameResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetDeviceNameResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetDeviceNameResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetDeviceRoutesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeviceRoutes + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetDeviceRoutesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetDeviceRoutesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetDeviceRoutesResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetDeviceRoutesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeviceRoutes + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetDeviceRoutesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetDeviceRoutesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetDeviceRoutesResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetDeviceTagsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *EmptyOutputBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetDeviceTagsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetDeviceTagsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetDeviceTagsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetACLResponse struct { + Body []byte + HTTPResponse *http.Response + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetACLResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetACLResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetACLResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetACLResponse struct { + Body []byte + HTTPResponse *http.Response + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON412 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetACLResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetACLResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetACLResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ListDevicesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListDevicesOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ListDevicesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListDevicesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListDevicesResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ListKeysResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListKeysOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ListKeysResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListKeysResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListKeysResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type CreateKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Key + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r CreateKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r CreateKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DeleteKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeleteKeyOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r DeleteKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeleteKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Key + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetTailnetSettingsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *TailnetSettings + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetTailnetSettingsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetTailnetSettingsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetTailnetSettingsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type UpdateTailnetSettingsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *TailnetSettings + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r UpdateTailnetSettingsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r UpdateTailnetSettingsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r UpdateTailnetSettingsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ListUsersResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListUsersOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ListUsersResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListUsersResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListUsersResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetUserResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *User + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetUserResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetUserResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetUserResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// DeleteDeviceWithResponse request returning *DeleteDeviceResponse +func (c *ClientWithResponses) DeleteDeviceWithResponse(ctx context.Context, id string, params *DeleteDeviceParams, reqEditors ...RequestEditorFn) (*DeleteDeviceResponse, error) { + rsp, err := c.DeleteDevice(ctx, id, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteDeviceResponse(rsp) +} + +// GetDeviceWithResponse request returning *GetDeviceResponse +func (c *ClientWithResponses) GetDeviceWithResponse(ctx context.Context, id string, params *GetDeviceParams, reqEditors ...RequestEditorFn) (*GetDeviceResponse, error) { + rsp, err := c.GetDevice(ctx, id, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetDeviceResponse(rsp) +} + +// AuthorizeDeviceWithBodyWithResponse request with arbitrary body returning *AuthorizeDeviceResponse +func (c *ClientWithResponses) AuthorizeDeviceWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthorizeDeviceResponse, error) { + rsp, err := c.AuthorizeDeviceWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthorizeDeviceResponse(rsp) +} + +func (c *ClientWithResponses) AuthorizeDeviceWithResponse(ctx context.Context, id string, body AuthorizeDeviceJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthorizeDeviceResponse, error) { + rsp, err := c.AuthorizeDevice(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthorizeDeviceResponse(rsp) +} + +// SetDeviceKeyWithBodyWithResponse request with arbitrary body returning *SetDeviceKeyResponse +func (c *ClientWithResponses) SetDeviceKeyWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceKeyResponse, error) { + rsp, err := c.SetDeviceKeyWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceKeyResponse(rsp) +} + +func (c *ClientWithResponses) SetDeviceKeyWithResponse(ctx context.Context, id string, body SetDeviceKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceKeyResponse, error) { + rsp, err := c.SetDeviceKey(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceKeyResponse(rsp) +} + +// SetDeviceNameWithBodyWithResponse request with arbitrary body returning *SetDeviceNameResponse +func (c *ClientWithResponses) SetDeviceNameWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceNameResponse, error) { + rsp, err := c.SetDeviceNameWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceNameResponse(rsp) +} + +func (c *ClientWithResponses) SetDeviceNameWithResponse(ctx context.Context, id string, body SetDeviceNameJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceNameResponse, error) { + rsp, err := c.SetDeviceName(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceNameResponse(rsp) +} + +// GetDeviceRoutesWithResponse request returning *GetDeviceRoutesResponse +func (c *ClientWithResponses) GetDeviceRoutesWithResponse(ctx context.Context, id string, params *GetDeviceRoutesParams, reqEditors ...RequestEditorFn) (*GetDeviceRoutesResponse, error) { + rsp, err := c.GetDeviceRoutes(ctx, id, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetDeviceRoutesResponse(rsp) +} + +// SetDeviceRoutesWithBodyWithResponse request with arbitrary body returning *SetDeviceRoutesResponse +func (c *ClientWithResponses) SetDeviceRoutesWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceRoutesResponse, error) { + rsp, err := c.SetDeviceRoutesWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceRoutesResponse(rsp) +} + +func (c *ClientWithResponses) SetDeviceRoutesWithResponse(ctx context.Context, id string, body SetDeviceRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceRoutesResponse, error) { + rsp, err := c.SetDeviceRoutes(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceRoutesResponse(rsp) +} + +// SetDeviceTagsWithBodyWithResponse request with arbitrary body returning *SetDeviceTagsResponse +func (c *ClientWithResponses) SetDeviceTagsWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceTagsResponse, error) { + rsp, err := c.SetDeviceTagsWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceTagsResponse(rsp) +} + +func (c *ClientWithResponses) SetDeviceTagsWithResponse(ctx context.Context, id string, body SetDeviceTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceTagsResponse, error) { + rsp, err := c.SetDeviceTags(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceTagsResponse(rsp) +} + +// GetACLWithResponse request returning *GetACLResponse +func (c *ClientWithResponses) GetACLWithResponse(ctx context.Context, tailnet string, params *GetACLParams, reqEditors ...RequestEditorFn) (*GetACLResponse, error) { + rsp, err := c.GetACL(ctx, tailnet, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetACLResponse(rsp) +} + +// SetACLWithBodyWithResponse request with arbitrary body returning *SetACLResponse +func (c *ClientWithResponses) SetACLWithBodyWithResponse(ctx context.Context, tailnet string, params *SetACLParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetACLResponse, error) { + rsp, err := c.SetACLWithBody(ctx, tailnet, params, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetACLResponse(rsp) +} + +func (c *ClientWithResponses) SetACLWithResponse(ctx context.Context, tailnet string, params *SetACLParams, body SetACLJSONRequestBody, reqEditors ...RequestEditorFn) (*SetACLResponse, error) { + rsp, err := c.SetACL(ctx, tailnet, params, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetACLResponse(rsp) +} + +// ListDevicesWithResponse request returning *ListDevicesResponse +func (c *ClientWithResponses) ListDevicesWithResponse(ctx context.Context, tailnet string, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*ListDevicesResponse, error) { + rsp, err := c.ListDevices(ctx, tailnet, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListDevicesResponse(rsp) +} + +// ListKeysWithResponse request returning *ListKeysResponse +func (c *ClientWithResponses) ListKeysWithResponse(ctx context.Context, tailnet string, params *ListKeysParams, reqEditors ...RequestEditorFn) (*ListKeysResponse, error) { + rsp, err := c.ListKeys(ctx, tailnet, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListKeysResponse(rsp) +} + +// CreateKeyWithBodyWithResponse request with arbitrary body returning *CreateKeyResponse +func (c *ClientWithResponses) CreateKeyWithBodyWithResponse(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateKeyResponse, error) { + rsp, err := c.CreateKeyWithBody(ctx, tailnet, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateKeyResponse(rsp) +} + +func (c *ClientWithResponses) CreateKeyWithResponse(ctx context.Context, tailnet string, body CreateKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateKeyResponse, error) { + rsp, err := c.CreateKey(ctx, tailnet, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateKeyResponse(rsp) +} + +// DeleteKeyWithResponse request returning *DeleteKeyResponse +func (c *ClientWithResponses) DeleteKeyWithResponse(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*DeleteKeyResponse, error) { + rsp, err := c.DeleteKey(ctx, tailnet, keyId, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteKeyResponse(rsp) +} + +// GetKeyWithResponse request returning *GetKeyResponse +func (c *ClientWithResponses) GetKeyWithResponse(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*GetKeyResponse, error) { + rsp, err := c.GetKey(ctx, tailnet, keyId, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetKeyResponse(rsp) +} + +// GetTailnetSettingsWithResponse request returning *GetTailnetSettingsResponse +func (c *ClientWithResponses) GetTailnetSettingsWithResponse(ctx context.Context, tailnet string, reqEditors ...RequestEditorFn) (*GetTailnetSettingsResponse, error) { + rsp, err := c.GetTailnetSettings(ctx, tailnet, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetTailnetSettingsResponse(rsp) +} + +// UpdateTailnetSettingsWithBodyWithResponse request with arbitrary body returning *UpdateTailnetSettingsResponse +func (c *ClientWithResponses) UpdateTailnetSettingsWithBodyWithResponse(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateTailnetSettingsResponse, error) { + rsp, err := c.UpdateTailnetSettingsWithBody(ctx, tailnet, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUpdateTailnetSettingsResponse(rsp) +} + +func (c *ClientWithResponses) UpdateTailnetSettingsWithResponse(ctx context.Context, tailnet string, body UpdateTailnetSettingsJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateTailnetSettingsResponse, error) { + rsp, err := c.UpdateTailnetSettings(ctx, tailnet, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUpdateTailnetSettingsResponse(rsp) +} + +// ListUsersWithResponse request returning *ListUsersResponse +func (c *ClientWithResponses) ListUsersWithResponse(ctx context.Context, tailnet string, params *ListUsersParams, reqEditors ...RequestEditorFn) (*ListUsersResponse, error) { + rsp, err := c.ListUsers(ctx, tailnet, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListUsersResponse(rsp) +} + +// GetUserWithResponse request returning *GetUserResponse +func (c *ClientWithResponses) GetUserWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetUserResponse, error) { + rsp, err := c.GetUser(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetUserResponse(rsp) +} + +// ParseDeleteDeviceResponse parses an HTTP response from a DeleteDeviceWithResponse call +func ParseDeleteDeviceResponse(rsp *http.Response) (*DeleteDeviceResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteDeviceResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest EmptyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseGetDeviceResponse parses an HTTP response from a GetDeviceWithResponse call +func ParseGetDeviceResponse(rsp *http.Response) (*GetDeviceResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetDeviceResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Device + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseAuthorizeDeviceResponse parses an HTTP response from a AuthorizeDeviceWithResponse call +func ParseAuthorizeDeviceResponse(rsp *http.Response) (*AuthorizeDeviceResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &AuthorizeDeviceResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest EmptyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseSetDeviceKeyResponse parses an HTTP response from a SetDeviceKeyWithResponse call +func ParseSetDeviceKeyResponse(rsp *http.Response) (*SetDeviceKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetDeviceKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest EmptyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseSetDeviceNameResponse parses an HTTP response from a SetDeviceNameWithResponse call +func ParseSetDeviceNameResponse(rsp *http.Response) (*SetDeviceNameResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetDeviceNameResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest EmptyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseGetDeviceRoutesResponse parses an HTTP response from a GetDeviceRoutesWithResponse call +func ParseGetDeviceRoutesResponse(rsp *http.Response) (*GetDeviceRoutesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetDeviceRoutesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeviceRoutes + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseSetDeviceRoutesResponse parses an HTTP response from a SetDeviceRoutesWithResponse call +func ParseSetDeviceRoutesResponse(rsp *http.Response) (*SetDeviceRoutesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetDeviceRoutesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeviceRoutes + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseSetDeviceTagsResponse parses an HTTP response from a SetDeviceTagsWithResponse call +func ParseSetDeviceTagsResponse(rsp *http.Response) (*SetDeviceTagsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetDeviceTagsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest EmptyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseGetACLResponse parses an HTTP response from a GetACLWithResponse call +func ParseGetACLResponse(rsp *http.Response) (*GetACLResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetACLResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseSetACLResponse parses an HTTP response from a SetACLWithResponse call +func ParseSetACLResponse(rsp *http.Response) (*SetACLResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetACLResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 412: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON412 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseListDevicesResponse parses an HTTP response from a ListDevicesWithResponse call +func ParseListDevicesResponse(rsp *http.Response) (*ListDevicesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListDevicesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListDevicesOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseListKeysResponse parses an HTTP response from a ListKeysWithResponse call +func ParseListKeysResponse(rsp *http.Response) (*ListKeysResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListKeysResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListKeysOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseCreateKeyResponse parses an HTTP response from a CreateKeyWithResponse call +func ParseCreateKeyResponse(rsp *http.Response) (*CreateKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Key + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseDeleteKeyResponse parses an HTTP response from a DeleteKeyWithResponse call +func ParseDeleteKeyResponse(rsp *http.Response) (*DeleteKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeleteKeyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseGetKeyResponse parses an HTTP response from a GetKeyWithResponse call +func ParseGetKeyResponse(rsp *http.Response) (*GetKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Key + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseGetTailnetSettingsResponse parses an HTTP response from a GetTailnetSettingsWithResponse call +func ParseGetTailnetSettingsResponse(rsp *http.Response) (*GetTailnetSettingsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetTailnetSettingsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest TailnetSettings + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseUpdateTailnetSettingsResponse parses an HTTP response from a UpdateTailnetSettingsWithResponse call +func ParseUpdateTailnetSettingsResponse(rsp *http.Response) (*UpdateTailnetSettingsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &UpdateTailnetSettingsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest TailnetSettings + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest + + } + + return response, nil +} + +// ParseListUsersResponse parses an HTTP response from a ListUsersWithResponse call +func ParseListUsersResponse(rsp *http.Response) (*ListUsersResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListUsersResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListUsersOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseGetUserResponse parses an HTTP response from a GetUserWithResponse call +func ParseGetUserResponse(rsp *http.Response) (*GetUserResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetUserResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest User + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} diff --git a/gen/go/headscale/v1/apikey.pb.go b/gen/go/headscale/v1/apikey.pb.go deleted file mode 100644 index 0c8557384..000000000 --- a/gen/go/headscale/v1/apikey.pb.go +++ /dev/null @@ -1,537 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/apikey.proto - -package v1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type ApiKey struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - Prefix string `protobuf:"bytes,2,opt,name=prefix,proto3" json:"prefix,omitempty"` - Expiration *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expiration,proto3" json:"expiration,omitempty"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - LastSeen *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=last_seen,json=lastSeen,proto3" json:"last_seen,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ApiKey) Reset() { - *x = ApiKey{} - mi := &file_headscale_v1_apikey_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ApiKey) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ApiKey) ProtoMessage() {} - -func (x *ApiKey) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ApiKey.ProtoReflect.Descriptor instead. -func (*ApiKey) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{0} -} - -func (x *ApiKey) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -func (x *ApiKey) GetPrefix() string { - if x != nil { - return x.Prefix - } - return "" -} - -func (x *ApiKey) GetExpiration() *timestamppb.Timestamp { - if x != nil { - return x.Expiration - } - return nil -} - -func (x *ApiKey) GetCreatedAt() *timestamppb.Timestamp { - if x != nil { - return x.CreatedAt - } - return nil -} - -func (x *ApiKey) GetLastSeen() *timestamppb.Timestamp { - if x != nil { - return x.LastSeen - } - return nil -} - -type CreateApiKeyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Expiration *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=expiration,proto3" json:"expiration,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateApiKeyRequest) Reset() { - *x = CreateApiKeyRequest{} - mi := &file_headscale_v1_apikey_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateApiKeyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateApiKeyRequest) ProtoMessage() {} - -func (x *CreateApiKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreateApiKeyRequest.ProtoReflect.Descriptor instead. -func (*CreateApiKeyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{1} -} - -func (x *CreateApiKeyRequest) GetExpiration() *timestamppb.Timestamp { - if x != nil { - return x.Expiration - } - return nil -} - -type CreateApiKeyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - ApiKey string `protobuf:"bytes,1,opt,name=api_key,json=apiKey,proto3" json:"api_key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateApiKeyResponse) Reset() { - *x = CreateApiKeyResponse{} - mi := &file_headscale_v1_apikey_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateApiKeyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateApiKeyResponse) ProtoMessage() {} - -func (x *CreateApiKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreateApiKeyResponse.ProtoReflect.Descriptor instead. -func (*CreateApiKeyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{2} -} - -func (x *CreateApiKeyResponse) GetApiKey() string { - if x != nil { - return x.ApiKey - } - return "" -} - -type ExpireApiKeyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Prefix string `protobuf:"bytes,1,opt,name=prefix,proto3" json:"prefix,omitempty"` - Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExpireApiKeyRequest) Reset() { - *x = ExpireApiKeyRequest{} - mi := &file_headscale_v1_apikey_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExpireApiKeyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExpireApiKeyRequest) ProtoMessage() {} - -func (x *ExpireApiKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExpireApiKeyRequest.ProtoReflect.Descriptor instead. -func (*ExpireApiKeyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{3} -} - -func (x *ExpireApiKeyRequest) GetPrefix() string { - if x != nil { - return x.Prefix - } - return "" -} - -func (x *ExpireApiKeyRequest) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -type ExpireApiKeyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExpireApiKeyResponse) Reset() { - *x = ExpireApiKeyResponse{} - mi := &file_headscale_v1_apikey_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExpireApiKeyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExpireApiKeyResponse) ProtoMessage() {} - -func (x *ExpireApiKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExpireApiKeyResponse.ProtoReflect.Descriptor instead. -func (*ExpireApiKeyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{4} -} - -type ListApiKeysRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListApiKeysRequest) Reset() { - *x = ListApiKeysRequest{} - mi := &file_headscale_v1_apikey_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListApiKeysRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListApiKeysRequest) ProtoMessage() {} - -func (x *ListApiKeysRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListApiKeysRequest.ProtoReflect.Descriptor instead. -func (*ListApiKeysRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{5} -} - -type ListApiKeysResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - ApiKeys []*ApiKey `protobuf:"bytes,1,rep,name=api_keys,json=apiKeys,proto3" json:"api_keys,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListApiKeysResponse) Reset() { - *x = ListApiKeysResponse{} - mi := &file_headscale_v1_apikey_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListApiKeysResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListApiKeysResponse) ProtoMessage() {} - -func (x *ListApiKeysResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListApiKeysResponse.ProtoReflect.Descriptor instead. -func (*ListApiKeysResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{6} -} - -func (x *ListApiKeysResponse) GetApiKeys() []*ApiKey { - if x != nil { - return x.ApiKeys - } - return nil -} - -type DeleteApiKeyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Prefix string `protobuf:"bytes,1,opt,name=prefix,proto3" json:"prefix,omitempty"` - Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteApiKeyRequest) Reset() { - *x = DeleteApiKeyRequest{} - mi := &file_headscale_v1_apikey_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteApiKeyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteApiKeyRequest) ProtoMessage() {} - -func (x *DeleteApiKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteApiKeyRequest.ProtoReflect.Descriptor instead. -func (*DeleteApiKeyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{7} -} - -func (x *DeleteApiKeyRequest) GetPrefix() string { - if x != nil { - return x.Prefix - } - return "" -} - -func (x *DeleteApiKeyRequest) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -type DeleteApiKeyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteApiKeyResponse) Reset() { - *x = DeleteApiKeyResponse{} - mi := &file_headscale_v1_apikey_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteApiKeyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteApiKeyResponse) ProtoMessage() {} - -func (x *DeleteApiKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteApiKeyResponse.ProtoReflect.Descriptor instead. -func (*DeleteApiKeyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{8} -} - -var File_headscale_v1_apikey_proto protoreflect.FileDescriptor - -const file_headscale_v1_apikey_proto_rawDesc = "" + - "\n" + - "\x19headscale/v1/apikey.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xe0\x01\n" + - "\x06ApiKey\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x04R\x02id\x12\x16\n" + - "\x06prefix\x18\x02 \x01(\tR\x06prefix\x12:\n" + - "\n" + - "expiration\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "expiration\x129\n" + - "\n" + - "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x127\n" + - "\tlast_seen\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\blastSeen\"Q\n" + - "\x13CreateApiKeyRequest\x12:\n" + - "\n" + - "expiration\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "expiration\"/\n" + - "\x14CreateApiKeyResponse\x12\x17\n" + - "\aapi_key\x18\x01 \x01(\tR\x06apiKey\"=\n" + - "\x13ExpireApiKeyRequest\x12\x16\n" + - "\x06prefix\x18\x01 \x01(\tR\x06prefix\x12\x0e\n" + - "\x02id\x18\x02 \x01(\x04R\x02id\"\x16\n" + - "\x14ExpireApiKeyResponse\"\x14\n" + - "\x12ListApiKeysRequest\"F\n" + - "\x13ListApiKeysResponse\x12/\n" + - "\bapi_keys\x18\x01 \x03(\v2\x14.headscale.v1.ApiKeyR\aapiKeys\"=\n" + - "\x13DeleteApiKeyRequest\x12\x16\n" + - "\x06prefix\x18\x01 \x01(\tR\x06prefix\x12\x0e\n" + - "\x02id\x18\x02 \x01(\x04R\x02id\"\x16\n" + - "\x14DeleteApiKeyResponseB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_apikey_proto_rawDescOnce sync.Once - file_headscale_v1_apikey_proto_rawDescData []byte -) - -func file_headscale_v1_apikey_proto_rawDescGZIP() []byte { - file_headscale_v1_apikey_proto_rawDescOnce.Do(func() { - file_headscale_v1_apikey_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_apikey_proto_rawDesc), len(file_headscale_v1_apikey_proto_rawDesc))) - }) - return file_headscale_v1_apikey_proto_rawDescData -} - -var file_headscale_v1_apikey_proto_msgTypes = make([]protoimpl.MessageInfo, 9) -var file_headscale_v1_apikey_proto_goTypes = []any{ - (*ApiKey)(nil), // 0: headscale.v1.ApiKey - (*CreateApiKeyRequest)(nil), // 1: headscale.v1.CreateApiKeyRequest - (*CreateApiKeyResponse)(nil), // 2: headscale.v1.CreateApiKeyResponse - (*ExpireApiKeyRequest)(nil), // 3: headscale.v1.ExpireApiKeyRequest - (*ExpireApiKeyResponse)(nil), // 4: headscale.v1.ExpireApiKeyResponse - (*ListApiKeysRequest)(nil), // 5: headscale.v1.ListApiKeysRequest - (*ListApiKeysResponse)(nil), // 6: headscale.v1.ListApiKeysResponse - (*DeleteApiKeyRequest)(nil), // 7: headscale.v1.DeleteApiKeyRequest - (*DeleteApiKeyResponse)(nil), // 8: headscale.v1.DeleteApiKeyResponse - (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp -} -var file_headscale_v1_apikey_proto_depIdxs = []int32{ - 9, // 0: headscale.v1.ApiKey.expiration:type_name -> google.protobuf.Timestamp - 9, // 1: headscale.v1.ApiKey.created_at:type_name -> google.protobuf.Timestamp - 9, // 2: headscale.v1.ApiKey.last_seen:type_name -> google.protobuf.Timestamp - 9, // 3: headscale.v1.CreateApiKeyRequest.expiration:type_name -> google.protobuf.Timestamp - 0, // 4: headscale.v1.ListApiKeysResponse.api_keys:type_name -> headscale.v1.ApiKey - 5, // [5:5] is the sub-list for method output_type - 5, // [5:5] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name -} - -func init() { file_headscale_v1_apikey_proto_init() } -func file_headscale_v1_apikey_proto_init() { - if File_headscale_v1_apikey_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_apikey_proto_rawDesc), len(file_headscale_v1_apikey_proto_rawDesc)), - NumEnums: 0, - NumMessages: 9, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_headscale_v1_apikey_proto_goTypes, - DependencyIndexes: file_headscale_v1_apikey_proto_depIdxs, - MessageInfos: file_headscale_v1_apikey_proto_msgTypes, - }.Build() - File_headscale_v1_apikey_proto = out.File - file_headscale_v1_apikey_proto_goTypes = nil - file_headscale_v1_apikey_proto_depIdxs = nil -} diff --git a/gen/go/headscale/v1/auth.pb.go b/gen/go/headscale/v1/auth.pb.go deleted file mode 100644 index 2d6bf779e..000000000 --- a/gen/go/headscale/v1/auth.pb.go +++ /dev/null @@ -1,351 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/auth.proto - -package v1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type AuthRegisterRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - AuthId string `protobuf:"bytes,2,opt,name=auth_id,json=authId,proto3" json:"auth_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AuthRegisterRequest) Reset() { - *x = AuthRegisterRequest{} - mi := &file_headscale_v1_auth_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AuthRegisterRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AuthRegisterRequest) ProtoMessage() {} - -func (x *AuthRegisterRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_auth_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AuthRegisterRequest.ProtoReflect.Descriptor instead. -func (*AuthRegisterRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_auth_proto_rawDescGZIP(), []int{0} -} - -func (x *AuthRegisterRequest) GetUser() string { - if x != nil { - return x.User - } - return "" -} - -func (x *AuthRegisterRequest) GetAuthId() string { - if x != nil { - return x.AuthId - } - return "" -} - -type AuthRegisterResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AuthRegisterResponse) Reset() { - *x = AuthRegisterResponse{} - mi := &file_headscale_v1_auth_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AuthRegisterResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AuthRegisterResponse) ProtoMessage() {} - -func (x *AuthRegisterResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_auth_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AuthRegisterResponse.ProtoReflect.Descriptor instead. -func (*AuthRegisterResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_auth_proto_rawDescGZIP(), []int{1} -} - -func (x *AuthRegisterResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type AuthApproveRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - AuthId string `protobuf:"bytes,1,opt,name=auth_id,json=authId,proto3" json:"auth_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AuthApproveRequest) Reset() { - *x = AuthApproveRequest{} - mi := &file_headscale_v1_auth_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AuthApproveRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AuthApproveRequest) ProtoMessage() {} - -func (x *AuthApproveRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_auth_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AuthApproveRequest.ProtoReflect.Descriptor instead. -func (*AuthApproveRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_auth_proto_rawDescGZIP(), []int{2} -} - -func (x *AuthApproveRequest) GetAuthId() string { - if x != nil { - return x.AuthId - } - return "" -} - -type AuthApproveResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AuthApproveResponse) Reset() { - *x = AuthApproveResponse{} - mi := &file_headscale_v1_auth_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AuthApproveResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AuthApproveResponse) ProtoMessage() {} - -func (x *AuthApproveResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_auth_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AuthApproveResponse.ProtoReflect.Descriptor instead. -func (*AuthApproveResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_auth_proto_rawDescGZIP(), []int{3} -} - -type AuthRejectRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - AuthId string `protobuf:"bytes,1,opt,name=auth_id,json=authId,proto3" json:"auth_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AuthRejectRequest) Reset() { - *x = AuthRejectRequest{} - mi := &file_headscale_v1_auth_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AuthRejectRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AuthRejectRequest) ProtoMessage() {} - -func (x *AuthRejectRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_auth_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AuthRejectRequest.ProtoReflect.Descriptor instead. -func (*AuthRejectRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_auth_proto_rawDescGZIP(), []int{4} -} - -func (x *AuthRejectRequest) GetAuthId() string { - if x != nil { - return x.AuthId - } - return "" -} - -type AuthRejectResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AuthRejectResponse) Reset() { - *x = AuthRejectResponse{} - mi := &file_headscale_v1_auth_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AuthRejectResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AuthRejectResponse) ProtoMessage() {} - -func (x *AuthRejectResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_auth_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AuthRejectResponse.ProtoReflect.Descriptor instead. -func (*AuthRejectResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_auth_proto_rawDescGZIP(), []int{5} -} - -var File_headscale_v1_auth_proto protoreflect.FileDescriptor - -const file_headscale_v1_auth_proto_rawDesc = "" + - "\n" + - "\x17headscale/v1/auth.proto\x12\fheadscale.v1\x1a\x17headscale/v1/node.proto\"B\n" + - "\x13AuthRegisterRequest\x12\x12\n" + - "\x04user\x18\x01 \x01(\tR\x04user\x12\x17\n" + - "\aauth_id\x18\x02 \x01(\tR\x06authId\">\n" + - "\x14AuthRegisterResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"-\n" + - "\x12AuthApproveRequest\x12\x17\n" + - "\aauth_id\x18\x01 \x01(\tR\x06authId\"\x15\n" + - "\x13AuthApproveResponse\",\n" + - "\x11AuthRejectRequest\x12\x17\n" + - "\aauth_id\x18\x01 \x01(\tR\x06authId\"\x14\n" + - "\x12AuthRejectResponseB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_auth_proto_rawDescOnce sync.Once - file_headscale_v1_auth_proto_rawDescData []byte -) - -func file_headscale_v1_auth_proto_rawDescGZIP() []byte { - file_headscale_v1_auth_proto_rawDescOnce.Do(func() { - file_headscale_v1_auth_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_auth_proto_rawDesc), len(file_headscale_v1_auth_proto_rawDesc))) - }) - return file_headscale_v1_auth_proto_rawDescData -} - -var file_headscale_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 6) -var file_headscale_v1_auth_proto_goTypes = []any{ - (*AuthRegisterRequest)(nil), // 0: headscale.v1.AuthRegisterRequest - (*AuthRegisterResponse)(nil), // 1: headscale.v1.AuthRegisterResponse - (*AuthApproveRequest)(nil), // 2: headscale.v1.AuthApproveRequest - (*AuthApproveResponse)(nil), // 3: headscale.v1.AuthApproveResponse - (*AuthRejectRequest)(nil), // 4: headscale.v1.AuthRejectRequest - (*AuthRejectResponse)(nil), // 5: headscale.v1.AuthRejectResponse - (*Node)(nil), // 6: headscale.v1.Node -} -var file_headscale_v1_auth_proto_depIdxs = []int32{ - 6, // 0: headscale.v1.AuthRegisterResponse.node:type_name -> headscale.v1.Node - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name -} - -func init() { file_headscale_v1_auth_proto_init() } -func file_headscale_v1_auth_proto_init() { - if File_headscale_v1_auth_proto != nil { - return - } - file_headscale_v1_node_proto_init() - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_auth_proto_rawDesc), len(file_headscale_v1_auth_proto_rawDesc)), - NumEnums: 0, - NumMessages: 6, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_headscale_v1_auth_proto_goTypes, - DependencyIndexes: file_headscale_v1_auth_proto_depIdxs, - MessageInfos: file_headscale_v1_auth_proto_msgTypes, - }.Build() - File_headscale_v1_auth_proto = out.File - file_headscale_v1_auth_proto_goTypes = nil - file_headscale_v1_auth_proto_depIdxs = nil -} diff --git a/gen/go/headscale/v1/device.pb.go b/gen/go/headscale/v1/device.pb.go deleted file mode 100644 index e2362b05a..000000000 --- a/gen/go/headscale/v1/device.pb.go +++ /dev/null @@ -1,890 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/device.proto - -package v1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type Latency struct { - state protoimpl.MessageState `protogen:"open.v1"` - LatencyMs float32 `protobuf:"fixed32,1,opt,name=latency_ms,json=latencyMs,proto3" json:"latency_ms,omitempty"` - Preferred bool `protobuf:"varint,2,opt,name=preferred,proto3" json:"preferred,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Latency) Reset() { - *x = Latency{} - mi := &file_headscale_v1_device_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Latency) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Latency) ProtoMessage() {} - -func (x *Latency) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Latency.ProtoReflect.Descriptor instead. -func (*Latency) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{0} -} - -func (x *Latency) GetLatencyMs() float32 { - if x != nil { - return x.LatencyMs - } - return 0 -} - -func (x *Latency) GetPreferred() bool { - if x != nil { - return x.Preferred - } - return false -} - -type ClientSupports struct { - state protoimpl.MessageState `protogen:"open.v1"` - HairPinning bool `protobuf:"varint,1,opt,name=hair_pinning,json=hairPinning,proto3" json:"hair_pinning,omitempty"` - Ipv6 bool `protobuf:"varint,2,opt,name=ipv6,proto3" json:"ipv6,omitempty"` - Pcp bool `protobuf:"varint,3,opt,name=pcp,proto3" json:"pcp,omitempty"` - Pmp bool `protobuf:"varint,4,opt,name=pmp,proto3" json:"pmp,omitempty"` - Udp bool `protobuf:"varint,5,opt,name=udp,proto3" json:"udp,omitempty"` - Upnp bool `protobuf:"varint,6,opt,name=upnp,proto3" json:"upnp,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientSupports) Reset() { - *x = ClientSupports{} - mi := &file_headscale_v1_device_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientSupports) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientSupports) ProtoMessage() {} - -func (x *ClientSupports) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientSupports.ProtoReflect.Descriptor instead. -func (*ClientSupports) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{1} -} - -func (x *ClientSupports) GetHairPinning() bool { - if x != nil { - return x.HairPinning - } - return false -} - -func (x *ClientSupports) GetIpv6() bool { - if x != nil { - return x.Ipv6 - } - return false -} - -func (x *ClientSupports) GetPcp() bool { - if x != nil { - return x.Pcp - } - return false -} - -func (x *ClientSupports) GetPmp() bool { - if x != nil { - return x.Pmp - } - return false -} - -func (x *ClientSupports) GetUdp() bool { - if x != nil { - return x.Udp - } - return false -} - -func (x *ClientSupports) GetUpnp() bool { - if x != nil { - return x.Upnp - } - return false -} - -type ClientConnectivity struct { - state protoimpl.MessageState `protogen:"open.v1"` - Endpoints []string `protobuf:"bytes,1,rep,name=endpoints,proto3" json:"endpoints,omitempty"` - Derp string `protobuf:"bytes,2,opt,name=derp,proto3" json:"derp,omitempty"` - MappingVariesByDestIp bool `protobuf:"varint,3,opt,name=mapping_varies_by_dest_ip,json=mappingVariesByDestIp,proto3" json:"mapping_varies_by_dest_ip,omitempty"` - Latency map[string]*Latency `protobuf:"bytes,4,rep,name=latency,proto3" json:"latency,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - ClientSupports *ClientSupports `protobuf:"bytes,5,opt,name=client_supports,json=clientSupports,proto3" json:"client_supports,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientConnectivity) Reset() { - *x = ClientConnectivity{} - mi := &file_headscale_v1_device_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientConnectivity) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientConnectivity) ProtoMessage() {} - -func (x *ClientConnectivity) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientConnectivity.ProtoReflect.Descriptor instead. -func (*ClientConnectivity) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{2} -} - -func (x *ClientConnectivity) GetEndpoints() []string { - if x != nil { - return x.Endpoints - } - return nil -} - -func (x *ClientConnectivity) GetDerp() string { - if x != nil { - return x.Derp - } - return "" -} - -func (x *ClientConnectivity) GetMappingVariesByDestIp() bool { - if x != nil { - return x.MappingVariesByDestIp - } - return false -} - -func (x *ClientConnectivity) GetLatency() map[string]*Latency { - if x != nil { - return x.Latency - } - return nil -} - -func (x *ClientConnectivity) GetClientSupports() *ClientSupports { - if x != nil { - return x.ClientSupports - } - return nil -} - -type GetDeviceRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetDeviceRequest) Reset() { - *x = GetDeviceRequest{} - mi := &file_headscale_v1_device_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetDeviceRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetDeviceRequest) ProtoMessage() {} - -func (x *GetDeviceRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetDeviceRequest.ProtoReflect.Descriptor instead. -func (*GetDeviceRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{3} -} - -func (x *GetDeviceRequest) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -type GetDeviceResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Addresses []string `protobuf:"bytes,1,rep,name=addresses,proto3" json:"addresses,omitempty"` - Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` - User string `protobuf:"bytes,3,opt,name=user,proto3" json:"user,omitempty"` - Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` - Hostname string `protobuf:"bytes,5,opt,name=hostname,proto3" json:"hostname,omitempty"` - ClientVersion string `protobuf:"bytes,6,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` - UpdateAvailable bool `protobuf:"varint,7,opt,name=update_available,json=updateAvailable,proto3" json:"update_available,omitempty"` - Os string `protobuf:"bytes,8,opt,name=os,proto3" json:"os,omitempty"` - Created *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created,proto3" json:"created,omitempty"` - LastSeen *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=last_seen,json=lastSeen,proto3" json:"last_seen,omitempty"` - KeyExpiryDisabled bool `protobuf:"varint,11,opt,name=key_expiry_disabled,json=keyExpiryDisabled,proto3" json:"key_expiry_disabled,omitempty"` - Expires *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=expires,proto3" json:"expires,omitempty"` - Authorized bool `protobuf:"varint,13,opt,name=authorized,proto3" json:"authorized,omitempty"` - IsExternal bool `protobuf:"varint,14,opt,name=is_external,json=isExternal,proto3" json:"is_external,omitempty"` - MachineKey string `protobuf:"bytes,15,opt,name=machine_key,json=machineKey,proto3" json:"machine_key,omitempty"` - NodeKey string `protobuf:"bytes,16,opt,name=node_key,json=nodeKey,proto3" json:"node_key,omitempty"` - BlocksIncomingConnections bool `protobuf:"varint,17,opt,name=blocks_incoming_connections,json=blocksIncomingConnections,proto3" json:"blocks_incoming_connections,omitempty"` - EnabledRoutes []string `protobuf:"bytes,18,rep,name=enabled_routes,json=enabledRoutes,proto3" json:"enabled_routes,omitempty"` - AdvertisedRoutes []string `protobuf:"bytes,19,rep,name=advertised_routes,json=advertisedRoutes,proto3" json:"advertised_routes,omitempty"` - ClientConnectivity *ClientConnectivity `protobuf:"bytes,20,opt,name=client_connectivity,json=clientConnectivity,proto3" json:"client_connectivity,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetDeviceResponse) Reset() { - *x = GetDeviceResponse{} - mi := &file_headscale_v1_device_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetDeviceResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetDeviceResponse) ProtoMessage() {} - -func (x *GetDeviceResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetDeviceResponse.ProtoReflect.Descriptor instead. -func (*GetDeviceResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{4} -} - -func (x *GetDeviceResponse) GetAddresses() []string { - if x != nil { - return x.Addresses - } - return nil -} - -func (x *GetDeviceResponse) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *GetDeviceResponse) GetUser() string { - if x != nil { - return x.User - } - return "" -} - -func (x *GetDeviceResponse) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *GetDeviceResponse) GetHostname() string { - if x != nil { - return x.Hostname - } - return "" -} - -func (x *GetDeviceResponse) GetClientVersion() string { - if x != nil { - return x.ClientVersion - } - return "" -} - -func (x *GetDeviceResponse) GetUpdateAvailable() bool { - if x != nil { - return x.UpdateAvailable - } - return false -} - -func (x *GetDeviceResponse) GetOs() string { - if x != nil { - return x.Os - } - return "" -} - -func (x *GetDeviceResponse) GetCreated() *timestamppb.Timestamp { - if x != nil { - return x.Created - } - return nil -} - -func (x *GetDeviceResponse) GetLastSeen() *timestamppb.Timestamp { - if x != nil { - return x.LastSeen - } - return nil -} - -func (x *GetDeviceResponse) GetKeyExpiryDisabled() bool { - if x != nil { - return x.KeyExpiryDisabled - } - return false -} - -func (x *GetDeviceResponse) GetExpires() *timestamppb.Timestamp { - if x != nil { - return x.Expires - } - return nil -} - -func (x *GetDeviceResponse) GetAuthorized() bool { - if x != nil { - return x.Authorized - } - return false -} - -func (x *GetDeviceResponse) GetIsExternal() bool { - if x != nil { - return x.IsExternal - } - return false -} - -func (x *GetDeviceResponse) GetMachineKey() string { - if x != nil { - return x.MachineKey - } - return "" -} - -func (x *GetDeviceResponse) GetNodeKey() string { - if x != nil { - return x.NodeKey - } - return "" -} - -func (x *GetDeviceResponse) GetBlocksIncomingConnections() bool { - if x != nil { - return x.BlocksIncomingConnections - } - return false -} - -func (x *GetDeviceResponse) GetEnabledRoutes() []string { - if x != nil { - return x.EnabledRoutes - } - return nil -} - -func (x *GetDeviceResponse) GetAdvertisedRoutes() []string { - if x != nil { - return x.AdvertisedRoutes - } - return nil -} - -func (x *GetDeviceResponse) GetClientConnectivity() *ClientConnectivity { - if x != nil { - return x.ClientConnectivity - } - return nil -} - -type DeleteDeviceRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteDeviceRequest) Reset() { - *x = DeleteDeviceRequest{} - mi := &file_headscale_v1_device_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteDeviceRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteDeviceRequest) ProtoMessage() {} - -func (x *DeleteDeviceRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteDeviceRequest.ProtoReflect.Descriptor instead. -func (*DeleteDeviceRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{5} -} - -func (x *DeleteDeviceRequest) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -type DeleteDeviceResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteDeviceResponse) Reset() { - *x = DeleteDeviceResponse{} - mi := &file_headscale_v1_device_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteDeviceResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteDeviceResponse) ProtoMessage() {} - -func (x *DeleteDeviceResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteDeviceResponse.ProtoReflect.Descriptor instead. -func (*DeleteDeviceResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{6} -} - -type GetDeviceRoutesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetDeviceRoutesRequest) Reset() { - *x = GetDeviceRoutesRequest{} - mi := &file_headscale_v1_device_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetDeviceRoutesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetDeviceRoutesRequest) ProtoMessage() {} - -func (x *GetDeviceRoutesRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetDeviceRoutesRequest.ProtoReflect.Descriptor instead. -func (*GetDeviceRoutesRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{7} -} - -func (x *GetDeviceRoutesRequest) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -type GetDeviceRoutesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - EnabledRoutes []string `protobuf:"bytes,1,rep,name=enabled_routes,json=enabledRoutes,proto3" json:"enabled_routes,omitempty"` - AdvertisedRoutes []string `protobuf:"bytes,2,rep,name=advertised_routes,json=advertisedRoutes,proto3" json:"advertised_routes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetDeviceRoutesResponse) Reset() { - *x = GetDeviceRoutesResponse{} - mi := &file_headscale_v1_device_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetDeviceRoutesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetDeviceRoutesResponse) ProtoMessage() {} - -func (x *GetDeviceRoutesResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetDeviceRoutesResponse.ProtoReflect.Descriptor instead. -func (*GetDeviceRoutesResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{8} -} - -func (x *GetDeviceRoutesResponse) GetEnabledRoutes() []string { - if x != nil { - return x.EnabledRoutes - } - return nil -} - -func (x *GetDeviceRoutesResponse) GetAdvertisedRoutes() []string { - if x != nil { - return x.AdvertisedRoutes - } - return nil -} - -type EnableDeviceRoutesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Routes []string `protobuf:"bytes,2,rep,name=routes,proto3" json:"routes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EnableDeviceRoutesRequest) Reset() { - *x = EnableDeviceRoutesRequest{} - mi := &file_headscale_v1_device_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EnableDeviceRoutesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EnableDeviceRoutesRequest) ProtoMessage() {} - -func (x *EnableDeviceRoutesRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[9] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EnableDeviceRoutesRequest.ProtoReflect.Descriptor instead. -func (*EnableDeviceRoutesRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{9} -} - -func (x *EnableDeviceRoutesRequest) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *EnableDeviceRoutesRequest) GetRoutes() []string { - if x != nil { - return x.Routes - } - return nil -} - -type EnableDeviceRoutesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - EnabledRoutes []string `protobuf:"bytes,1,rep,name=enabled_routes,json=enabledRoutes,proto3" json:"enabled_routes,omitempty"` - AdvertisedRoutes []string `protobuf:"bytes,2,rep,name=advertised_routes,json=advertisedRoutes,proto3" json:"advertised_routes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EnableDeviceRoutesResponse) Reset() { - *x = EnableDeviceRoutesResponse{} - mi := &file_headscale_v1_device_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EnableDeviceRoutesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EnableDeviceRoutesResponse) ProtoMessage() {} - -func (x *EnableDeviceRoutesResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[10] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EnableDeviceRoutesResponse.ProtoReflect.Descriptor instead. -func (*EnableDeviceRoutesResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{10} -} - -func (x *EnableDeviceRoutesResponse) GetEnabledRoutes() []string { - if x != nil { - return x.EnabledRoutes - } - return nil -} - -func (x *EnableDeviceRoutesResponse) GetAdvertisedRoutes() []string { - if x != nil { - return x.AdvertisedRoutes - } - return nil -} - -var File_headscale_v1_device_proto protoreflect.FileDescriptor - -const file_headscale_v1_device_proto_rawDesc = "" + - "\n" + - "\x19headscale/v1/device.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"F\n" + - "\aLatency\x12\x1d\n" + - "\n" + - "latency_ms\x18\x01 \x01(\x02R\tlatencyMs\x12\x1c\n" + - "\tpreferred\x18\x02 \x01(\bR\tpreferred\"\x91\x01\n" + - "\x0eClientSupports\x12!\n" + - "\fhair_pinning\x18\x01 \x01(\bR\vhairPinning\x12\x12\n" + - "\x04ipv6\x18\x02 \x01(\bR\x04ipv6\x12\x10\n" + - "\x03pcp\x18\x03 \x01(\bR\x03pcp\x12\x10\n" + - "\x03pmp\x18\x04 \x01(\bR\x03pmp\x12\x10\n" + - "\x03udp\x18\x05 \x01(\bR\x03udp\x12\x12\n" + - "\x04upnp\x18\x06 \x01(\bR\x04upnp\"\xe3\x02\n" + - "\x12ClientConnectivity\x12\x1c\n" + - "\tendpoints\x18\x01 \x03(\tR\tendpoints\x12\x12\n" + - "\x04derp\x18\x02 \x01(\tR\x04derp\x128\n" + - "\x19mapping_varies_by_dest_ip\x18\x03 \x01(\bR\x15mappingVariesByDestIp\x12G\n" + - "\alatency\x18\x04 \x03(\v2-.headscale.v1.ClientConnectivity.LatencyEntryR\alatency\x12E\n" + - "\x0fclient_supports\x18\x05 \x01(\v2\x1c.headscale.v1.ClientSupportsR\x0eclientSupports\x1aQ\n" + - "\fLatencyEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12+\n" + - "\x05value\x18\x02 \x01(\v2\x15.headscale.v1.LatencyR\x05value:\x028\x01\"\"\n" + - "\x10GetDeviceRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"\xa0\x06\n" + - "\x11GetDeviceResponse\x12\x1c\n" + - "\taddresses\x18\x01 \x03(\tR\taddresses\x12\x0e\n" + - "\x02id\x18\x02 \x01(\tR\x02id\x12\x12\n" + - "\x04user\x18\x03 \x01(\tR\x04user\x12\x12\n" + - "\x04name\x18\x04 \x01(\tR\x04name\x12\x1a\n" + - "\bhostname\x18\x05 \x01(\tR\bhostname\x12%\n" + - "\x0eclient_version\x18\x06 \x01(\tR\rclientVersion\x12)\n" + - "\x10update_available\x18\a \x01(\bR\x0fupdateAvailable\x12\x0e\n" + - "\x02os\x18\b \x01(\tR\x02os\x124\n" + - "\acreated\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\acreated\x127\n" + - "\tlast_seen\x18\n" + - " \x01(\v2\x1a.google.protobuf.TimestampR\blastSeen\x12.\n" + - "\x13key_expiry_disabled\x18\v \x01(\bR\x11keyExpiryDisabled\x124\n" + - "\aexpires\x18\f \x01(\v2\x1a.google.protobuf.TimestampR\aexpires\x12\x1e\n" + - "\n" + - "authorized\x18\r \x01(\bR\n" + - "authorized\x12\x1f\n" + - "\vis_external\x18\x0e \x01(\bR\n" + - "isExternal\x12\x1f\n" + - "\vmachine_key\x18\x0f \x01(\tR\n" + - "machineKey\x12\x19\n" + - "\bnode_key\x18\x10 \x01(\tR\anodeKey\x12>\n" + - "\x1bblocks_incoming_connections\x18\x11 \x01(\bR\x19blocksIncomingConnections\x12%\n" + - "\x0eenabled_routes\x18\x12 \x03(\tR\renabledRoutes\x12+\n" + - "\x11advertised_routes\x18\x13 \x03(\tR\x10advertisedRoutes\x12Q\n" + - "\x13client_connectivity\x18\x14 \x01(\v2 .headscale.v1.ClientConnectivityR\x12clientConnectivity\"%\n" + - "\x13DeleteDeviceRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"\x16\n" + - "\x14DeleteDeviceResponse\"(\n" + - "\x16GetDeviceRoutesRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"m\n" + - "\x17GetDeviceRoutesResponse\x12%\n" + - "\x0eenabled_routes\x18\x01 \x03(\tR\renabledRoutes\x12+\n" + - "\x11advertised_routes\x18\x02 \x03(\tR\x10advertisedRoutes\"C\n" + - "\x19EnableDeviceRoutesRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + - "\x06routes\x18\x02 \x03(\tR\x06routes\"p\n" + - "\x1aEnableDeviceRoutesResponse\x12%\n" + - "\x0eenabled_routes\x18\x01 \x03(\tR\renabledRoutes\x12+\n" + - "\x11advertised_routes\x18\x02 \x03(\tR\x10advertisedRoutesB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_device_proto_rawDescOnce sync.Once - file_headscale_v1_device_proto_rawDescData []byte -) - -func file_headscale_v1_device_proto_rawDescGZIP() []byte { - file_headscale_v1_device_proto_rawDescOnce.Do(func() { - file_headscale_v1_device_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_device_proto_rawDesc), len(file_headscale_v1_device_proto_rawDesc))) - }) - return file_headscale_v1_device_proto_rawDescData -} - -var file_headscale_v1_device_proto_msgTypes = make([]protoimpl.MessageInfo, 12) -var file_headscale_v1_device_proto_goTypes = []any{ - (*Latency)(nil), // 0: headscale.v1.Latency - (*ClientSupports)(nil), // 1: headscale.v1.ClientSupports - (*ClientConnectivity)(nil), // 2: headscale.v1.ClientConnectivity - (*GetDeviceRequest)(nil), // 3: headscale.v1.GetDeviceRequest - (*GetDeviceResponse)(nil), // 4: headscale.v1.GetDeviceResponse - (*DeleteDeviceRequest)(nil), // 5: headscale.v1.DeleteDeviceRequest - (*DeleteDeviceResponse)(nil), // 6: headscale.v1.DeleteDeviceResponse - (*GetDeviceRoutesRequest)(nil), // 7: headscale.v1.GetDeviceRoutesRequest - (*GetDeviceRoutesResponse)(nil), // 8: headscale.v1.GetDeviceRoutesResponse - (*EnableDeviceRoutesRequest)(nil), // 9: headscale.v1.EnableDeviceRoutesRequest - (*EnableDeviceRoutesResponse)(nil), // 10: headscale.v1.EnableDeviceRoutesResponse - nil, // 11: headscale.v1.ClientConnectivity.LatencyEntry - (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp -} -var file_headscale_v1_device_proto_depIdxs = []int32{ - 11, // 0: headscale.v1.ClientConnectivity.latency:type_name -> headscale.v1.ClientConnectivity.LatencyEntry - 1, // 1: headscale.v1.ClientConnectivity.client_supports:type_name -> headscale.v1.ClientSupports - 12, // 2: headscale.v1.GetDeviceResponse.created:type_name -> google.protobuf.Timestamp - 12, // 3: headscale.v1.GetDeviceResponse.last_seen:type_name -> google.protobuf.Timestamp - 12, // 4: headscale.v1.GetDeviceResponse.expires:type_name -> google.protobuf.Timestamp - 2, // 5: headscale.v1.GetDeviceResponse.client_connectivity:type_name -> headscale.v1.ClientConnectivity - 0, // 6: headscale.v1.ClientConnectivity.LatencyEntry.value:type_name -> headscale.v1.Latency - 7, // [7:7] is the sub-list for method output_type - 7, // [7:7] is the sub-list for method input_type - 7, // [7:7] is the sub-list for extension type_name - 7, // [7:7] is the sub-list for extension extendee - 0, // [0:7] is the sub-list for field type_name -} - -func init() { file_headscale_v1_device_proto_init() } -func file_headscale_v1_device_proto_init() { - if File_headscale_v1_device_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_device_proto_rawDesc), len(file_headscale_v1_device_proto_rawDesc)), - NumEnums: 0, - NumMessages: 12, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_headscale_v1_device_proto_goTypes, - DependencyIndexes: file_headscale_v1_device_proto_depIdxs, - MessageInfos: file_headscale_v1_device_proto_msgTypes, - }.Build() - File_headscale_v1_device_proto = out.File - file_headscale_v1_device_proto_goTypes = nil - file_headscale_v1_device_proto_depIdxs = nil -} diff --git a/gen/go/headscale/v1/headscale.pb.go b/gen/go/headscale/v1/headscale.pb.go deleted file mode 100644 index 4a1875126..000000000 --- a/gen/go/headscale/v1/headscale.pb.go +++ /dev/null @@ -1,318 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/headscale.proto - -package v1 - -import ( - _ "google.golang.org/genproto/googleapis/api/annotations" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type HealthRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *HealthRequest) Reset() { - *x = HealthRequest{} - mi := &file_headscale_v1_headscale_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *HealthRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*HealthRequest) ProtoMessage() {} - -func (x *HealthRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_headscale_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead. -func (*HealthRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_headscale_proto_rawDescGZIP(), []int{0} -} - -type HealthResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - DatabaseConnectivity bool `protobuf:"varint,1,opt,name=database_connectivity,json=databaseConnectivity,proto3" json:"database_connectivity,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *HealthResponse) Reset() { - *x = HealthResponse{} - mi := &file_headscale_v1_headscale_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *HealthResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*HealthResponse) ProtoMessage() {} - -func (x *HealthResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_headscale_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. -func (*HealthResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_headscale_proto_rawDescGZIP(), []int{1} -} - -func (x *HealthResponse) GetDatabaseConnectivity() bool { - if x != nil { - return x.DatabaseConnectivity - } - return false -} - -var File_headscale_v1_headscale_proto protoreflect.FileDescriptor - -const file_headscale_v1_headscale_proto_rawDesc = "" + - "\n" + - "\x1cheadscale/v1/headscale.proto\x12\fheadscale.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17headscale/v1/user.proto\x1a\x1dheadscale/v1/preauthkey.proto\x1a\x17headscale/v1/node.proto\x1a\x19headscale/v1/apikey.proto\x1a\x17headscale/v1/auth.proto\x1a\x19headscale/v1/policy.proto\"\x0f\n" + - "\rHealthRequest\"E\n" + - "\x0eHealthResponse\x123\n" + - "\x15database_connectivity\x18\x01 \x01(\bR\x14databaseConnectivity2\xe0\x1a\n" + - "\x10HeadscaleService\x12h\n" + - "\n" + - "CreateUser\x12\x1f.headscale.v1.CreateUserRequest\x1a .headscale.v1.CreateUserResponse\"\x17\x82\xd3\xe4\x93\x02\x11:\x01*\"\f/api/v1/user\x12\x80\x01\n" + - "\n" + - "RenameUser\x12\x1f.headscale.v1.RenameUserRequest\x1a .headscale.v1.RenameUserResponse\"/\x82\xd3\xe4\x93\x02)\"'/api/v1/user/{old_id}/rename/{new_name}\x12j\n" + - "\n" + - "DeleteUser\x12\x1f.headscale.v1.DeleteUserRequest\x1a .headscale.v1.DeleteUserResponse\"\x19\x82\xd3\xe4\x93\x02\x13*\x11/api/v1/user/{id}\x12b\n" + - "\tListUsers\x12\x1e.headscale.v1.ListUsersRequest\x1a\x1f.headscale.v1.ListUsersResponse\"\x14\x82\xd3\xe4\x93\x02\x0e\x12\f/api/v1/user\x12\x80\x01\n" + - "\x10CreatePreAuthKey\x12%.headscale.v1.CreatePreAuthKeyRequest\x1a&.headscale.v1.CreatePreAuthKeyResponse\"\x1d\x82\xd3\xe4\x93\x02\x17:\x01*\"\x12/api/v1/preauthkey\x12\x87\x01\n" + - "\x10ExpirePreAuthKey\x12%.headscale.v1.ExpirePreAuthKeyRequest\x1a&.headscale.v1.ExpirePreAuthKeyResponse\"$\x82\xd3\xe4\x93\x02\x1e:\x01*\"\x19/api/v1/preauthkey/expire\x12}\n" + - "\x10DeletePreAuthKey\x12%.headscale.v1.DeletePreAuthKeyRequest\x1a&.headscale.v1.DeletePreAuthKeyResponse\"\x1a\x82\xd3\xe4\x93\x02\x14*\x12/api/v1/preauthkey\x12z\n" + - "\x0fListPreAuthKeys\x12$.headscale.v1.ListPreAuthKeysRequest\x1a%.headscale.v1.ListPreAuthKeysResponse\"\x1a\x82\xd3\xe4\x93\x02\x14\x12\x12/api/v1/preauthkey\x12}\n" + - "\x0fDebugCreateNode\x12$.headscale.v1.DebugCreateNodeRequest\x1a%.headscale.v1.DebugCreateNodeResponse\"\x1d\x82\xd3\xe4\x93\x02\x17:\x01*\"\x12/api/v1/debug/node\x12f\n" + - "\aGetNode\x12\x1c.headscale.v1.GetNodeRequest\x1a\x1d.headscale.v1.GetNodeResponse\"\x1e\x82\xd3\xe4\x93\x02\x18\x12\x16/api/v1/node/{node_id}\x12n\n" + - "\aSetTags\x12\x1c.headscale.v1.SetTagsRequest\x1a\x1d.headscale.v1.SetTagsResponse\"&\x82\xd3\xe4\x93\x02 :\x01*\"\x1b/api/v1/node/{node_id}/tags\x12\x96\x01\n" + - "\x11SetApprovedRoutes\x12&.headscale.v1.SetApprovedRoutesRequest\x1a'.headscale.v1.SetApprovedRoutesResponse\"0\x82\xd3\xe4\x93\x02*:\x01*\"%/api/v1/node/{node_id}/approve_routes\x12t\n" + - "\fRegisterNode\x12!.headscale.v1.RegisterNodeRequest\x1a\".headscale.v1.RegisterNodeResponse\"\x1d\x82\xd3\xe4\x93\x02\x17\"\x15/api/v1/node/register\x12o\n" + - "\n" + - "DeleteNode\x12\x1f.headscale.v1.DeleteNodeRequest\x1a .headscale.v1.DeleteNodeResponse\"\x1e\x82\xd3\xe4\x93\x02\x18*\x16/api/v1/node/{node_id}\x12v\n" + - "\n" + - "ExpireNode\x12\x1f.headscale.v1.ExpireNodeRequest\x1a .headscale.v1.ExpireNodeResponse\"%\x82\xd3\xe4\x93\x02\x1f\"\x1d/api/v1/node/{node_id}/expire\x12\x81\x01\n" + - "\n" + - "RenameNode\x12\x1f.headscale.v1.RenameNodeRequest\x1a .headscale.v1.RenameNodeResponse\"0\x82\xd3\xe4\x93\x02*\"(/api/v1/node/{node_id}/rename/{new_name}\x12b\n" + - "\tListNodes\x12\x1e.headscale.v1.ListNodesRequest\x1a\x1f.headscale.v1.ListNodesResponse\"\x14\x82\xd3\xe4\x93\x02\x0e\x12\f/api/v1/node\x12\x80\x01\n" + - "\x0fBackfillNodeIPs\x12$.headscale.v1.BackfillNodeIPsRequest\x1a%.headscale.v1.BackfillNodeIPsResponse\" \x82\xd3\xe4\x93\x02\x1a\"\x18/api/v1/node/backfillips\x12w\n" + - "\fAuthRegister\x12!.headscale.v1.AuthRegisterRequest\x1a\".headscale.v1.AuthRegisterResponse\" \x82\xd3\xe4\x93\x02\x1a:\x01*\"\x15/api/v1/auth/register\x12s\n" + - "\vAuthApprove\x12 .headscale.v1.AuthApproveRequest\x1a!.headscale.v1.AuthApproveResponse\"\x1f\x82\xd3\xe4\x93\x02\x19:\x01*\"\x14/api/v1/auth/approve\x12o\n" + - "\n" + - "AuthReject\x12\x1f.headscale.v1.AuthRejectRequest\x1a .headscale.v1.AuthRejectResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/api/v1/auth/reject\x12p\n" + - "\fCreateApiKey\x12!.headscale.v1.CreateApiKeyRequest\x1a\".headscale.v1.CreateApiKeyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\"\x0e/api/v1/apikey\x12w\n" + - "\fExpireApiKey\x12!.headscale.v1.ExpireApiKeyRequest\x1a\".headscale.v1.ExpireApiKeyResponse\" \x82\xd3\xe4\x93\x02\x1a:\x01*\"\x15/api/v1/apikey/expire\x12j\n" + - "\vListApiKeys\x12 .headscale.v1.ListApiKeysRequest\x1a!.headscale.v1.ListApiKeysResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/apikey\x12v\n" + - "\fDeleteApiKey\x12!.headscale.v1.DeleteApiKeyRequest\x1a\".headscale.v1.DeleteApiKeyResponse\"\x1f\x82\xd3\xe4\x93\x02\x19*\x17/api/v1/apikey/{prefix}\x12d\n" + - "\tGetPolicy\x12\x1e.headscale.v1.GetPolicyRequest\x1a\x1f.headscale.v1.GetPolicyResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/policy\x12g\n" + - "\tSetPolicy\x12\x1e.headscale.v1.SetPolicyRequest\x1a\x1f.headscale.v1.SetPolicyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\x1a\x0e/api/v1/policy\x12s\n" + - "\vCheckPolicy\x12 .headscale.v1.CheckPolicyRequest\x1a!.headscale.v1.CheckPolicyResponse\"\x1f\x82\xd3\xe4\x93\x02\x19:\x01*\"\x14/api/v1/policy/check\x12[\n" + - "\x06Health\x12\x1b.headscale.v1.HealthRequest\x1a\x1c.headscale.v1.HealthResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/healthB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_headscale_proto_rawDescOnce sync.Once - file_headscale_v1_headscale_proto_rawDescData []byte -) - -func file_headscale_v1_headscale_proto_rawDescGZIP() []byte { - file_headscale_v1_headscale_proto_rawDescOnce.Do(func() { - file_headscale_v1_headscale_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_headscale_proto_rawDesc), len(file_headscale_v1_headscale_proto_rawDesc))) - }) - return file_headscale_v1_headscale_proto_rawDescData -} - -var file_headscale_v1_headscale_proto_msgTypes = make([]protoimpl.MessageInfo, 2) -var file_headscale_v1_headscale_proto_goTypes = []any{ - (*HealthRequest)(nil), // 0: headscale.v1.HealthRequest - (*HealthResponse)(nil), // 1: headscale.v1.HealthResponse - (*CreateUserRequest)(nil), // 2: headscale.v1.CreateUserRequest - (*RenameUserRequest)(nil), // 3: headscale.v1.RenameUserRequest - (*DeleteUserRequest)(nil), // 4: headscale.v1.DeleteUserRequest - (*ListUsersRequest)(nil), // 5: headscale.v1.ListUsersRequest - (*CreatePreAuthKeyRequest)(nil), // 6: headscale.v1.CreatePreAuthKeyRequest - (*ExpirePreAuthKeyRequest)(nil), // 7: headscale.v1.ExpirePreAuthKeyRequest - (*DeletePreAuthKeyRequest)(nil), // 8: headscale.v1.DeletePreAuthKeyRequest - (*ListPreAuthKeysRequest)(nil), // 9: headscale.v1.ListPreAuthKeysRequest - (*DebugCreateNodeRequest)(nil), // 10: headscale.v1.DebugCreateNodeRequest - (*GetNodeRequest)(nil), // 11: headscale.v1.GetNodeRequest - (*SetTagsRequest)(nil), // 12: headscale.v1.SetTagsRequest - (*SetApprovedRoutesRequest)(nil), // 13: headscale.v1.SetApprovedRoutesRequest - (*RegisterNodeRequest)(nil), // 14: headscale.v1.RegisterNodeRequest - (*DeleteNodeRequest)(nil), // 15: headscale.v1.DeleteNodeRequest - (*ExpireNodeRequest)(nil), // 16: headscale.v1.ExpireNodeRequest - (*RenameNodeRequest)(nil), // 17: headscale.v1.RenameNodeRequest - (*ListNodesRequest)(nil), // 18: headscale.v1.ListNodesRequest - (*BackfillNodeIPsRequest)(nil), // 19: headscale.v1.BackfillNodeIPsRequest - (*AuthRegisterRequest)(nil), // 20: headscale.v1.AuthRegisterRequest - (*AuthApproveRequest)(nil), // 21: headscale.v1.AuthApproveRequest - (*AuthRejectRequest)(nil), // 22: headscale.v1.AuthRejectRequest - (*CreateApiKeyRequest)(nil), // 23: headscale.v1.CreateApiKeyRequest - (*ExpireApiKeyRequest)(nil), // 24: headscale.v1.ExpireApiKeyRequest - (*ListApiKeysRequest)(nil), // 25: headscale.v1.ListApiKeysRequest - (*DeleteApiKeyRequest)(nil), // 26: headscale.v1.DeleteApiKeyRequest - (*GetPolicyRequest)(nil), // 27: headscale.v1.GetPolicyRequest - (*SetPolicyRequest)(nil), // 28: headscale.v1.SetPolicyRequest - (*CheckPolicyRequest)(nil), // 29: headscale.v1.CheckPolicyRequest - (*CreateUserResponse)(nil), // 30: headscale.v1.CreateUserResponse - (*RenameUserResponse)(nil), // 31: headscale.v1.RenameUserResponse - (*DeleteUserResponse)(nil), // 32: headscale.v1.DeleteUserResponse - (*ListUsersResponse)(nil), // 33: headscale.v1.ListUsersResponse - (*CreatePreAuthKeyResponse)(nil), // 34: headscale.v1.CreatePreAuthKeyResponse - (*ExpirePreAuthKeyResponse)(nil), // 35: headscale.v1.ExpirePreAuthKeyResponse - (*DeletePreAuthKeyResponse)(nil), // 36: headscale.v1.DeletePreAuthKeyResponse - (*ListPreAuthKeysResponse)(nil), // 37: headscale.v1.ListPreAuthKeysResponse - (*DebugCreateNodeResponse)(nil), // 38: headscale.v1.DebugCreateNodeResponse - (*GetNodeResponse)(nil), // 39: headscale.v1.GetNodeResponse - (*SetTagsResponse)(nil), // 40: headscale.v1.SetTagsResponse - (*SetApprovedRoutesResponse)(nil), // 41: headscale.v1.SetApprovedRoutesResponse - (*RegisterNodeResponse)(nil), // 42: headscale.v1.RegisterNodeResponse - (*DeleteNodeResponse)(nil), // 43: headscale.v1.DeleteNodeResponse - (*ExpireNodeResponse)(nil), // 44: headscale.v1.ExpireNodeResponse - (*RenameNodeResponse)(nil), // 45: headscale.v1.RenameNodeResponse - (*ListNodesResponse)(nil), // 46: headscale.v1.ListNodesResponse - (*BackfillNodeIPsResponse)(nil), // 47: headscale.v1.BackfillNodeIPsResponse - (*AuthRegisterResponse)(nil), // 48: headscale.v1.AuthRegisterResponse - (*AuthApproveResponse)(nil), // 49: headscale.v1.AuthApproveResponse - (*AuthRejectResponse)(nil), // 50: headscale.v1.AuthRejectResponse - (*CreateApiKeyResponse)(nil), // 51: headscale.v1.CreateApiKeyResponse - (*ExpireApiKeyResponse)(nil), // 52: headscale.v1.ExpireApiKeyResponse - (*ListApiKeysResponse)(nil), // 53: headscale.v1.ListApiKeysResponse - (*DeleteApiKeyResponse)(nil), // 54: headscale.v1.DeleteApiKeyResponse - (*GetPolicyResponse)(nil), // 55: headscale.v1.GetPolicyResponse - (*SetPolicyResponse)(nil), // 56: headscale.v1.SetPolicyResponse - (*CheckPolicyResponse)(nil), // 57: headscale.v1.CheckPolicyResponse -} -var file_headscale_v1_headscale_proto_depIdxs = []int32{ - 2, // 0: headscale.v1.HeadscaleService.CreateUser:input_type -> headscale.v1.CreateUserRequest - 3, // 1: headscale.v1.HeadscaleService.RenameUser:input_type -> headscale.v1.RenameUserRequest - 4, // 2: headscale.v1.HeadscaleService.DeleteUser:input_type -> headscale.v1.DeleteUserRequest - 5, // 3: headscale.v1.HeadscaleService.ListUsers:input_type -> headscale.v1.ListUsersRequest - 6, // 4: headscale.v1.HeadscaleService.CreatePreAuthKey:input_type -> headscale.v1.CreatePreAuthKeyRequest - 7, // 5: headscale.v1.HeadscaleService.ExpirePreAuthKey:input_type -> headscale.v1.ExpirePreAuthKeyRequest - 8, // 6: headscale.v1.HeadscaleService.DeletePreAuthKey:input_type -> headscale.v1.DeletePreAuthKeyRequest - 9, // 7: headscale.v1.HeadscaleService.ListPreAuthKeys:input_type -> headscale.v1.ListPreAuthKeysRequest - 10, // 8: headscale.v1.HeadscaleService.DebugCreateNode:input_type -> headscale.v1.DebugCreateNodeRequest - 11, // 9: headscale.v1.HeadscaleService.GetNode:input_type -> headscale.v1.GetNodeRequest - 12, // 10: headscale.v1.HeadscaleService.SetTags:input_type -> headscale.v1.SetTagsRequest - 13, // 11: headscale.v1.HeadscaleService.SetApprovedRoutes:input_type -> headscale.v1.SetApprovedRoutesRequest - 14, // 12: headscale.v1.HeadscaleService.RegisterNode:input_type -> headscale.v1.RegisterNodeRequest - 15, // 13: headscale.v1.HeadscaleService.DeleteNode:input_type -> headscale.v1.DeleteNodeRequest - 16, // 14: headscale.v1.HeadscaleService.ExpireNode:input_type -> headscale.v1.ExpireNodeRequest - 17, // 15: headscale.v1.HeadscaleService.RenameNode:input_type -> headscale.v1.RenameNodeRequest - 18, // 16: headscale.v1.HeadscaleService.ListNodes:input_type -> headscale.v1.ListNodesRequest - 19, // 17: headscale.v1.HeadscaleService.BackfillNodeIPs:input_type -> headscale.v1.BackfillNodeIPsRequest - 20, // 18: headscale.v1.HeadscaleService.AuthRegister:input_type -> headscale.v1.AuthRegisterRequest - 21, // 19: headscale.v1.HeadscaleService.AuthApprove:input_type -> headscale.v1.AuthApproveRequest - 22, // 20: headscale.v1.HeadscaleService.AuthReject:input_type -> headscale.v1.AuthRejectRequest - 23, // 21: headscale.v1.HeadscaleService.CreateApiKey:input_type -> headscale.v1.CreateApiKeyRequest - 24, // 22: headscale.v1.HeadscaleService.ExpireApiKey:input_type -> headscale.v1.ExpireApiKeyRequest - 25, // 23: headscale.v1.HeadscaleService.ListApiKeys:input_type -> headscale.v1.ListApiKeysRequest - 26, // 24: headscale.v1.HeadscaleService.DeleteApiKey:input_type -> headscale.v1.DeleteApiKeyRequest - 27, // 25: headscale.v1.HeadscaleService.GetPolicy:input_type -> headscale.v1.GetPolicyRequest - 28, // 26: headscale.v1.HeadscaleService.SetPolicy:input_type -> headscale.v1.SetPolicyRequest - 29, // 27: headscale.v1.HeadscaleService.CheckPolicy:input_type -> headscale.v1.CheckPolicyRequest - 0, // 28: headscale.v1.HeadscaleService.Health:input_type -> headscale.v1.HealthRequest - 30, // 29: headscale.v1.HeadscaleService.CreateUser:output_type -> headscale.v1.CreateUserResponse - 31, // 30: headscale.v1.HeadscaleService.RenameUser:output_type -> headscale.v1.RenameUserResponse - 32, // 31: headscale.v1.HeadscaleService.DeleteUser:output_type -> headscale.v1.DeleteUserResponse - 33, // 32: headscale.v1.HeadscaleService.ListUsers:output_type -> headscale.v1.ListUsersResponse - 34, // 33: headscale.v1.HeadscaleService.CreatePreAuthKey:output_type -> headscale.v1.CreatePreAuthKeyResponse - 35, // 34: headscale.v1.HeadscaleService.ExpirePreAuthKey:output_type -> headscale.v1.ExpirePreAuthKeyResponse - 36, // 35: headscale.v1.HeadscaleService.DeletePreAuthKey:output_type -> headscale.v1.DeletePreAuthKeyResponse - 37, // 36: headscale.v1.HeadscaleService.ListPreAuthKeys:output_type -> headscale.v1.ListPreAuthKeysResponse - 38, // 37: headscale.v1.HeadscaleService.DebugCreateNode:output_type -> headscale.v1.DebugCreateNodeResponse - 39, // 38: headscale.v1.HeadscaleService.GetNode:output_type -> headscale.v1.GetNodeResponse - 40, // 39: headscale.v1.HeadscaleService.SetTags:output_type -> headscale.v1.SetTagsResponse - 41, // 40: headscale.v1.HeadscaleService.SetApprovedRoutes:output_type -> headscale.v1.SetApprovedRoutesResponse - 42, // 41: headscale.v1.HeadscaleService.RegisterNode:output_type -> headscale.v1.RegisterNodeResponse - 43, // 42: headscale.v1.HeadscaleService.DeleteNode:output_type -> headscale.v1.DeleteNodeResponse - 44, // 43: headscale.v1.HeadscaleService.ExpireNode:output_type -> headscale.v1.ExpireNodeResponse - 45, // 44: headscale.v1.HeadscaleService.RenameNode:output_type -> headscale.v1.RenameNodeResponse - 46, // 45: headscale.v1.HeadscaleService.ListNodes:output_type -> headscale.v1.ListNodesResponse - 47, // 46: headscale.v1.HeadscaleService.BackfillNodeIPs:output_type -> headscale.v1.BackfillNodeIPsResponse - 48, // 47: headscale.v1.HeadscaleService.AuthRegister:output_type -> headscale.v1.AuthRegisterResponse - 49, // 48: headscale.v1.HeadscaleService.AuthApprove:output_type -> headscale.v1.AuthApproveResponse - 50, // 49: headscale.v1.HeadscaleService.AuthReject:output_type -> headscale.v1.AuthRejectResponse - 51, // 50: headscale.v1.HeadscaleService.CreateApiKey:output_type -> headscale.v1.CreateApiKeyResponse - 52, // 51: headscale.v1.HeadscaleService.ExpireApiKey:output_type -> headscale.v1.ExpireApiKeyResponse - 53, // 52: headscale.v1.HeadscaleService.ListApiKeys:output_type -> headscale.v1.ListApiKeysResponse - 54, // 53: headscale.v1.HeadscaleService.DeleteApiKey:output_type -> headscale.v1.DeleteApiKeyResponse - 55, // 54: headscale.v1.HeadscaleService.GetPolicy:output_type -> headscale.v1.GetPolicyResponse - 56, // 55: headscale.v1.HeadscaleService.SetPolicy:output_type -> headscale.v1.SetPolicyResponse - 57, // 56: headscale.v1.HeadscaleService.CheckPolicy:output_type -> headscale.v1.CheckPolicyResponse - 1, // 57: headscale.v1.HeadscaleService.Health:output_type -> headscale.v1.HealthResponse - 29, // [29:58] is the sub-list for method output_type - 0, // [0:29] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_headscale_v1_headscale_proto_init() } -func file_headscale_v1_headscale_proto_init() { - if File_headscale_v1_headscale_proto != nil { - return - } - file_headscale_v1_user_proto_init() - file_headscale_v1_preauthkey_proto_init() - file_headscale_v1_node_proto_init() - file_headscale_v1_apikey_proto_init() - file_headscale_v1_auth_proto_init() - file_headscale_v1_policy_proto_init() - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_headscale_proto_rawDesc), len(file_headscale_v1_headscale_proto_rawDesc)), - NumEnums: 0, - NumMessages: 2, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_headscale_v1_headscale_proto_goTypes, - DependencyIndexes: file_headscale_v1_headscale_proto_depIdxs, - MessageInfos: file_headscale_v1_headscale_proto_msgTypes, - }.Build() - File_headscale_v1_headscale_proto = out.File - file_headscale_v1_headscale_proto_goTypes = nil - file_headscale_v1_headscale_proto_depIdxs = nil -} diff --git a/gen/go/headscale/v1/headscale.pb.gw.go b/gen/go/headscale/v1/headscale.pb.gw.go deleted file mode 100644 index cbe1c9c31..000000000 --- a/gen/go/headscale/v1/headscale.pb.gw.go +++ /dev/null @@ -1,2201 +0,0 @@ -// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT. -// source: headscale/v1/headscale.proto - -/* -Package v1 is a reverse proxy. - -It translates gRPC into RESTful JSON APIs. -*/ -package v1 - -import ( - "context" - "errors" - "io" - "net/http" - - "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" - "github.com/grpc-ecosystem/grpc-gateway/v2/utilities" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/grpclog" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/proto" -) - -// Suppress "imported and not used" errors -var ( - _ codes.Code - _ io.Reader - _ status.Status - _ = errors.New - _ = runtime.String - _ = utilities.NewDoubleArray - _ = metadata.Join -) - -func request_HeadscaleService_CreateUser_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CreateUserRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.CreateUser(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_CreateUser_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CreateUserRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.CreateUser(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_RenameUser_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RenameUserRequest - metadata runtime.ServerMetadata - err error - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["old_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "old_id") - } - protoReq.OldId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "old_id", err) - } - val, ok = pathParams["new_name"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "new_name") - } - protoReq.NewName, err = runtime.String(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "new_name", err) - } - msg, err := client.RenameUser(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_RenameUser_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RenameUserRequest - metadata runtime.ServerMetadata - err error - ) - val, ok := pathParams["old_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "old_id") - } - protoReq.OldId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "old_id", err) - } - val, ok = pathParams["new_name"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "new_name") - } - protoReq.NewName, err = runtime.String(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "new_name", err) - } - msg, err := server.RenameUser(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_DeleteUser_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeleteUserRequest - metadata runtime.ServerMetadata - err error - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") - } - protoReq.Id, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) - } - msg, err := client.DeleteUser(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_DeleteUser_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeleteUserRequest - metadata runtime.ServerMetadata - err error - ) - val, ok := pathParams["id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") - } - protoReq.Id, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) - } - msg, err := server.DeleteUser(ctx, &protoReq) - return msg, metadata, err -} - -var filter_HeadscaleService_ListUsers_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} - -func request_HeadscaleService_ListUsers_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListUsersRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ListUsers_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := client.ListUsers(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_ListUsers_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListUsersRequest - metadata runtime.ServerMetadata - ) - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ListUsers_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.ListUsers(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_CreatePreAuthKey_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CreatePreAuthKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.CreatePreAuthKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_CreatePreAuthKey_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CreatePreAuthKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.CreatePreAuthKey(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_ExpirePreAuthKey_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ExpirePreAuthKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.ExpirePreAuthKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_ExpirePreAuthKey_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ExpirePreAuthKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.ExpirePreAuthKey(ctx, &protoReq) - return msg, metadata, err -} - -var filter_HeadscaleService_DeletePreAuthKey_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} - -func request_HeadscaleService_DeletePreAuthKey_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeletePreAuthKeyRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_DeletePreAuthKey_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := client.DeletePreAuthKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_DeletePreAuthKey_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeletePreAuthKeyRequest - metadata runtime.ServerMetadata - ) - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_DeletePreAuthKey_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.DeletePreAuthKey(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_ListPreAuthKeys_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListPreAuthKeysRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.ListPreAuthKeys(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_ListPreAuthKeys_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListPreAuthKeysRequest - metadata runtime.ServerMetadata - ) - msg, err := server.ListPreAuthKeys(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_DebugCreateNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DebugCreateNodeRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.DebugCreateNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_DebugCreateNode_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DebugCreateNodeRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.DebugCreateNode(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_GetNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq GetNodeRequest - metadata runtime.ServerMetadata - err error - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := client.GetNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_GetNode_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq GetNodeRequest - metadata runtime.ServerMetadata - err error - ) - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := server.GetNode(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_SetTags_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq SetTagsRequest - metadata runtime.ServerMetadata - err error - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := client.SetTags(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_SetTags_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq SetTagsRequest - metadata runtime.ServerMetadata - err error - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := server.SetTags(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_SetApprovedRoutes_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq SetApprovedRoutesRequest - metadata runtime.ServerMetadata - err error - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := client.SetApprovedRoutes(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_SetApprovedRoutes_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq SetApprovedRoutesRequest - metadata runtime.ServerMetadata - err error - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := server.SetApprovedRoutes(ctx, &protoReq) - return msg, metadata, err -} - -var filter_HeadscaleService_RegisterNode_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} - -func request_HeadscaleService_RegisterNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RegisterNodeRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_RegisterNode_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := client.RegisterNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_RegisterNode_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RegisterNodeRequest - metadata runtime.ServerMetadata - ) - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_RegisterNode_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.RegisterNode(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_DeleteNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeleteNodeRequest - metadata runtime.ServerMetadata - err error - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := client.DeleteNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_DeleteNode_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeleteNodeRequest - metadata runtime.ServerMetadata - err error - ) - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := server.DeleteNode(ctx, &protoReq) - return msg, metadata, err -} - -var filter_HeadscaleService_ExpireNode_0 = &utilities.DoubleArray{Encoding: map[string]int{"node_id": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}} - -func request_HeadscaleService_ExpireNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ExpireNodeRequest - metadata runtime.ServerMetadata - err error - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ExpireNode_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := client.ExpireNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_ExpireNode_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ExpireNodeRequest - metadata runtime.ServerMetadata - err error - ) - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ExpireNode_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.ExpireNode(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_RenameNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RenameNodeRequest - metadata runtime.ServerMetadata - err error - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - val, ok = pathParams["new_name"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "new_name") - } - protoReq.NewName, err = runtime.String(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "new_name", err) - } - msg, err := client.RenameNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_RenameNode_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RenameNodeRequest - metadata runtime.ServerMetadata - err error - ) - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - val, ok = pathParams["new_name"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "new_name") - } - protoReq.NewName, err = runtime.String(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "new_name", err) - } - msg, err := server.RenameNode(ctx, &protoReq) - return msg, metadata, err -} - -var filter_HeadscaleService_ListNodes_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} - -func request_HeadscaleService_ListNodes_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListNodesRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ListNodes_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := client.ListNodes(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_ListNodes_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListNodesRequest - metadata runtime.ServerMetadata - ) - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ListNodes_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.ListNodes(ctx, &protoReq) - return msg, metadata, err -} - -var filter_HeadscaleService_BackfillNodeIPs_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} - -func request_HeadscaleService_BackfillNodeIPs_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq BackfillNodeIPsRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_BackfillNodeIPs_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := client.BackfillNodeIPs(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_BackfillNodeIPs_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq BackfillNodeIPsRequest - metadata runtime.ServerMetadata - ) - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_BackfillNodeIPs_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.BackfillNodeIPs(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_AuthRegister_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq AuthRegisterRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.AuthRegister(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_AuthRegister_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq AuthRegisterRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.AuthRegister(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_AuthApprove_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq AuthApproveRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.AuthApprove(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_AuthApprove_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq AuthApproveRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.AuthApprove(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_AuthReject_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq AuthRejectRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.AuthReject(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_AuthReject_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq AuthRejectRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.AuthReject(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_CreateApiKey_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CreateApiKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.CreateApiKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_CreateApiKey_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CreateApiKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.CreateApiKey(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_ExpireApiKey_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ExpireApiKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.ExpireApiKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_ExpireApiKey_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ExpireApiKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.ExpireApiKey(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_ListApiKeys_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListApiKeysRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.ListApiKeys(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_ListApiKeys_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListApiKeysRequest - metadata runtime.ServerMetadata - ) - msg, err := server.ListApiKeys(ctx, &protoReq) - return msg, metadata, err -} - -var filter_HeadscaleService_DeleteApiKey_0 = &utilities.DoubleArray{Encoding: map[string]int{"prefix": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}} - -func request_HeadscaleService_DeleteApiKey_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeleteApiKeyRequest - metadata runtime.ServerMetadata - err error - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["prefix"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "prefix") - } - protoReq.Prefix, err = runtime.String(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "prefix", err) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_DeleteApiKey_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := client.DeleteApiKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_DeleteApiKey_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeleteApiKeyRequest - metadata runtime.ServerMetadata - err error - ) - val, ok := pathParams["prefix"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "prefix") - } - protoReq.Prefix, err = runtime.String(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "prefix", err) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_DeleteApiKey_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.DeleteApiKey(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_GetPolicy_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq GetPolicyRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.GetPolicy(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_GetPolicy_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq GetPolicyRequest - metadata runtime.ServerMetadata - ) - msg, err := server.GetPolicy(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_SetPolicy_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq SetPolicyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.SetPolicy(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_SetPolicy_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq SetPolicyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.SetPolicy(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_CheckPolicy_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CheckPolicyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.CheckPolicy(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_CheckPolicy_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CheckPolicyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.CheckPolicy(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_Health_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq HealthRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.Health(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_Health_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq HealthRequest - metadata runtime.ServerMetadata - ) - msg, err := server.Health(ctx, &protoReq) - return msg, metadata, err -} - -// RegisterHeadscaleServiceHandlerServer registers the http handlers for service HeadscaleService to "mux". -// UnaryRPC :call HeadscaleServiceServer directly. -// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. -// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterHeadscaleServiceHandlerFromEndpoint instead. -// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call. -func RegisterHeadscaleServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server HeadscaleServiceServer) error { - mux.Handle(http.MethodPost, pattern_HeadscaleService_CreateUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CreateUser", runtime.WithHTTPPathPattern("/api/v1/user")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_CreateUser_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CreateUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_RenameUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/RenameUser", runtime.WithHTTPPathPattern("/api/v1/user/{old_id}/rename/{new_name}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_RenameUser_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_RenameUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeleteUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeleteUser", runtime.WithHTTPPathPattern("/api/v1/user/{id}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_DeleteUser_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeleteUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListUsers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListUsers", runtime.WithHTTPPathPattern("/api/v1/user")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_ListUsers_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListUsers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_CreatePreAuthKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CreatePreAuthKey", runtime.WithHTTPPathPattern("/api/v1/preauthkey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_CreatePreAuthKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CreatePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_ExpirePreAuthKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ExpirePreAuthKey", runtime.WithHTTPPathPattern("/api/v1/preauthkey/expire")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_ExpirePreAuthKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ExpirePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeletePreAuthKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeletePreAuthKey", runtime.WithHTTPPathPattern("/api/v1/preauthkey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_DeletePreAuthKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeletePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListPreAuthKeys_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListPreAuthKeys", runtime.WithHTTPPathPattern("/api/v1/preauthkey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_ListPreAuthKeys_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListPreAuthKeys_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_DebugCreateNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DebugCreateNode", runtime.WithHTTPPathPattern("/api/v1/debug/node")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_DebugCreateNode_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DebugCreateNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_GetNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/GetNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_GetNode_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_GetNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_SetTags_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/SetTags", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/tags")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_SetTags_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_SetTags_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_SetApprovedRoutes_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/SetApprovedRoutes", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/approve_routes")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_SetApprovedRoutes_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_SetApprovedRoutes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_RegisterNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/RegisterNode", runtime.WithHTTPPathPattern("/api/v1/node/register")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_RegisterNode_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_RegisterNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeleteNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeleteNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_DeleteNode_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeleteNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_ExpireNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ExpireNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/expire")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_ExpireNode_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ExpireNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_RenameNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/RenameNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/rename/{new_name}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_RenameNode_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_RenameNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListNodes_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListNodes", runtime.WithHTTPPathPattern("/api/v1/node")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_ListNodes_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListNodes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_BackfillNodeIPs_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/BackfillNodeIPs", runtime.WithHTTPPathPattern("/api/v1/node/backfillips")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_BackfillNodeIPs_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_BackfillNodeIPs_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_AuthRegister_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/AuthRegister", runtime.WithHTTPPathPattern("/api/v1/auth/register")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_AuthRegister_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_AuthRegister_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_AuthApprove_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/AuthApprove", runtime.WithHTTPPathPattern("/api/v1/auth/approve")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_AuthApprove_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_AuthApprove_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_AuthReject_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/AuthReject", runtime.WithHTTPPathPattern("/api/v1/auth/reject")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_AuthReject_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_AuthReject_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_CreateApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CreateApiKey", runtime.WithHTTPPathPattern("/api/v1/apikey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_CreateApiKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CreateApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_ExpireApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ExpireApiKey", runtime.WithHTTPPathPattern("/api/v1/apikey/expire")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_ExpireApiKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ExpireApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListApiKeys_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListApiKeys", runtime.WithHTTPPathPattern("/api/v1/apikey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_ListApiKeys_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListApiKeys_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeleteApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeleteApiKey", runtime.WithHTTPPathPattern("/api/v1/apikey/{prefix}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_DeleteApiKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeleteApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_GetPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/GetPolicy", runtime.WithHTTPPathPattern("/api/v1/policy")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_GetPolicy_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_GetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPut, pattern_HeadscaleService_SetPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/SetPolicy", runtime.WithHTTPPathPattern("/api/v1/policy")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_SetPolicy_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_SetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_CheckPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CheckPolicy", runtime.WithHTTPPathPattern("/api/v1/policy/check")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_CheckPolicy_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CheckPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_Health_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/Health", runtime.WithHTTPPathPattern("/api/v1/health")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_Health_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_Health_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - return nil -} - -// RegisterHeadscaleServiceHandlerFromEndpoint is same as RegisterHeadscaleServiceHandler but -// automatically dials to "endpoint" and closes the connection when "ctx" gets done. -func RegisterHeadscaleServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { - conn, err := grpc.NewClient(endpoint, opts...) - if err != nil { - return err - } - defer func() { - if err != nil { - if cerr := conn.Close(); cerr != nil { - grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) - } - return - } - go func() { - <-ctx.Done() - if cerr := conn.Close(); cerr != nil { - grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) - } - }() - }() - return RegisterHeadscaleServiceHandler(ctx, mux, conn) -} - -// RegisterHeadscaleServiceHandler registers the http handlers for service HeadscaleService to "mux". -// The handlers forward requests to the grpc endpoint over "conn". -func RegisterHeadscaleServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { - return RegisterHeadscaleServiceHandlerClient(ctx, mux, NewHeadscaleServiceClient(conn)) -} - -// RegisterHeadscaleServiceHandlerClient registers the http handlers for service HeadscaleService -// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "HeadscaleServiceClient". -// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "HeadscaleServiceClient" -// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in -// "HeadscaleServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares. -func RegisterHeadscaleServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client HeadscaleServiceClient) error { - mux.Handle(http.MethodPost, pattern_HeadscaleService_CreateUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CreateUser", runtime.WithHTTPPathPattern("/api/v1/user")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_CreateUser_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CreateUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_RenameUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/RenameUser", runtime.WithHTTPPathPattern("/api/v1/user/{old_id}/rename/{new_name}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_RenameUser_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_RenameUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeleteUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeleteUser", runtime.WithHTTPPathPattern("/api/v1/user/{id}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_DeleteUser_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeleteUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListUsers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListUsers", runtime.WithHTTPPathPattern("/api/v1/user")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_ListUsers_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListUsers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_CreatePreAuthKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CreatePreAuthKey", runtime.WithHTTPPathPattern("/api/v1/preauthkey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_CreatePreAuthKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CreatePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_ExpirePreAuthKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ExpirePreAuthKey", runtime.WithHTTPPathPattern("/api/v1/preauthkey/expire")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_ExpirePreAuthKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ExpirePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeletePreAuthKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeletePreAuthKey", runtime.WithHTTPPathPattern("/api/v1/preauthkey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_DeletePreAuthKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeletePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListPreAuthKeys_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListPreAuthKeys", runtime.WithHTTPPathPattern("/api/v1/preauthkey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_ListPreAuthKeys_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListPreAuthKeys_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_DebugCreateNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DebugCreateNode", runtime.WithHTTPPathPattern("/api/v1/debug/node")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_DebugCreateNode_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DebugCreateNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_GetNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/GetNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_GetNode_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_GetNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_SetTags_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/SetTags", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/tags")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_SetTags_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_SetTags_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_SetApprovedRoutes_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/SetApprovedRoutes", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/approve_routes")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_SetApprovedRoutes_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_SetApprovedRoutes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_RegisterNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/RegisterNode", runtime.WithHTTPPathPattern("/api/v1/node/register")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_RegisterNode_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_RegisterNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeleteNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeleteNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_DeleteNode_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeleteNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_ExpireNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ExpireNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/expire")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_ExpireNode_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ExpireNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_RenameNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/RenameNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/rename/{new_name}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_RenameNode_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_RenameNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListNodes_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListNodes", runtime.WithHTTPPathPattern("/api/v1/node")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_ListNodes_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListNodes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_BackfillNodeIPs_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/BackfillNodeIPs", runtime.WithHTTPPathPattern("/api/v1/node/backfillips")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_BackfillNodeIPs_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_BackfillNodeIPs_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_AuthRegister_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/AuthRegister", runtime.WithHTTPPathPattern("/api/v1/auth/register")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_AuthRegister_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_AuthRegister_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_AuthApprove_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/AuthApprove", runtime.WithHTTPPathPattern("/api/v1/auth/approve")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_AuthApprove_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_AuthApprove_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_AuthReject_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/AuthReject", runtime.WithHTTPPathPattern("/api/v1/auth/reject")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_AuthReject_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_AuthReject_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_CreateApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CreateApiKey", runtime.WithHTTPPathPattern("/api/v1/apikey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_CreateApiKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CreateApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_ExpireApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ExpireApiKey", runtime.WithHTTPPathPattern("/api/v1/apikey/expire")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_ExpireApiKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ExpireApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListApiKeys_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListApiKeys", runtime.WithHTTPPathPattern("/api/v1/apikey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_ListApiKeys_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListApiKeys_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeleteApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeleteApiKey", runtime.WithHTTPPathPattern("/api/v1/apikey/{prefix}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_DeleteApiKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeleteApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_GetPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/GetPolicy", runtime.WithHTTPPathPattern("/api/v1/policy")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_GetPolicy_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_GetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPut, pattern_HeadscaleService_SetPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/SetPolicy", runtime.WithHTTPPathPattern("/api/v1/policy")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_SetPolicy_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_SetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_CheckPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CheckPolicy", runtime.WithHTTPPathPattern("/api/v1/policy/check")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_CheckPolicy_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CheckPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_Health_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/Health", runtime.WithHTTPPathPattern("/api/v1/health")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_Health_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_Health_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - return nil -} - -var ( - pattern_HeadscaleService_CreateUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "user"}, "")) - pattern_HeadscaleService_RenameUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4, 1, 0, 4, 1, 5, 5}, []string{"api", "v1", "user", "old_id", "rename", "new_name"}, "")) - pattern_HeadscaleService_DeleteUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "user", "id"}, "")) - pattern_HeadscaleService_ListUsers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "user"}, "")) - pattern_HeadscaleService_CreatePreAuthKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "preauthkey"}, "")) - pattern_HeadscaleService_ExpirePreAuthKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "preauthkey", "expire"}, "")) - pattern_HeadscaleService_DeletePreAuthKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "preauthkey"}, "")) - pattern_HeadscaleService_ListPreAuthKeys_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "preauthkey"}, "")) - pattern_HeadscaleService_DebugCreateNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "debug", "node"}, "")) - pattern_HeadscaleService_GetNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "node", "node_id"}, "")) - pattern_HeadscaleService_SetTags_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"api", "v1", "node", "node_id", "tags"}, "")) - pattern_HeadscaleService_SetApprovedRoutes_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"api", "v1", "node", "node_id", "approve_routes"}, "")) - pattern_HeadscaleService_RegisterNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "node", "register"}, "")) - pattern_HeadscaleService_DeleteNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "node", "node_id"}, "")) - pattern_HeadscaleService_ExpireNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"api", "v1", "node", "node_id", "expire"}, "")) - pattern_HeadscaleService_RenameNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4, 1, 0, 4, 1, 5, 5}, []string{"api", "v1", "node", "node_id", "rename", "new_name"}, "")) - pattern_HeadscaleService_ListNodes_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "node"}, "")) - pattern_HeadscaleService_BackfillNodeIPs_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "node", "backfillips"}, "")) - pattern_HeadscaleService_AuthRegister_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "auth", "register"}, "")) - pattern_HeadscaleService_AuthApprove_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "auth", "approve"}, "")) - pattern_HeadscaleService_AuthReject_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "auth", "reject"}, "")) - pattern_HeadscaleService_CreateApiKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "apikey"}, "")) - pattern_HeadscaleService_ExpireApiKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "apikey", "expire"}, "")) - pattern_HeadscaleService_ListApiKeys_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "apikey"}, "")) - pattern_HeadscaleService_DeleteApiKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "apikey", "prefix"}, "")) - pattern_HeadscaleService_GetPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "policy"}, "")) - pattern_HeadscaleService_SetPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "policy"}, "")) - pattern_HeadscaleService_CheckPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "policy", "check"}, "")) - pattern_HeadscaleService_Health_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "health"}, "")) -) - -var ( - forward_HeadscaleService_CreateUser_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_RenameUser_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_DeleteUser_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_ListUsers_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_CreatePreAuthKey_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_ExpirePreAuthKey_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_DeletePreAuthKey_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_ListPreAuthKeys_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_DebugCreateNode_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_GetNode_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_SetTags_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_SetApprovedRoutes_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_RegisterNode_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_DeleteNode_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_ExpireNode_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_RenameNode_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_ListNodes_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_BackfillNodeIPs_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_AuthRegister_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_AuthApprove_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_AuthReject_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_CreateApiKey_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_ExpireApiKey_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_ListApiKeys_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_DeleteApiKey_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_GetPolicy_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_SetPolicy_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_CheckPolicy_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_Health_0 = runtime.ForwardResponseMessage -) diff --git a/gen/go/headscale/v1/headscale_grpc.pb.go b/gen/go/headscale/v1/headscale_grpc.pb.go deleted file mode 100644 index 07e09b42b..000000000 --- a/gen/go/headscale/v1/headscale_grpc.pb.go +++ /dev/null @@ -1,1199 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.6.2 -// - protoc (unknown) -// source: headscale/v1/headscale.proto - -package v1 - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.64.0 or later. -const _ = grpc.SupportPackageIsVersion9 - -const ( - HeadscaleService_CreateUser_FullMethodName = "/headscale.v1.HeadscaleService/CreateUser" - HeadscaleService_RenameUser_FullMethodName = "/headscale.v1.HeadscaleService/RenameUser" - HeadscaleService_DeleteUser_FullMethodName = "/headscale.v1.HeadscaleService/DeleteUser" - HeadscaleService_ListUsers_FullMethodName = "/headscale.v1.HeadscaleService/ListUsers" - HeadscaleService_CreatePreAuthKey_FullMethodName = "/headscale.v1.HeadscaleService/CreatePreAuthKey" - HeadscaleService_ExpirePreAuthKey_FullMethodName = "/headscale.v1.HeadscaleService/ExpirePreAuthKey" - HeadscaleService_DeletePreAuthKey_FullMethodName = "/headscale.v1.HeadscaleService/DeletePreAuthKey" - HeadscaleService_ListPreAuthKeys_FullMethodName = "/headscale.v1.HeadscaleService/ListPreAuthKeys" - HeadscaleService_DebugCreateNode_FullMethodName = "/headscale.v1.HeadscaleService/DebugCreateNode" - HeadscaleService_GetNode_FullMethodName = "/headscale.v1.HeadscaleService/GetNode" - HeadscaleService_SetTags_FullMethodName = "/headscale.v1.HeadscaleService/SetTags" - HeadscaleService_SetApprovedRoutes_FullMethodName = "/headscale.v1.HeadscaleService/SetApprovedRoutes" - HeadscaleService_RegisterNode_FullMethodName = "/headscale.v1.HeadscaleService/RegisterNode" - HeadscaleService_DeleteNode_FullMethodName = "/headscale.v1.HeadscaleService/DeleteNode" - HeadscaleService_ExpireNode_FullMethodName = "/headscale.v1.HeadscaleService/ExpireNode" - HeadscaleService_RenameNode_FullMethodName = "/headscale.v1.HeadscaleService/RenameNode" - HeadscaleService_ListNodes_FullMethodName = "/headscale.v1.HeadscaleService/ListNodes" - HeadscaleService_BackfillNodeIPs_FullMethodName = "/headscale.v1.HeadscaleService/BackfillNodeIPs" - HeadscaleService_AuthRegister_FullMethodName = "/headscale.v1.HeadscaleService/AuthRegister" - HeadscaleService_AuthApprove_FullMethodName = "/headscale.v1.HeadscaleService/AuthApprove" - HeadscaleService_AuthReject_FullMethodName = "/headscale.v1.HeadscaleService/AuthReject" - HeadscaleService_CreateApiKey_FullMethodName = "/headscale.v1.HeadscaleService/CreateApiKey" - HeadscaleService_ExpireApiKey_FullMethodName = "/headscale.v1.HeadscaleService/ExpireApiKey" - HeadscaleService_ListApiKeys_FullMethodName = "/headscale.v1.HeadscaleService/ListApiKeys" - HeadscaleService_DeleteApiKey_FullMethodName = "/headscale.v1.HeadscaleService/DeleteApiKey" - HeadscaleService_GetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/GetPolicy" - HeadscaleService_SetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/SetPolicy" - HeadscaleService_CheckPolicy_FullMethodName = "/headscale.v1.HeadscaleService/CheckPolicy" - HeadscaleService_Health_FullMethodName = "/headscale.v1.HeadscaleService/Health" -) - -// HeadscaleServiceClient is the client API for HeadscaleService service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -type HeadscaleServiceClient interface { - // --- User start --- - CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*CreateUserResponse, error) - RenameUser(ctx context.Context, in *RenameUserRequest, opts ...grpc.CallOption) (*RenameUserResponse, error) - DeleteUser(ctx context.Context, in *DeleteUserRequest, opts ...grpc.CallOption) (*DeleteUserResponse, error) - ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error) - // --- PreAuthKeys start --- - CreatePreAuthKey(ctx context.Context, in *CreatePreAuthKeyRequest, opts ...grpc.CallOption) (*CreatePreAuthKeyResponse, error) - ExpirePreAuthKey(ctx context.Context, in *ExpirePreAuthKeyRequest, opts ...grpc.CallOption) (*ExpirePreAuthKeyResponse, error) - DeletePreAuthKey(ctx context.Context, in *DeletePreAuthKeyRequest, opts ...grpc.CallOption) (*DeletePreAuthKeyResponse, error) - ListPreAuthKeys(ctx context.Context, in *ListPreAuthKeysRequest, opts ...grpc.CallOption) (*ListPreAuthKeysResponse, error) - // --- Node start --- - DebugCreateNode(ctx context.Context, in *DebugCreateNodeRequest, opts ...grpc.CallOption) (*DebugCreateNodeResponse, error) - GetNode(ctx context.Context, in *GetNodeRequest, opts ...grpc.CallOption) (*GetNodeResponse, error) - SetTags(ctx context.Context, in *SetTagsRequest, opts ...grpc.CallOption) (*SetTagsResponse, error) - SetApprovedRoutes(ctx context.Context, in *SetApprovedRoutesRequest, opts ...grpc.CallOption) (*SetApprovedRoutesResponse, error) - RegisterNode(ctx context.Context, in *RegisterNodeRequest, opts ...grpc.CallOption) (*RegisterNodeResponse, error) - DeleteNode(ctx context.Context, in *DeleteNodeRequest, opts ...grpc.CallOption) (*DeleteNodeResponse, error) - ExpireNode(ctx context.Context, in *ExpireNodeRequest, opts ...grpc.CallOption) (*ExpireNodeResponse, error) - RenameNode(ctx context.Context, in *RenameNodeRequest, opts ...grpc.CallOption) (*RenameNodeResponse, error) - ListNodes(ctx context.Context, in *ListNodesRequest, opts ...grpc.CallOption) (*ListNodesResponse, error) - BackfillNodeIPs(ctx context.Context, in *BackfillNodeIPsRequest, opts ...grpc.CallOption) (*BackfillNodeIPsResponse, error) - // --- Auth start --- - AuthRegister(ctx context.Context, in *AuthRegisterRequest, opts ...grpc.CallOption) (*AuthRegisterResponse, error) - AuthApprove(ctx context.Context, in *AuthApproveRequest, opts ...grpc.CallOption) (*AuthApproveResponse, error) - AuthReject(ctx context.Context, in *AuthRejectRequest, opts ...grpc.CallOption) (*AuthRejectResponse, error) - // --- ApiKeys start --- - CreateApiKey(ctx context.Context, in *CreateApiKeyRequest, opts ...grpc.CallOption) (*CreateApiKeyResponse, error) - ExpireApiKey(ctx context.Context, in *ExpireApiKeyRequest, opts ...grpc.CallOption) (*ExpireApiKeyResponse, error) - ListApiKeys(ctx context.Context, in *ListApiKeysRequest, opts ...grpc.CallOption) (*ListApiKeysResponse, error) - DeleteApiKey(ctx context.Context, in *DeleteApiKeyRequest, opts ...grpc.CallOption) (*DeleteApiKeyResponse, error) - // --- Policy start --- - GetPolicy(ctx context.Context, in *GetPolicyRequest, opts ...grpc.CallOption) (*GetPolicyResponse, error) - SetPolicy(ctx context.Context, in *SetPolicyRequest, opts ...grpc.CallOption) (*SetPolicyResponse, error) - CheckPolicy(ctx context.Context, in *CheckPolicyRequest, opts ...grpc.CallOption) (*CheckPolicyResponse, error) - // --- Health start --- - Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) -} - -type headscaleServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewHeadscaleServiceClient(cc grpc.ClientConnInterface) HeadscaleServiceClient { - return &headscaleServiceClient{cc} -} - -func (c *headscaleServiceClient) CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*CreateUserResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CreateUserResponse) - err := c.cc.Invoke(ctx, HeadscaleService_CreateUser_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) RenameUser(ctx context.Context, in *RenameUserRequest, opts ...grpc.CallOption) (*RenameUserResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(RenameUserResponse) - err := c.cc.Invoke(ctx, HeadscaleService_RenameUser_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) DeleteUser(ctx context.Context, in *DeleteUserRequest, opts ...grpc.CallOption) (*DeleteUserResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(DeleteUserResponse) - err := c.cc.Invoke(ctx, HeadscaleService_DeleteUser_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ListUsersResponse) - err := c.cc.Invoke(ctx, HeadscaleService_ListUsers_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) CreatePreAuthKey(ctx context.Context, in *CreatePreAuthKeyRequest, opts ...grpc.CallOption) (*CreatePreAuthKeyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CreatePreAuthKeyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_CreatePreAuthKey_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) ExpirePreAuthKey(ctx context.Context, in *ExpirePreAuthKeyRequest, opts ...grpc.CallOption) (*ExpirePreAuthKeyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ExpirePreAuthKeyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_ExpirePreAuthKey_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) DeletePreAuthKey(ctx context.Context, in *DeletePreAuthKeyRequest, opts ...grpc.CallOption) (*DeletePreAuthKeyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(DeletePreAuthKeyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_DeletePreAuthKey_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) ListPreAuthKeys(ctx context.Context, in *ListPreAuthKeysRequest, opts ...grpc.CallOption) (*ListPreAuthKeysResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ListPreAuthKeysResponse) - err := c.cc.Invoke(ctx, HeadscaleService_ListPreAuthKeys_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) DebugCreateNode(ctx context.Context, in *DebugCreateNodeRequest, opts ...grpc.CallOption) (*DebugCreateNodeResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(DebugCreateNodeResponse) - err := c.cc.Invoke(ctx, HeadscaleService_DebugCreateNode_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) GetNode(ctx context.Context, in *GetNodeRequest, opts ...grpc.CallOption) (*GetNodeResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(GetNodeResponse) - err := c.cc.Invoke(ctx, HeadscaleService_GetNode_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) SetTags(ctx context.Context, in *SetTagsRequest, opts ...grpc.CallOption) (*SetTagsResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(SetTagsResponse) - err := c.cc.Invoke(ctx, HeadscaleService_SetTags_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) SetApprovedRoutes(ctx context.Context, in *SetApprovedRoutesRequest, opts ...grpc.CallOption) (*SetApprovedRoutesResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(SetApprovedRoutesResponse) - err := c.cc.Invoke(ctx, HeadscaleService_SetApprovedRoutes_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) RegisterNode(ctx context.Context, in *RegisterNodeRequest, opts ...grpc.CallOption) (*RegisterNodeResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(RegisterNodeResponse) - err := c.cc.Invoke(ctx, HeadscaleService_RegisterNode_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) DeleteNode(ctx context.Context, in *DeleteNodeRequest, opts ...grpc.CallOption) (*DeleteNodeResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(DeleteNodeResponse) - err := c.cc.Invoke(ctx, HeadscaleService_DeleteNode_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) ExpireNode(ctx context.Context, in *ExpireNodeRequest, opts ...grpc.CallOption) (*ExpireNodeResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ExpireNodeResponse) - err := c.cc.Invoke(ctx, HeadscaleService_ExpireNode_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) RenameNode(ctx context.Context, in *RenameNodeRequest, opts ...grpc.CallOption) (*RenameNodeResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(RenameNodeResponse) - err := c.cc.Invoke(ctx, HeadscaleService_RenameNode_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) ListNodes(ctx context.Context, in *ListNodesRequest, opts ...grpc.CallOption) (*ListNodesResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ListNodesResponse) - err := c.cc.Invoke(ctx, HeadscaleService_ListNodes_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) BackfillNodeIPs(ctx context.Context, in *BackfillNodeIPsRequest, opts ...grpc.CallOption) (*BackfillNodeIPsResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(BackfillNodeIPsResponse) - err := c.cc.Invoke(ctx, HeadscaleService_BackfillNodeIPs_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) AuthRegister(ctx context.Context, in *AuthRegisterRequest, opts ...grpc.CallOption) (*AuthRegisterResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(AuthRegisterResponse) - err := c.cc.Invoke(ctx, HeadscaleService_AuthRegister_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) AuthApprove(ctx context.Context, in *AuthApproveRequest, opts ...grpc.CallOption) (*AuthApproveResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(AuthApproveResponse) - err := c.cc.Invoke(ctx, HeadscaleService_AuthApprove_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) AuthReject(ctx context.Context, in *AuthRejectRequest, opts ...grpc.CallOption) (*AuthRejectResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(AuthRejectResponse) - err := c.cc.Invoke(ctx, HeadscaleService_AuthReject_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) CreateApiKey(ctx context.Context, in *CreateApiKeyRequest, opts ...grpc.CallOption) (*CreateApiKeyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CreateApiKeyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_CreateApiKey_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) ExpireApiKey(ctx context.Context, in *ExpireApiKeyRequest, opts ...grpc.CallOption) (*ExpireApiKeyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ExpireApiKeyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_ExpireApiKey_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) ListApiKeys(ctx context.Context, in *ListApiKeysRequest, opts ...grpc.CallOption) (*ListApiKeysResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ListApiKeysResponse) - err := c.cc.Invoke(ctx, HeadscaleService_ListApiKeys_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) DeleteApiKey(ctx context.Context, in *DeleteApiKeyRequest, opts ...grpc.CallOption) (*DeleteApiKeyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(DeleteApiKeyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_DeleteApiKey_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) GetPolicy(ctx context.Context, in *GetPolicyRequest, opts ...grpc.CallOption) (*GetPolicyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(GetPolicyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_GetPolicy_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) SetPolicy(ctx context.Context, in *SetPolicyRequest, opts ...grpc.CallOption) (*SetPolicyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(SetPolicyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_SetPolicy_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) CheckPolicy(ctx context.Context, in *CheckPolicyRequest, opts ...grpc.CallOption) (*CheckPolicyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CheckPolicyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_CheckPolicy_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(HealthResponse) - err := c.cc.Invoke(ctx, HeadscaleService_Health_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -// HeadscaleServiceServer is the server API for HeadscaleService service. -// All implementations must embed UnimplementedHeadscaleServiceServer -// for forward compatibility. -type HeadscaleServiceServer interface { - // --- User start --- - CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error) - RenameUser(context.Context, *RenameUserRequest) (*RenameUserResponse, error) - DeleteUser(context.Context, *DeleteUserRequest) (*DeleteUserResponse, error) - ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) - // --- PreAuthKeys start --- - CreatePreAuthKey(context.Context, *CreatePreAuthKeyRequest) (*CreatePreAuthKeyResponse, error) - ExpirePreAuthKey(context.Context, *ExpirePreAuthKeyRequest) (*ExpirePreAuthKeyResponse, error) - DeletePreAuthKey(context.Context, *DeletePreAuthKeyRequest) (*DeletePreAuthKeyResponse, error) - ListPreAuthKeys(context.Context, *ListPreAuthKeysRequest) (*ListPreAuthKeysResponse, error) - // --- Node start --- - DebugCreateNode(context.Context, *DebugCreateNodeRequest) (*DebugCreateNodeResponse, error) - GetNode(context.Context, *GetNodeRequest) (*GetNodeResponse, error) - SetTags(context.Context, *SetTagsRequest) (*SetTagsResponse, error) - SetApprovedRoutes(context.Context, *SetApprovedRoutesRequest) (*SetApprovedRoutesResponse, error) - RegisterNode(context.Context, *RegisterNodeRequest) (*RegisterNodeResponse, error) - DeleteNode(context.Context, *DeleteNodeRequest) (*DeleteNodeResponse, error) - ExpireNode(context.Context, *ExpireNodeRequest) (*ExpireNodeResponse, error) - RenameNode(context.Context, *RenameNodeRequest) (*RenameNodeResponse, error) - ListNodes(context.Context, *ListNodesRequest) (*ListNodesResponse, error) - BackfillNodeIPs(context.Context, *BackfillNodeIPsRequest) (*BackfillNodeIPsResponse, error) - // --- Auth start --- - AuthRegister(context.Context, *AuthRegisterRequest) (*AuthRegisterResponse, error) - AuthApprove(context.Context, *AuthApproveRequest) (*AuthApproveResponse, error) - AuthReject(context.Context, *AuthRejectRequest) (*AuthRejectResponse, error) - // --- ApiKeys start --- - CreateApiKey(context.Context, *CreateApiKeyRequest) (*CreateApiKeyResponse, error) - ExpireApiKey(context.Context, *ExpireApiKeyRequest) (*ExpireApiKeyResponse, error) - ListApiKeys(context.Context, *ListApiKeysRequest) (*ListApiKeysResponse, error) - DeleteApiKey(context.Context, *DeleteApiKeyRequest) (*DeleteApiKeyResponse, error) - // --- Policy start --- - GetPolicy(context.Context, *GetPolicyRequest) (*GetPolicyResponse, error) - SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error) - CheckPolicy(context.Context, *CheckPolicyRequest) (*CheckPolicyResponse, error) - // --- Health start --- - Health(context.Context, *HealthRequest) (*HealthResponse, error) - mustEmbedUnimplementedHeadscaleServiceServer() -} - -// UnimplementedHeadscaleServiceServer must be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedHeadscaleServiceServer struct{} - -func (UnimplementedHeadscaleServiceServer) CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error) { - return nil, status.Error(codes.Unimplemented, "method CreateUser not implemented") -} -func (UnimplementedHeadscaleServiceServer) RenameUser(context.Context, *RenameUserRequest) (*RenameUserResponse, error) { - return nil, status.Error(codes.Unimplemented, "method RenameUser not implemented") -} -func (UnimplementedHeadscaleServiceServer) DeleteUser(context.Context, *DeleteUserRequest) (*DeleteUserResponse, error) { - return nil, status.Error(codes.Unimplemented, "method DeleteUser not implemented") -} -func (UnimplementedHeadscaleServiceServer) ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ListUsers not implemented") -} -func (UnimplementedHeadscaleServiceServer) CreatePreAuthKey(context.Context, *CreatePreAuthKeyRequest) (*CreatePreAuthKeyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method CreatePreAuthKey not implemented") -} -func (UnimplementedHeadscaleServiceServer) ExpirePreAuthKey(context.Context, *ExpirePreAuthKeyRequest) (*ExpirePreAuthKeyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ExpirePreAuthKey not implemented") -} -func (UnimplementedHeadscaleServiceServer) DeletePreAuthKey(context.Context, *DeletePreAuthKeyRequest) (*DeletePreAuthKeyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method DeletePreAuthKey not implemented") -} -func (UnimplementedHeadscaleServiceServer) ListPreAuthKeys(context.Context, *ListPreAuthKeysRequest) (*ListPreAuthKeysResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ListPreAuthKeys not implemented") -} -func (UnimplementedHeadscaleServiceServer) DebugCreateNode(context.Context, *DebugCreateNodeRequest) (*DebugCreateNodeResponse, error) { - return nil, status.Error(codes.Unimplemented, "method DebugCreateNode not implemented") -} -func (UnimplementedHeadscaleServiceServer) GetNode(context.Context, *GetNodeRequest) (*GetNodeResponse, error) { - return nil, status.Error(codes.Unimplemented, "method GetNode not implemented") -} -func (UnimplementedHeadscaleServiceServer) SetTags(context.Context, *SetTagsRequest) (*SetTagsResponse, error) { - return nil, status.Error(codes.Unimplemented, "method SetTags not implemented") -} -func (UnimplementedHeadscaleServiceServer) SetApprovedRoutes(context.Context, *SetApprovedRoutesRequest) (*SetApprovedRoutesResponse, error) { - return nil, status.Error(codes.Unimplemented, "method SetApprovedRoutes not implemented") -} -func (UnimplementedHeadscaleServiceServer) RegisterNode(context.Context, *RegisterNodeRequest) (*RegisterNodeResponse, error) { - return nil, status.Error(codes.Unimplemented, "method RegisterNode not implemented") -} -func (UnimplementedHeadscaleServiceServer) DeleteNode(context.Context, *DeleteNodeRequest) (*DeleteNodeResponse, error) { - return nil, status.Error(codes.Unimplemented, "method DeleteNode not implemented") -} -func (UnimplementedHeadscaleServiceServer) ExpireNode(context.Context, *ExpireNodeRequest) (*ExpireNodeResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ExpireNode not implemented") -} -func (UnimplementedHeadscaleServiceServer) RenameNode(context.Context, *RenameNodeRequest) (*RenameNodeResponse, error) { - return nil, status.Error(codes.Unimplemented, "method RenameNode not implemented") -} -func (UnimplementedHeadscaleServiceServer) ListNodes(context.Context, *ListNodesRequest) (*ListNodesResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ListNodes not implemented") -} -func (UnimplementedHeadscaleServiceServer) BackfillNodeIPs(context.Context, *BackfillNodeIPsRequest) (*BackfillNodeIPsResponse, error) { - return nil, status.Error(codes.Unimplemented, "method BackfillNodeIPs not implemented") -} -func (UnimplementedHeadscaleServiceServer) AuthRegister(context.Context, *AuthRegisterRequest) (*AuthRegisterResponse, error) { - return nil, status.Error(codes.Unimplemented, "method AuthRegister not implemented") -} -func (UnimplementedHeadscaleServiceServer) AuthApprove(context.Context, *AuthApproveRequest) (*AuthApproveResponse, error) { - return nil, status.Error(codes.Unimplemented, "method AuthApprove not implemented") -} -func (UnimplementedHeadscaleServiceServer) AuthReject(context.Context, *AuthRejectRequest) (*AuthRejectResponse, error) { - return nil, status.Error(codes.Unimplemented, "method AuthReject not implemented") -} -func (UnimplementedHeadscaleServiceServer) CreateApiKey(context.Context, *CreateApiKeyRequest) (*CreateApiKeyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method CreateApiKey not implemented") -} -func (UnimplementedHeadscaleServiceServer) ExpireApiKey(context.Context, *ExpireApiKeyRequest) (*ExpireApiKeyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ExpireApiKey not implemented") -} -func (UnimplementedHeadscaleServiceServer) ListApiKeys(context.Context, *ListApiKeysRequest) (*ListApiKeysResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ListApiKeys not implemented") -} -func (UnimplementedHeadscaleServiceServer) DeleteApiKey(context.Context, *DeleteApiKeyRequest) (*DeleteApiKeyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method DeleteApiKey not implemented") -} -func (UnimplementedHeadscaleServiceServer) GetPolicy(context.Context, *GetPolicyRequest) (*GetPolicyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method GetPolicy not implemented") -} -func (UnimplementedHeadscaleServiceServer) SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method SetPolicy not implemented") -} -func (UnimplementedHeadscaleServiceServer) CheckPolicy(context.Context, *CheckPolicyRequest) (*CheckPolicyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method CheckPolicy not implemented") -} -func (UnimplementedHeadscaleServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Health not implemented") -} -func (UnimplementedHeadscaleServiceServer) mustEmbedUnimplementedHeadscaleServiceServer() {} -func (UnimplementedHeadscaleServiceServer) testEmbeddedByValue() {} - -// UnsafeHeadscaleServiceServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to HeadscaleServiceServer will -// result in compilation errors. -type UnsafeHeadscaleServiceServer interface { - mustEmbedUnimplementedHeadscaleServiceServer() -} - -func RegisterHeadscaleServiceServer(s grpc.ServiceRegistrar, srv HeadscaleServiceServer) { - // If the following call panics, it indicates UnimplementedHeadscaleServiceServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } - s.RegisterService(&HeadscaleService_ServiceDesc, srv) -} - -func _HeadscaleService_CreateUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CreateUserRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).CreateUser(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_CreateUser_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).CreateUser(ctx, req.(*CreateUserRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_RenameUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(RenameUserRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).RenameUser(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_RenameUser_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).RenameUser(ctx, req.(*RenameUserRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_DeleteUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DeleteUserRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).DeleteUser(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_DeleteUser_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).DeleteUser(ctx, req.(*DeleteUserRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_ListUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListUsersRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).ListUsers(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_ListUsers_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).ListUsers(ctx, req.(*ListUsersRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_CreatePreAuthKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CreatePreAuthKeyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).CreatePreAuthKey(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_CreatePreAuthKey_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).CreatePreAuthKey(ctx, req.(*CreatePreAuthKeyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_ExpirePreAuthKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ExpirePreAuthKeyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).ExpirePreAuthKey(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_ExpirePreAuthKey_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).ExpirePreAuthKey(ctx, req.(*ExpirePreAuthKeyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_DeletePreAuthKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DeletePreAuthKeyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).DeletePreAuthKey(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_DeletePreAuthKey_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).DeletePreAuthKey(ctx, req.(*DeletePreAuthKeyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_ListPreAuthKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListPreAuthKeysRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).ListPreAuthKeys(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_ListPreAuthKeys_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).ListPreAuthKeys(ctx, req.(*ListPreAuthKeysRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_DebugCreateNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DebugCreateNodeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).DebugCreateNode(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_DebugCreateNode_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).DebugCreateNode(ctx, req.(*DebugCreateNodeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_GetNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetNodeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).GetNode(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_GetNode_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).GetNode(ctx, req.(*GetNodeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_SetTags_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(SetTagsRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).SetTags(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_SetTags_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).SetTags(ctx, req.(*SetTagsRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_SetApprovedRoutes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(SetApprovedRoutesRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).SetApprovedRoutes(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_SetApprovedRoutes_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).SetApprovedRoutes(ctx, req.(*SetApprovedRoutesRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_RegisterNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(RegisterNodeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).RegisterNode(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_RegisterNode_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).RegisterNode(ctx, req.(*RegisterNodeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_DeleteNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DeleteNodeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).DeleteNode(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_DeleteNode_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).DeleteNode(ctx, req.(*DeleteNodeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_ExpireNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ExpireNodeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).ExpireNode(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_ExpireNode_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).ExpireNode(ctx, req.(*ExpireNodeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_RenameNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(RenameNodeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).RenameNode(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_RenameNode_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).RenameNode(ctx, req.(*RenameNodeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_ListNodes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListNodesRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).ListNodes(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_ListNodes_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).ListNodes(ctx, req.(*ListNodesRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_BackfillNodeIPs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(BackfillNodeIPsRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).BackfillNodeIPs(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_BackfillNodeIPs_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).BackfillNodeIPs(ctx, req.(*BackfillNodeIPsRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_AuthRegister_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(AuthRegisterRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).AuthRegister(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_AuthRegister_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).AuthRegister(ctx, req.(*AuthRegisterRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_AuthApprove_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(AuthApproveRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).AuthApprove(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_AuthApprove_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).AuthApprove(ctx, req.(*AuthApproveRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_AuthReject_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(AuthRejectRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).AuthReject(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_AuthReject_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).AuthReject(ctx, req.(*AuthRejectRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_CreateApiKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CreateApiKeyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).CreateApiKey(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_CreateApiKey_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).CreateApiKey(ctx, req.(*CreateApiKeyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_ExpireApiKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ExpireApiKeyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).ExpireApiKey(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_ExpireApiKey_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).ExpireApiKey(ctx, req.(*ExpireApiKeyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_ListApiKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListApiKeysRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).ListApiKeys(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_ListApiKeys_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).ListApiKeys(ctx, req.(*ListApiKeysRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_DeleteApiKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DeleteApiKeyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).DeleteApiKey(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_DeleteApiKey_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).DeleteApiKey(ctx, req.(*DeleteApiKeyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_GetPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetPolicyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).GetPolicy(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_GetPolicy_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).GetPolicy(ctx, req.(*GetPolicyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_SetPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(SetPolicyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).SetPolicy(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_SetPolicy_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).SetPolicy(ctx, req.(*SetPolicyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_CheckPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CheckPolicyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).CheckPolicy(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_CheckPolicy_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).CheckPolicy(ctx, req.(*CheckPolicyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(HealthRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).Health(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_Health_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).Health(ctx, req.(*HealthRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// HeadscaleService_ServiceDesc is the grpc.ServiceDesc for HeadscaleService service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var HeadscaleService_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "headscale.v1.HeadscaleService", - HandlerType: (*HeadscaleServiceServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "CreateUser", - Handler: _HeadscaleService_CreateUser_Handler, - }, - { - MethodName: "RenameUser", - Handler: _HeadscaleService_RenameUser_Handler, - }, - { - MethodName: "DeleteUser", - Handler: _HeadscaleService_DeleteUser_Handler, - }, - { - MethodName: "ListUsers", - Handler: _HeadscaleService_ListUsers_Handler, - }, - { - MethodName: "CreatePreAuthKey", - Handler: _HeadscaleService_CreatePreAuthKey_Handler, - }, - { - MethodName: "ExpirePreAuthKey", - Handler: _HeadscaleService_ExpirePreAuthKey_Handler, - }, - { - MethodName: "DeletePreAuthKey", - Handler: _HeadscaleService_DeletePreAuthKey_Handler, - }, - { - MethodName: "ListPreAuthKeys", - Handler: _HeadscaleService_ListPreAuthKeys_Handler, - }, - { - MethodName: "DebugCreateNode", - Handler: _HeadscaleService_DebugCreateNode_Handler, - }, - { - MethodName: "GetNode", - Handler: _HeadscaleService_GetNode_Handler, - }, - { - MethodName: "SetTags", - Handler: _HeadscaleService_SetTags_Handler, - }, - { - MethodName: "SetApprovedRoutes", - Handler: _HeadscaleService_SetApprovedRoutes_Handler, - }, - { - MethodName: "RegisterNode", - Handler: _HeadscaleService_RegisterNode_Handler, - }, - { - MethodName: "DeleteNode", - Handler: _HeadscaleService_DeleteNode_Handler, - }, - { - MethodName: "ExpireNode", - Handler: _HeadscaleService_ExpireNode_Handler, - }, - { - MethodName: "RenameNode", - Handler: _HeadscaleService_RenameNode_Handler, - }, - { - MethodName: "ListNodes", - Handler: _HeadscaleService_ListNodes_Handler, - }, - { - MethodName: "BackfillNodeIPs", - Handler: _HeadscaleService_BackfillNodeIPs_Handler, - }, - { - MethodName: "AuthRegister", - Handler: _HeadscaleService_AuthRegister_Handler, - }, - { - MethodName: "AuthApprove", - Handler: _HeadscaleService_AuthApprove_Handler, - }, - { - MethodName: "AuthReject", - Handler: _HeadscaleService_AuthReject_Handler, - }, - { - MethodName: "CreateApiKey", - Handler: _HeadscaleService_CreateApiKey_Handler, - }, - { - MethodName: "ExpireApiKey", - Handler: _HeadscaleService_ExpireApiKey_Handler, - }, - { - MethodName: "ListApiKeys", - Handler: _HeadscaleService_ListApiKeys_Handler, - }, - { - MethodName: "DeleteApiKey", - Handler: _HeadscaleService_DeleteApiKey_Handler, - }, - { - MethodName: "GetPolicy", - Handler: _HeadscaleService_GetPolicy_Handler, - }, - { - MethodName: "SetPolicy", - Handler: _HeadscaleService_SetPolicy_Handler, - }, - { - MethodName: "CheckPolicy", - Handler: _HeadscaleService_CheckPolicy_Handler, - }, - { - MethodName: "Health", - Handler: _HeadscaleService_Health_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "headscale/v1/headscale.proto", -} diff --git a/gen/go/headscale/v1/node.pb.go b/gen/go/headscale/v1/node.pb.go deleted file mode 100644 index 32142ed8b..000000000 --- a/gen/go/headscale/v1/node.pb.go +++ /dev/null @@ -1,1379 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/node.proto - -package v1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type RegisterMethod int32 - -const ( - RegisterMethod_REGISTER_METHOD_UNSPECIFIED RegisterMethod = 0 - RegisterMethod_REGISTER_METHOD_AUTH_KEY RegisterMethod = 1 - RegisterMethod_REGISTER_METHOD_CLI RegisterMethod = 2 - RegisterMethod_REGISTER_METHOD_OIDC RegisterMethod = 3 -) - -// Enum value maps for RegisterMethod. -var ( - RegisterMethod_name = map[int32]string{ - 0: "REGISTER_METHOD_UNSPECIFIED", - 1: "REGISTER_METHOD_AUTH_KEY", - 2: "REGISTER_METHOD_CLI", - 3: "REGISTER_METHOD_OIDC", - } - RegisterMethod_value = map[string]int32{ - "REGISTER_METHOD_UNSPECIFIED": 0, - "REGISTER_METHOD_AUTH_KEY": 1, - "REGISTER_METHOD_CLI": 2, - "REGISTER_METHOD_OIDC": 3, - } -) - -func (x RegisterMethod) Enum() *RegisterMethod { - p := new(RegisterMethod) - *p = x - return p -} - -func (x RegisterMethod) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (RegisterMethod) Descriptor() protoreflect.EnumDescriptor { - return file_headscale_v1_node_proto_enumTypes[0].Descriptor() -} - -func (RegisterMethod) Type() protoreflect.EnumType { - return &file_headscale_v1_node_proto_enumTypes[0] -} - -func (x RegisterMethod) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use RegisterMethod.Descriptor instead. -func (RegisterMethod) EnumDescriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{0} -} - -type Node struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - MachineKey string `protobuf:"bytes,2,opt,name=machine_key,json=machineKey,proto3" json:"machine_key,omitempty"` - NodeKey string `protobuf:"bytes,3,opt,name=node_key,json=nodeKey,proto3" json:"node_key,omitempty"` - DiscoKey string `protobuf:"bytes,4,opt,name=disco_key,json=discoKey,proto3" json:"disco_key,omitempty"` - IpAddresses []string `protobuf:"bytes,5,rep,name=ip_addresses,json=ipAddresses,proto3" json:"ip_addresses,omitempty"` - Name string `protobuf:"bytes,6,opt,name=name,proto3" json:"name,omitempty"` - User *User `protobuf:"bytes,7,opt,name=user,proto3" json:"user,omitempty"` - LastSeen *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_seen,json=lastSeen,proto3" json:"last_seen,omitempty"` - Expiry *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=expiry,proto3" json:"expiry,omitempty"` - PreAuthKey *PreAuthKey `protobuf:"bytes,11,opt,name=pre_auth_key,json=preAuthKey,proto3" json:"pre_auth_key,omitempty"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - RegisterMethod RegisterMethod `protobuf:"varint,13,opt,name=register_method,json=registerMethod,proto3,enum=headscale.v1.RegisterMethod" json:"register_method,omitempty"` - // Deprecated - // repeated string forced_tags = 18; - // repeated string invalid_tags = 19; - // repeated string valid_tags = 20; - GivenName string `protobuf:"bytes,21,opt,name=given_name,json=givenName,proto3" json:"given_name,omitempty"` - Online bool `protobuf:"varint,22,opt,name=online,proto3" json:"online,omitempty"` - ApprovedRoutes []string `protobuf:"bytes,23,rep,name=approved_routes,json=approvedRoutes,proto3" json:"approved_routes,omitempty"` - AvailableRoutes []string `protobuf:"bytes,24,rep,name=available_routes,json=availableRoutes,proto3" json:"available_routes,omitempty"` - SubnetRoutes []string `protobuf:"bytes,25,rep,name=subnet_routes,json=subnetRoutes,proto3" json:"subnet_routes,omitempty"` - Tags []string `protobuf:"bytes,26,rep,name=tags,proto3" json:"tags,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Node) Reset() { - *x = Node{} - mi := &file_headscale_v1_node_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Node) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Node) ProtoMessage() {} - -func (x *Node) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Node.ProtoReflect.Descriptor instead. -func (*Node) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{0} -} - -func (x *Node) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -func (x *Node) GetMachineKey() string { - if x != nil { - return x.MachineKey - } - return "" -} - -func (x *Node) GetNodeKey() string { - if x != nil { - return x.NodeKey - } - return "" -} - -func (x *Node) GetDiscoKey() string { - if x != nil { - return x.DiscoKey - } - return "" -} - -func (x *Node) GetIpAddresses() []string { - if x != nil { - return x.IpAddresses - } - return nil -} - -func (x *Node) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *Node) GetUser() *User { - if x != nil { - return x.User - } - return nil -} - -func (x *Node) GetLastSeen() *timestamppb.Timestamp { - if x != nil { - return x.LastSeen - } - return nil -} - -func (x *Node) GetExpiry() *timestamppb.Timestamp { - if x != nil { - return x.Expiry - } - return nil -} - -func (x *Node) GetPreAuthKey() *PreAuthKey { - if x != nil { - return x.PreAuthKey - } - return nil -} - -func (x *Node) GetCreatedAt() *timestamppb.Timestamp { - if x != nil { - return x.CreatedAt - } - return nil -} - -func (x *Node) GetRegisterMethod() RegisterMethod { - if x != nil { - return x.RegisterMethod - } - return RegisterMethod_REGISTER_METHOD_UNSPECIFIED -} - -func (x *Node) GetGivenName() string { - if x != nil { - return x.GivenName - } - return "" -} - -func (x *Node) GetOnline() bool { - if x != nil { - return x.Online - } - return false -} - -func (x *Node) GetApprovedRoutes() []string { - if x != nil { - return x.ApprovedRoutes - } - return nil -} - -func (x *Node) GetAvailableRoutes() []string { - if x != nil { - return x.AvailableRoutes - } - return nil -} - -func (x *Node) GetSubnetRoutes() []string { - if x != nil { - return x.SubnetRoutes - } - return nil -} - -func (x *Node) GetTags() []string { - if x != nil { - return x.Tags - } - return nil -} - -type RegisterNodeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RegisterNodeRequest) Reset() { - *x = RegisterNodeRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RegisterNodeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RegisterNodeRequest) ProtoMessage() {} - -func (x *RegisterNodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RegisterNodeRequest.ProtoReflect.Descriptor instead. -func (*RegisterNodeRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{1} -} - -func (x *RegisterNodeRequest) GetUser() string { - if x != nil { - return x.User - } - return "" -} - -func (x *RegisterNodeRequest) GetKey() string { - if x != nil { - return x.Key - } - return "" -} - -type RegisterNodeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RegisterNodeResponse) Reset() { - *x = RegisterNodeResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RegisterNodeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RegisterNodeResponse) ProtoMessage() {} - -func (x *RegisterNodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RegisterNodeResponse.ProtoReflect.Descriptor instead. -func (*RegisterNodeResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{2} -} - -func (x *RegisterNodeResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type GetNodeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetNodeRequest) Reset() { - *x = GetNodeRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetNodeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetNodeRequest) ProtoMessage() {} - -func (x *GetNodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetNodeRequest.ProtoReflect.Descriptor instead. -func (*GetNodeRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{3} -} - -func (x *GetNodeRequest) GetNodeId() uint64 { - if x != nil { - return x.NodeId - } - return 0 -} - -type GetNodeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetNodeResponse) Reset() { - *x = GetNodeResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetNodeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetNodeResponse) ProtoMessage() {} - -func (x *GetNodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetNodeResponse.ProtoReflect.Descriptor instead. -func (*GetNodeResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{4} -} - -func (x *GetNodeResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type SetTagsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` - Tags []string `protobuf:"bytes,2,rep,name=tags,proto3" json:"tags,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetTagsRequest) Reset() { - *x = SetTagsRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetTagsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetTagsRequest) ProtoMessage() {} - -func (x *SetTagsRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetTagsRequest.ProtoReflect.Descriptor instead. -func (*SetTagsRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{5} -} - -func (x *SetTagsRequest) GetNodeId() uint64 { - if x != nil { - return x.NodeId - } - return 0 -} - -func (x *SetTagsRequest) GetTags() []string { - if x != nil { - return x.Tags - } - return nil -} - -type SetTagsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetTagsResponse) Reset() { - *x = SetTagsResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetTagsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetTagsResponse) ProtoMessage() {} - -func (x *SetTagsResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetTagsResponse.ProtoReflect.Descriptor instead. -func (*SetTagsResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{6} -} - -func (x *SetTagsResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type SetApprovedRoutesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` - Routes []string `protobuf:"bytes,2,rep,name=routes,proto3" json:"routes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetApprovedRoutesRequest) Reset() { - *x = SetApprovedRoutesRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetApprovedRoutesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetApprovedRoutesRequest) ProtoMessage() {} - -func (x *SetApprovedRoutesRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetApprovedRoutesRequest.ProtoReflect.Descriptor instead. -func (*SetApprovedRoutesRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{7} -} - -func (x *SetApprovedRoutesRequest) GetNodeId() uint64 { - if x != nil { - return x.NodeId - } - return 0 -} - -func (x *SetApprovedRoutesRequest) GetRoutes() []string { - if x != nil { - return x.Routes - } - return nil -} - -type SetApprovedRoutesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetApprovedRoutesResponse) Reset() { - *x = SetApprovedRoutesResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetApprovedRoutesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetApprovedRoutesResponse) ProtoMessage() {} - -func (x *SetApprovedRoutesResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetApprovedRoutesResponse.ProtoReflect.Descriptor instead. -func (*SetApprovedRoutesResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{8} -} - -func (x *SetApprovedRoutesResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type DeleteNodeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteNodeRequest) Reset() { - *x = DeleteNodeRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteNodeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteNodeRequest) ProtoMessage() {} - -func (x *DeleteNodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[9] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteNodeRequest.ProtoReflect.Descriptor instead. -func (*DeleteNodeRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{9} -} - -func (x *DeleteNodeRequest) GetNodeId() uint64 { - if x != nil { - return x.NodeId - } - return 0 -} - -type DeleteNodeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteNodeResponse) Reset() { - *x = DeleteNodeResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteNodeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteNodeResponse) ProtoMessage() {} - -func (x *DeleteNodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[10] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteNodeResponse.ProtoReflect.Descriptor instead. -func (*DeleteNodeResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{10} -} - -type ExpireNodeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` - Expiry *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expiry,proto3" json:"expiry,omitempty"` - // When true, sets expiry to null (node will never expire). - DisableExpiry bool `protobuf:"varint,3,opt,name=disable_expiry,json=disableExpiry,proto3" json:"disable_expiry,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExpireNodeRequest) Reset() { - *x = ExpireNodeRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExpireNodeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExpireNodeRequest) ProtoMessage() {} - -func (x *ExpireNodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[11] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExpireNodeRequest.ProtoReflect.Descriptor instead. -func (*ExpireNodeRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{11} -} - -func (x *ExpireNodeRequest) GetNodeId() uint64 { - if x != nil { - return x.NodeId - } - return 0 -} - -func (x *ExpireNodeRequest) GetExpiry() *timestamppb.Timestamp { - if x != nil { - return x.Expiry - } - return nil -} - -func (x *ExpireNodeRequest) GetDisableExpiry() bool { - if x != nil { - return x.DisableExpiry - } - return false -} - -type ExpireNodeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExpireNodeResponse) Reset() { - *x = ExpireNodeResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExpireNodeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExpireNodeResponse) ProtoMessage() {} - -func (x *ExpireNodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[12] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExpireNodeResponse.ProtoReflect.Descriptor instead. -func (*ExpireNodeResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{12} -} - -func (x *ExpireNodeResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type RenameNodeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` - NewName string `protobuf:"bytes,2,opt,name=new_name,json=newName,proto3" json:"new_name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenameNodeRequest) Reset() { - *x = RenameNodeRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenameNodeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenameNodeRequest) ProtoMessage() {} - -func (x *RenameNodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[13] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RenameNodeRequest.ProtoReflect.Descriptor instead. -func (*RenameNodeRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{13} -} - -func (x *RenameNodeRequest) GetNodeId() uint64 { - if x != nil { - return x.NodeId - } - return 0 -} - -func (x *RenameNodeRequest) GetNewName() string { - if x != nil { - return x.NewName - } - return "" -} - -type RenameNodeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenameNodeResponse) Reset() { - *x = RenameNodeResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenameNodeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenameNodeResponse) ProtoMessage() {} - -func (x *RenameNodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[14] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RenameNodeResponse.ProtoReflect.Descriptor instead. -func (*RenameNodeResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{14} -} - -func (x *RenameNodeResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type ListNodesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListNodesRequest) Reset() { - *x = ListNodesRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListNodesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListNodesRequest) ProtoMessage() {} - -func (x *ListNodesRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[15] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListNodesRequest.ProtoReflect.Descriptor instead. -func (*ListNodesRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{15} -} - -func (x *ListNodesRequest) GetUser() string { - if x != nil { - return x.User - } - return "" -} - -type ListNodesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Nodes []*Node `protobuf:"bytes,1,rep,name=nodes,proto3" json:"nodes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListNodesResponse) Reset() { - *x = ListNodesResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListNodesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListNodesResponse) ProtoMessage() {} - -func (x *ListNodesResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[16] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListNodesResponse.ProtoReflect.Descriptor instead. -func (*ListNodesResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{16} -} - -func (x *ListNodesResponse) GetNodes() []*Node { - if x != nil { - return x.Nodes - } - return nil -} - -type DebugCreateNodeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` - Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` - Routes []string `protobuf:"bytes,4,rep,name=routes,proto3" json:"routes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DebugCreateNodeRequest) Reset() { - *x = DebugCreateNodeRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DebugCreateNodeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DebugCreateNodeRequest) ProtoMessage() {} - -func (x *DebugCreateNodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[17] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DebugCreateNodeRequest.ProtoReflect.Descriptor instead. -func (*DebugCreateNodeRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{17} -} - -func (x *DebugCreateNodeRequest) GetUser() string { - if x != nil { - return x.User - } - return "" -} - -func (x *DebugCreateNodeRequest) GetKey() string { - if x != nil { - return x.Key - } - return "" -} - -func (x *DebugCreateNodeRequest) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *DebugCreateNodeRequest) GetRoutes() []string { - if x != nil { - return x.Routes - } - return nil -} - -type DebugCreateNodeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DebugCreateNodeResponse) Reset() { - *x = DebugCreateNodeResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DebugCreateNodeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DebugCreateNodeResponse) ProtoMessage() {} - -func (x *DebugCreateNodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[18] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DebugCreateNodeResponse.ProtoReflect.Descriptor instead. -func (*DebugCreateNodeResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{18} -} - -func (x *DebugCreateNodeResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type BackfillNodeIPsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Confirmed bool `protobuf:"varint,1,opt,name=confirmed,proto3" json:"confirmed,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *BackfillNodeIPsRequest) Reset() { - *x = BackfillNodeIPsRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *BackfillNodeIPsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*BackfillNodeIPsRequest) ProtoMessage() {} - -func (x *BackfillNodeIPsRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[19] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use BackfillNodeIPsRequest.ProtoReflect.Descriptor instead. -func (*BackfillNodeIPsRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{19} -} - -func (x *BackfillNodeIPsRequest) GetConfirmed() bool { - if x != nil { - return x.Confirmed - } - return false -} - -type BackfillNodeIPsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Changes []string `protobuf:"bytes,1,rep,name=changes,proto3" json:"changes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *BackfillNodeIPsResponse) Reset() { - *x = BackfillNodeIPsResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[20] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *BackfillNodeIPsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*BackfillNodeIPsResponse) ProtoMessage() {} - -func (x *BackfillNodeIPsResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[20] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use BackfillNodeIPsResponse.ProtoReflect.Descriptor instead. -func (*BackfillNodeIPsResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{20} -} - -func (x *BackfillNodeIPsResponse) GetChanges() []string { - if x != nil { - return x.Changes - } - return nil -} - -var File_headscale_v1_node_proto protoreflect.FileDescriptor - -const file_headscale_v1_node_proto_rawDesc = "" + - "\n" + - "\x17headscale/v1/node.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1dheadscale/v1/preauthkey.proto\x1a\x17headscale/v1/user.proto\"\xc9\x05\n" + - "\x04Node\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x04R\x02id\x12\x1f\n" + - "\vmachine_key\x18\x02 \x01(\tR\n" + - "machineKey\x12\x19\n" + - "\bnode_key\x18\x03 \x01(\tR\anodeKey\x12\x1b\n" + - "\tdisco_key\x18\x04 \x01(\tR\bdiscoKey\x12!\n" + - "\fip_addresses\x18\x05 \x03(\tR\vipAddresses\x12\x12\n" + - "\x04name\x18\x06 \x01(\tR\x04name\x12&\n" + - "\x04user\x18\a \x01(\v2\x12.headscale.v1.UserR\x04user\x127\n" + - "\tlast_seen\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\blastSeen\x122\n" + - "\x06expiry\x18\n" + - " \x01(\v2\x1a.google.protobuf.TimestampR\x06expiry\x12:\n" + - "\fpre_auth_key\x18\v \x01(\v2\x18.headscale.v1.PreAuthKeyR\n" + - "preAuthKey\x129\n" + - "\n" + - "created_at\x18\f \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12E\n" + - "\x0fregister_method\x18\r \x01(\x0e2\x1c.headscale.v1.RegisterMethodR\x0eregisterMethod\x12\x1d\n" + - "\n" + - "given_name\x18\x15 \x01(\tR\tgivenName\x12\x16\n" + - "\x06online\x18\x16 \x01(\bR\x06online\x12'\n" + - "\x0fapproved_routes\x18\x17 \x03(\tR\x0eapprovedRoutes\x12)\n" + - "\x10available_routes\x18\x18 \x03(\tR\x0favailableRoutes\x12#\n" + - "\rsubnet_routes\x18\x19 \x03(\tR\fsubnetRoutes\x12\x12\n" + - "\x04tags\x18\x1a \x03(\tR\x04tagsJ\x04\b\t\x10\n" + - "J\x04\b\x0e\x10\x15\";\n" + - "\x13RegisterNodeRequest\x12\x12\n" + - "\x04user\x18\x01 \x01(\tR\x04user\x12\x10\n" + - "\x03key\x18\x02 \x01(\tR\x03key\">\n" + - "\x14RegisterNodeResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\")\n" + - "\x0eGetNodeRequest\x12\x17\n" + - "\anode_id\x18\x01 \x01(\x04R\x06nodeId\"9\n" + - "\x0fGetNodeResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"=\n" + - "\x0eSetTagsRequest\x12\x17\n" + - "\anode_id\x18\x01 \x01(\x04R\x06nodeId\x12\x12\n" + - "\x04tags\x18\x02 \x03(\tR\x04tags\"9\n" + - "\x0fSetTagsResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"K\n" + - "\x18SetApprovedRoutesRequest\x12\x17\n" + - "\anode_id\x18\x01 \x01(\x04R\x06nodeId\x12\x16\n" + - "\x06routes\x18\x02 \x03(\tR\x06routes\"C\n" + - "\x19SetApprovedRoutesResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\",\n" + - "\x11DeleteNodeRequest\x12\x17\n" + - "\anode_id\x18\x01 \x01(\x04R\x06nodeId\"\x14\n" + - "\x12DeleteNodeResponse\"\x87\x01\n" + - "\x11ExpireNodeRequest\x12\x17\n" + - "\anode_id\x18\x01 \x01(\x04R\x06nodeId\x122\n" + - "\x06expiry\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x06expiry\x12%\n" + - "\x0edisable_expiry\x18\x03 \x01(\bR\rdisableExpiry\"<\n" + - "\x12ExpireNodeResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"G\n" + - "\x11RenameNodeRequest\x12\x17\n" + - "\anode_id\x18\x01 \x01(\x04R\x06nodeId\x12\x19\n" + - "\bnew_name\x18\x02 \x01(\tR\anewName\"<\n" + - "\x12RenameNodeResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"&\n" + - "\x10ListNodesRequest\x12\x12\n" + - "\x04user\x18\x01 \x01(\tR\x04user\"=\n" + - "\x11ListNodesResponse\x12(\n" + - "\x05nodes\x18\x01 \x03(\v2\x12.headscale.v1.NodeR\x05nodes\"j\n" + - "\x16DebugCreateNodeRequest\x12\x12\n" + - "\x04user\x18\x01 \x01(\tR\x04user\x12\x10\n" + - "\x03key\x18\x02 \x01(\tR\x03key\x12\x12\n" + - "\x04name\x18\x03 \x01(\tR\x04name\x12\x16\n" + - "\x06routes\x18\x04 \x03(\tR\x06routes\"A\n" + - "\x17DebugCreateNodeResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"6\n" + - "\x16BackfillNodeIPsRequest\x12\x1c\n" + - "\tconfirmed\x18\x01 \x01(\bR\tconfirmed\"3\n" + - "\x17BackfillNodeIPsResponse\x12\x18\n" + - "\achanges\x18\x01 \x03(\tR\achanges*\x82\x01\n" + - "\x0eRegisterMethod\x12\x1f\n" + - "\x1bREGISTER_METHOD_UNSPECIFIED\x10\x00\x12\x1c\n" + - "\x18REGISTER_METHOD_AUTH_KEY\x10\x01\x12\x17\n" + - "\x13REGISTER_METHOD_CLI\x10\x02\x12\x18\n" + - "\x14REGISTER_METHOD_OIDC\x10\x03B)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_node_proto_rawDescOnce sync.Once - file_headscale_v1_node_proto_rawDescData []byte -) - -func file_headscale_v1_node_proto_rawDescGZIP() []byte { - file_headscale_v1_node_proto_rawDescOnce.Do(func() { - file_headscale_v1_node_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_node_proto_rawDesc), len(file_headscale_v1_node_proto_rawDesc))) - }) - return file_headscale_v1_node_proto_rawDescData -} - -var file_headscale_v1_node_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_headscale_v1_node_proto_msgTypes = make([]protoimpl.MessageInfo, 21) -var file_headscale_v1_node_proto_goTypes = []any{ - (RegisterMethod)(0), // 0: headscale.v1.RegisterMethod - (*Node)(nil), // 1: headscale.v1.Node - (*RegisterNodeRequest)(nil), // 2: headscale.v1.RegisterNodeRequest - (*RegisterNodeResponse)(nil), // 3: headscale.v1.RegisterNodeResponse - (*GetNodeRequest)(nil), // 4: headscale.v1.GetNodeRequest - (*GetNodeResponse)(nil), // 5: headscale.v1.GetNodeResponse - (*SetTagsRequest)(nil), // 6: headscale.v1.SetTagsRequest - (*SetTagsResponse)(nil), // 7: headscale.v1.SetTagsResponse - (*SetApprovedRoutesRequest)(nil), // 8: headscale.v1.SetApprovedRoutesRequest - (*SetApprovedRoutesResponse)(nil), // 9: headscale.v1.SetApprovedRoutesResponse - (*DeleteNodeRequest)(nil), // 10: headscale.v1.DeleteNodeRequest - (*DeleteNodeResponse)(nil), // 11: headscale.v1.DeleteNodeResponse - (*ExpireNodeRequest)(nil), // 12: headscale.v1.ExpireNodeRequest - (*ExpireNodeResponse)(nil), // 13: headscale.v1.ExpireNodeResponse - (*RenameNodeRequest)(nil), // 14: headscale.v1.RenameNodeRequest - (*RenameNodeResponse)(nil), // 15: headscale.v1.RenameNodeResponse - (*ListNodesRequest)(nil), // 16: headscale.v1.ListNodesRequest - (*ListNodesResponse)(nil), // 17: headscale.v1.ListNodesResponse - (*DebugCreateNodeRequest)(nil), // 18: headscale.v1.DebugCreateNodeRequest - (*DebugCreateNodeResponse)(nil), // 19: headscale.v1.DebugCreateNodeResponse - (*BackfillNodeIPsRequest)(nil), // 20: headscale.v1.BackfillNodeIPsRequest - (*BackfillNodeIPsResponse)(nil), // 21: headscale.v1.BackfillNodeIPsResponse - (*User)(nil), // 22: headscale.v1.User - (*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp - (*PreAuthKey)(nil), // 24: headscale.v1.PreAuthKey -} -var file_headscale_v1_node_proto_depIdxs = []int32{ - 22, // 0: headscale.v1.Node.user:type_name -> headscale.v1.User - 23, // 1: headscale.v1.Node.last_seen:type_name -> google.protobuf.Timestamp - 23, // 2: headscale.v1.Node.expiry:type_name -> google.protobuf.Timestamp - 24, // 3: headscale.v1.Node.pre_auth_key:type_name -> headscale.v1.PreAuthKey - 23, // 4: headscale.v1.Node.created_at:type_name -> google.protobuf.Timestamp - 0, // 5: headscale.v1.Node.register_method:type_name -> headscale.v1.RegisterMethod - 1, // 6: headscale.v1.RegisterNodeResponse.node:type_name -> headscale.v1.Node - 1, // 7: headscale.v1.GetNodeResponse.node:type_name -> headscale.v1.Node - 1, // 8: headscale.v1.SetTagsResponse.node:type_name -> headscale.v1.Node - 1, // 9: headscale.v1.SetApprovedRoutesResponse.node:type_name -> headscale.v1.Node - 23, // 10: headscale.v1.ExpireNodeRequest.expiry:type_name -> google.protobuf.Timestamp - 1, // 11: headscale.v1.ExpireNodeResponse.node:type_name -> headscale.v1.Node - 1, // 12: headscale.v1.RenameNodeResponse.node:type_name -> headscale.v1.Node - 1, // 13: headscale.v1.ListNodesResponse.nodes:type_name -> headscale.v1.Node - 1, // 14: headscale.v1.DebugCreateNodeResponse.node:type_name -> headscale.v1.Node - 15, // [15:15] is the sub-list for method output_type - 15, // [15:15] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name -} - -func init() { file_headscale_v1_node_proto_init() } -func file_headscale_v1_node_proto_init() { - if File_headscale_v1_node_proto != nil { - return - } - file_headscale_v1_preauthkey_proto_init() - file_headscale_v1_user_proto_init() - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_node_proto_rawDesc), len(file_headscale_v1_node_proto_rawDesc)), - NumEnums: 1, - NumMessages: 21, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_headscale_v1_node_proto_goTypes, - DependencyIndexes: file_headscale_v1_node_proto_depIdxs, - EnumInfos: file_headscale_v1_node_proto_enumTypes, - MessageInfos: file_headscale_v1_node_proto_msgTypes, - }.Build() - File_headscale_v1_node_proto = out.File - file_headscale_v1_node_proto_goTypes = nil - file_headscale_v1_node_proto_depIdxs = nil -} diff --git a/gen/go/headscale/v1/policy.pb.go b/gen/go/headscale/v1/policy.pb.go deleted file mode 100644 index 7b58ac121..000000000 --- a/gen/go/headscale/v1/policy.pb.go +++ /dev/null @@ -1,363 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/policy.proto - -package v1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type SetPolicyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetPolicyRequest) Reset() { - *x = SetPolicyRequest{} - mi := &file_headscale_v1_policy_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetPolicyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetPolicyRequest) ProtoMessage() {} - -func (x *SetPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_policy_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetPolicyRequest.ProtoReflect.Descriptor instead. -func (*SetPolicyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_policy_proto_rawDescGZIP(), []int{0} -} - -func (x *SetPolicyRequest) GetPolicy() string { - if x != nil { - return x.Policy - } - return "" -} - -type SetPolicyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"` - UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetPolicyResponse) Reset() { - *x = SetPolicyResponse{} - mi := &file_headscale_v1_policy_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetPolicyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetPolicyResponse) ProtoMessage() {} - -func (x *SetPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_policy_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetPolicyResponse.ProtoReflect.Descriptor instead. -func (*SetPolicyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_policy_proto_rawDescGZIP(), []int{1} -} - -func (x *SetPolicyResponse) GetPolicy() string { - if x != nil { - return x.Policy - } - return "" -} - -func (x *SetPolicyResponse) GetUpdatedAt() *timestamppb.Timestamp { - if x != nil { - return x.UpdatedAt - } - return nil -} - -type GetPolicyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetPolicyRequest) Reset() { - *x = GetPolicyRequest{} - mi := &file_headscale_v1_policy_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetPolicyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetPolicyRequest) ProtoMessage() {} - -func (x *GetPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_policy_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetPolicyRequest.ProtoReflect.Descriptor instead. -func (*GetPolicyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_policy_proto_rawDescGZIP(), []int{2} -} - -type GetPolicyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"` - UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetPolicyResponse) Reset() { - *x = GetPolicyResponse{} - mi := &file_headscale_v1_policy_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetPolicyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetPolicyResponse) ProtoMessage() {} - -func (x *GetPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_policy_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetPolicyResponse.ProtoReflect.Descriptor instead. -func (*GetPolicyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_policy_proto_rawDescGZIP(), []int{3} -} - -func (x *GetPolicyResponse) GetPolicy() string { - if x != nil { - return x.Policy - } - return "" -} - -func (x *GetPolicyResponse) GetUpdatedAt() *timestamppb.Timestamp { - if x != nil { - return x.UpdatedAt - } - return nil -} - -type CheckPolicyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CheckPolicyRequest) Reset() { - *x = CheckPolicyRequest{} - mi := &file_headscale_v1_policy_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CheckPolicyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CheckPolicyRequest) ProtoMessage() {} - -func (x *CheckPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_policy_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CheckPolicyRequest.ProtoReflect.Descriptor instead. -func (*CheckPolicyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_policy_proto_rawDescGZIP(), []int{4} -} - -func (x *CheckPolicyRequest) GetPolicy() string { - if x != nil { - return x.Policy - } - return "" -} - -type CheckPolicyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CheckPolicyResponse) Reset() { - *x = CheckPolicyResponse{} - mi := &file_headscale_v1_policy_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CheckPolicyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CheckPolicyResponse) ProtoMessage() {} - -func (x *CheckPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_policy_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CheckPolicyResponse.ProtoReflect.Descriptor instead. -func (*CheckPolicyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_policy_proto_rawDescGZIP(), []int{5} -} - -var File_headscale_v1_policy_proto protoreflect.FileDescriptor - -const file_headscale_v1_policy_proto_rawDesc = "" + - "\n" + - "\x19headscale/v1/policy.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"*\n" + - "\x10SetPolicyRequest\x12\x16\n" + - "\x06policy\x18\x01 \x01(\tR\x06policy\"f\n" + - "\x11SetPolicyResponse\x12\x16\n" + - "\x06policy\x18\x01 \x01(\tR\x06policy\x129\n" + - "\n" + - "updated_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"\x12\n" + - "\x10GetPolicyRequest\"f\n" + - "\x11GetPolicyResponse\x12\x16\n" + - "\x06policy\x18\x01 \x01(\tR\x06policy\x129\n" + - "\n" + - "updated_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\",\n" + - "\x12CheckPolicyRequest\x12\x16\n" + - "\x06policy\x18\x01 \x01(\tR\x06policy\"\x15\n" + - "\x13CheckPolicyResponseB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_policy_proto_rawDescOnce sync.Once - file_headscale_v1_policy_proto_rawDescData []byte -) - -func file_headscale_v1_policy_proto_rawDescGZIP() []byte { - file_headscale_v1_policy_proto_rawDescOnce.Do(func() { - file_headscale_v1_policy_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_policy_proto_rawDesc), len(file_headscale_v1_policy_proto_rawDesc))) - }) - return file_headscale_v1_policy_proto_rawDescData -} - -var file_headscale_v1_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 6) -var file_headscale_v1_policy_proto_goTypes = []any{ - (*SetPolicyRequest)(nil), // 0: headscale.v1.SetPolicyRequest - (*SetPolicyResponse)(nil), // 1: headscale.v1.SetPolicyResponse - (*GetPolicyRequest)(nil), // 2: headscale.v1.GetPolicyRequest - (*GetPolicyResponse)(nil), // 3: headscale.v1.GetPolicyResponse - (*CheckPolicyRequest)(nil), // 4: headscale.v1.CheckPolicyRequest - (*CheckPolicyResponse)(nil), // 5: headscale.v1.CheckPolicyResponse - (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp -} -var file_headscale_v1_policy_proto_depIdxs = []int32{ - 6, // 0: headscale.v1.SetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp - 6, // 1: headscale.v1.GetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name -} - -func init() { file_headscale_v1_policy_proto_init() } -func file_headscale_v1_policy_proto_init() { - if File_headscale_v1_policy_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_policy_proto_rawDesc), len(file_headscale_v1_policy_proto_rawDesc)), - NumEnums: 0, - NumMessages: 6, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_headscale_v1_policy_proto_goTypes, - DependencyIndexes: file_headscale_v1_policy_proto_depIdxs, - MessageInfos: file_headscale_v1_policy_proto_msgTypes, - }.Build() - File_headscale_v1_policy_proto = out.File - file_headscale_v1_policy_proto_goTypes = nil - file_headscale_v1_policy_proto_depIdxs = nil -} diff --git a/gen/go/headscale/v1/preauthkey.pb.go b/gen/go/headscale/v1/preauthkey.pb.go deleted file mode 100644 index ff902d45c..000000000 --- a/gen/go/headscale/v1/preauthkey.pb.go +++ /dev/null @@ -1,596 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/preauthkey.proto - -package v1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type PreAuthKey struct { - state protoimpl.MessageState `protogen:"open.v1"` - User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` - Key string `protobuf:"bytes,3,opt,name=key,proto3" json:"key,omitempty"` - Reusable bool `protobuf:"varint,4,opt,name=reusable,proto3" json:"reusable,omitempty"` - Ephemeral bool `protobuf:"varint,5,opt,name=ephemeral,proto3" json:"ephemeral,omitempty"` - Used bool `protobuf:"varint,6,opt,name=used,proto3" json:"used,omitempty"` - Expiration *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=expiration,proto3" json:"expiration,omitempty"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - AclTags []string `protobuf:"bytes,9,rep,name=acl_tags,json=aclTags,proto3" json:"acl_tags,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PreAuthKey) Reset() { - *x = PreAuthKey{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PreAuthKey) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PreAuthKey) ProtoMessage() {} - -func (x *PreAuthKey) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PreAuthKey.ProtoReflect.Descriptor instead. -func (*PreAuthKey) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{0} -} - -func (x *PreAuthKey) GetUser() *User { - if x != nil { - return x.User - } - return nil -} - -func (x *PreAuthKey) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -func (x *PreAuthKey) GetKey() string { - if x != nil { - return x.Key - } - return "" -} - -func (x *PreAuthKey) GetReusable() bool { - if x != nil { - return x.Reusable - } - return false -} - -func (x *PreAuthKey) GetEphemeral() bool { - if x != nil { - return x.Ephemeral - } - return false -} - -func (x *PreAuthKey) GetUsed() bool { - if x != nil { - return x.Used - } - return false -} - -func (x *PreAuthKey) GetExpiration() *timestamppb.Timestamp { - if x != nil { - return x.Expiration - } - return nil -} - -func (x *PreAuthKey) GetCreatedAt() *timestamppb.Timestamp { - if x != nil { - return x.CreatedAt - } - return nil -} - -func (x *PreAuthKey) GetAclTags() []string { - if x != nil { - return x.AclTags - } - return nil -} - -type CreatePreAuthKeyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - User uint64 `protobuf:"varint,1,opt,name=user,proto3" json:"user,omitempty"` - Reusable bool `protobuf:"varint,2,opt,name=reusable,proto3" json:"reusable,omitempty"` - Ephemeral bool `protobuf:"varint,3,opt,name=ephemeral,proto3" json:"ephemeral,omitempty"` - Expiration *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=expiration,proto3" json:"expiration,omitempty"` - AclTags []string `protobuf:"bytes,5,rep,name=acl_tags,json=aclTags,proto3" json:"acl_tags,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreatePreAuthKeyRequest) Reset() { - *x = CreatePreAuthKeyRequest{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreatePreAuthKeyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreatePreAuthKeyRequest) ProtoMessage() {} - -func (x *CreatePreAuthKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreatePreAuthKeyRequest.ProtoReflect.Descriptor instead. -func (*CreatePreAuthKeyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{1} -} - -func (x *CreatePreAuthKeyRequest) GetUser() uint64 { - if x != nil { - return x.User - } - return 0 -} - -func (x *CreatePreAuthKeyRequest) GetReusable() bool { - if x != nil { - return x.Reusable - } - return false -} - -func (x *CreatePreAuthKeyRequest) GetEphemeral() bool { - if x != nil { - return x.Ephemeral - } - return false -} - -func (x *CreatePreAuthKeyRequest) GetExpiration() *timestamppb.Timestamp { - if x != nil { - return x.Expiration - } - return nil -} - -func (x *CreatePreAuthKeyRequest) GetAclTags() []string { - if x != nil { - return x.AclTags - } - return nil -} - -type CreatePreAuthKeyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - PreAuthKey *PreAuthKey `protobuf:"bytes,1,opt,name=pre_auth_key,json=preAuthKey,proto3" json:"pre_auth_key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreatePreAuthKeyResponse) Reset() { - *x = CreatePreAuthKeyResponse{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreatePreAuthKeyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreatePreAuthKeyResponse) ProtoMessage() {} - -func (x *CreatePreAuthKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreatePreAuthKeyResponse.ProtoReflect.Descriptor instead. -func (*CreatePreAuthKeyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{2} -} - -func (x *CreatePreAuthKeyResponse) GetPreAuthKey() *PreAuthKey { - if x != nil { - return x.PreAuthKey - } - return nil -} - -type ExpirePreAuthKeyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExpirePreAuthKeyRequest) Reset() { - *x = ExpirePreAuthKeyRequest{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExpirePreAuthKeyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExpirePreAuthKeyRequest) ProtoMessage() {} - -func (x *ExpirePreAuthKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExpirePreAuthKeyRequest.ProtoReflect.Descriptor instead. -func (*ExpirePreAuthKeyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{3} -} - -func (x *ExpirePreAuthKeyRequest) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -type ExpirePreAuthKeyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExpirePreAuthKeyResponse) Reset() { - *x = ExpirePreAuthKeyResponse{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExpirePreAuthKeyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExpirePreAuthKeyResponse) ProtoMessage() {} - -func (x *ExpirePreAuthKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExpirePreAuthKeyResponse.ProtoReflect.Descriptor instead. -func (*ExpirePreAuthKeyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{4} -} - -type DeletePreAuthKeyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeletePreAuthKeyRequest) Reset() { - *x = DeletePreAuthKeyRequest{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeletePreAuthKeyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeletePreAuthKeyRequest) ProtoMessage() {} - -func (x *DeletePreAuthKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeletePreAuthKeyRequest.ProtoReflect.Descriptor instead. -func (*DeletePreAuthKeyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{5} -} - -func (x *DeletePreAuthKeyRequest) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -type DeletePreAuthKeyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeletePreAuthKeyResponse) Reset() { - *x = DeletePreAuthKeyResponse{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeletePreAuthKeyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeletePreAuthKeyResponse) ProtoMessage() {} - -func (x *DeletePreAuthKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeletePreAuthKeyResponse.ProtoReflect.Descriptor instead. -func (*DeletePreAuthKeyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{6} -} - -type ListPreAuthKeysRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListPreAuthKeysRequest) Reset() { - *x = ListPreAuthKeysRequest{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListPreAuthKeysRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListPreAuthKeysRequest) ProtoMessage() {} - -func (x *ListPreAuthKeysRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListPreAuthKeysRequest.ProtoReflect.Descriptor instead. -func (*ListPreAuthKeysRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{7} -} - -type ListPreAuthKeysResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - PreAuthKeys []*PreAuthKey `protobuf:"bytes,1,rep,name=pre_auth_keys,json=preAuthKeys,proto3" json:"pre_auth_keys,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListPreAuthKeysResponse) Reset() { - *x = ListPreAuthKeysResponse{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListPreAuthKeysResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListPreAuthKeysResponse) ProtoMessage() {} - -func (x *ListPreAuthKeysResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListPreAuthKeysResponse.ProtoReflect.Descriptor instead. -func (*ListPreAuthKeysResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{8} -} - -func (x *ListPreAuthKeysResponse) GetPreAuthKeys() []*PreAuthKey { - if x != nil { - return x.PreAuthKeys - } - return nil -} - -var File_headscale_v1_preauthkey_proto protoreflect.FileDescriptor - -const file_headscale_v1_preauthkey_proto_rawDesc = "" + - "\n" + - "\x1dheadscale/v1/preauthkey.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17headscale/v1/user.proto\"\xb6\x02\n" + - "\n" + - "PreAuthKey\x12&\n" + - "\x04user\x18\x01 \x01(\v2\x12.headscale.v1.UserR\x04user\x12\x0e\n" + - "\x02id\x18\x02 \x01(\x04R\x02id\x12\x10\n" + - "\x03key\x18\x03 \x01(\tR\x03key\x12\x1a\n" + - "\breusable\x18\x04 \x01(\bR\breusable\x12\x1c\n" + - "\tephemeral\x18\x05 \x01(\bR\tephemeral\x12\x12\n" + - "\x04used\x18\x06 \x01(\bR\x04used\x12:\n" + - "\n" + - "expiration\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "expiration\x129\n" + - "\n" + - "created_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x19\n" + - "\bacl_tags\x18\t \x03(\tR\aaclTags\"\xbe\x01\n" + - "\x17CreatePreAuthKeyRequest\x12\x12\n" + - "\x04user\x18\x01 \x01(\x04R\x04user\x12\x1a\n" + - "\breusable\x18\x02 \x01(\bR\breusable\x12\x1c\n" + - "\tephemeral\x18\x03 \x01(\bR\tephemeral\x12:\n" + - "\n" + - "expiration\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "expiration\x12\x19\n" + - "\bacl_tags\x18\x05 \x03(\tR\aaclTags\"V\n" + - "\x18CreatePreAuthKeyResponse\x12:\n" + - "\fpre_auth_key\x18\x01 \x01(\v2\x18.headscale.v1.PreAuthKeyR\n" + - "preAuthKey\")\n" + - "\x17ExpirePreAuthKeyRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x04R\x02id\"\x1a\n" + - "\x18ExpirePreAuthKeyResponse\")\n" + - "\x17DeletePreAuthKeyRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x04R\x02id\"\x1a\n" + - "\x18DeletePreAuthKeyResponse\"\x18\n" + - "\x16ListPreAuthKeysRequest\"W\n" + - "\x17ListPreAuthKeysResponse\x12<\n" + - "\rpre_auth_keys\x18\x01 \x03(\v2\x18.headscale.v1.PreAuthKeyR\vpreAuthKeysB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_preauthkey_proto_rawDescOnce sync.Once - file_headscale_v1_preauthkey_proto_rawDescData []byte -) - -func file_headscale_v1_preauthkey_proto_rawDescGZIP() []byte { - file_headscale_v1_preauthkey_proto_rawDescOnce.Do(func() { - file_headscale_v1_preauthkey_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_preauthkey_proto_rawDesc), len(file_headscale_v1_preauthkey_proto_rawDesc))) - }) - return file_headscale_v1_preauthkey_proto_rawDescData -} - -var file_headscale_v1_preauthkey_proto_msgTypes = make([]protoimpl.MessageInfo, 9) -var file_headscale_v1_preauthkey_proto_goTypes = []any{ - (*PreAuthKey)(nil), // 0: headscale.v1.PreAuthKey - (*CreatePreAuthKeyRequest)(nil), // 1: headscale.v1.CreatePreAuthKeyRequest - (*CreatePreAuthKeyResponse)(nil), // 2: headscale.v1.CreatePreAuthKeyResponse - (*ExpirePreAuthKeyRequest)(nil), // 3: headscale.v1.ExpirePreAuthKeyRequest - (*ExpirePreAuthKeyResponse)(nil), // 4: headscale.v1.ExpirePreAuthKeyResponse - (*DeletePreAuthKeyRequest)(nil), // 5: headscale.v1.DeletePreAuthKeyRequest - (*DeletePreAuthKeyResponse)(nil), // 6: headscale.v1.DeletePreAuthKeyResponse - (*ListPreAuthKeysRequest)(nil), // 7: headscale.v1.ListPreAuthKeysRequest - (*ListPreAuthKeysResponse)(nil), // 8: headscale.v1.ListPreAuthKeysResponse - (*User)(nil), // 9: headscale.v1.User - (*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp -} -var file_headscale_v1_preauthkey_proto_depIdxs = []int32{ - 9, // 0: headscale.v1.PreAuthKey.user:type_name -> headscale.v1.User - 10, // 1: headscale.v1.PreAuthKey.expiration:type_name -> google.protobuf.Timestamp - 10, // 2: headscale.v1.PreAuthKey.created_at:type_name -> google.protobuf.Timestamp - 10, // 3: headscale.v1.CreatePreAuthKeyRequest.expiration:type_name -> google.protobuf.Timestamp - 0, // 4: headscale.v1.CreatePreAuthKeyResponse.pre_auth_key:type_name -> headscale.v1.PreAuthKey - 0, // 5: headscale.v1.ListPreAuthKeysResponse.pre_auth_keys:type_name -> headscale.v1.PreAuthKey - 6, // [6:6] is the sub-list for method output_type - 6, // [6:6] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name -} - -func init() { file_headscale_v1_preauthkey_proto_init() } -func file_headscale_v1_preauthkey_proto_init() { - if File_headscale_v1_preauthkey_proto != nil { - return - } - file_headscale_v1_user_proto_init() - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_preauthkey_proto_rawDesc), len(file_headscale_v1_preauthkey_proto_rawDesc)), - NumEnums: 0, - NumMessages: 9, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_headscale_v1_preauthkey_proto_goTypes, - DependencyIndexes: file_headscale_v1_preauthkey_proto_depIdxs, - MessageInfos: file_headscale_v1_preauthkey_proto_msgTypes, - }.Build() - File_headscale_v1_preauthkey_proto = out.File - file_headscale_v1_preauthkey_proto_goTypes = nil - file_headscale_v1_preauthkey_proto_depIdxs = nil -} diff --git a/gen/go/headscale/v1/user.pb.go b/gen/go/headscale/v1/user.pb.go deleted file mode 100644 index 5f05d0849..000000000 --- a/gen/go/headscale/v1/user.pb.go +++ /dev/null @@ -1,615 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/user.proto - -package v1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type User struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - DisplayName string `protobuf:"bytes,4,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` - Email string `protobuf:"bytes,5,opt,name=email,proto3" json:"email,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"` - ProfilePicUrl string `protobuf:"bytes,8,opt,name=profile_pic_url,json=profilePicUrl,proto3" json:"profile_pic_url,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *User) Reset() { - *x = User{} - mi := &file_headscale_v1_user_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *User) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*User) ProtoMessage() {} - -func (x *User) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use User.ProtoReflect.Descriptor instead. -func (*User) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{0} -} - -func (x *User) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -func (x *User) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *User) GetCreatedAt() *timestamppb.Timestamp { - if x != nil { - return x.CreatedAt - } - return nil -} - -func (x *User) GetDisplayName() string { - if x != nil { - return x.DisplayName - } - return "" -} - -func (x *User) GetEmail() string { - if x != nil { - return x.Email - } - return "" -} - -func (x *User) GetProviderId() string { - if x != nil { - return x.ProviderId - } - return "" -} - -func (x *User) GetProvider() string { - if x != nil { - return x.Provider - } - return "" -} - -func (x *User) GetProfilePicUrl() string { - if x != nil { - return x.ProfilePicUrl - } - return "" -} - -type CreateUserRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` - Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` - PictureUrl string `protobuf:"bytes,4,opt,name=picture_url,json=pictureUrl,proto3" json:"picture_url,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateUserRequest) Reset() { - *x = CreateUserRequest{} - mi := &file_headscale_v1_user_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateUserRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateUserRequest) ProtoMessage() {} - -func (x *CreateUserRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreateUserRequest.ProtoReflect.Descriptor instead. -func (*CreateUserRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{1} -} - -func (x *CreateUserRequest) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *CreateUserRequest) GetDisplayName() string { - if x != nil { - return x.DisplayName - } - return "" -} - -func (x *CreateUserRequest) GetEmail() string { - if x != nil { - return x.Email - } - return "" -} - -func (x *CreateUserRequest) GetPictureUrl() string { - if x != nil { - return x.PictureUrl - } - return "" -} - -type CreateUserResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateUserResponse) Reset() { - *x = CreateUserResponse{} - mi := &file_headscale_v1_user_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateUserResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateUserResponse) ProtoMessage() {} - -func (x *CreateUserResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreateUserResponse.ProtoReflect.Descriptor instead. -func (*CreateUserResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{2} -} - -func (x *CreateUserResponse) GetUser() *User { - if x != nil { - return x.User - } - return nil -} - -type RenameUserRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - OldId uint64 `protobuf:"varint,1,opt,name=old_id,json=oldId,proto3" json:"old_id,omitempty"` - NewName string `protobuf:"bytes,2,opt,name=new_name,json=newName,proto3" json:"new_name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenameUserRequest) Reset() { - *x = RenameUserRequest{} - mi := &file_headscale_v1_user_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenameUserRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenameUserRequest) ProtoMessage() {} - -func (x *RenameUserRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RenameUserRequest.ProtoReflect.Descriptor instead. -func (*RenameUserRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{3} -} - -func (x *RenameUserRequest) GetOldId() uint64 { - if x != nil { - return x.OldId - } - return 0 -} - -func (x *RenameUserRequest) GetNewName() string { - if x != nil { - return x.NewName - } - return "" -} - -type RenameUserResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenameUserResponse) Reset() { - *x = RenameUserResponse{} - mi := &file_headscale_v1_user_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenameUserResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenameUserResponse) ProtoMessage() {} - -func (x *RenameUserResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RenameUserResponse.ProtoReflect.Descriptor instead. -func (*RenameUserResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{4} -} - -func (x *RenameUserResponse) GetUser() *User { - if x != nil { - return x.User - } - return nil -} - -type DeleteUserRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteUserRequest) Reset() { - *x = DeleteUserRequest{} - mi := &file_headscale_v1_user_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteUserRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteUserRequest) ProtoMessage() {} - -func (x *DeleteUserRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteUserRequest.ProtoReflect.Descriptor instead. -func (*DeleteUserRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{5} -} - -func (x *DeleteUserRequest) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -type DeleteUserResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteUserResponse) Reset() { - *x = DeleteUserResponse{} - mi := &file_headscale_v1_user_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteUserResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteUserResponse) ProtoMessage() {} - -func (x *DeleteUserResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteUserResponse.ProtoReflect.Descriptor instead. -func (*DeleteUserResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{6} -} - -type ListUsersRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListUsersRequest) Reset() { - *x = ListUsersRequest{} - mi := &file_headscale_v1_user_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListUsersRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListUsersRequest) ProtoMessage() {} - -func (x *ListUsersRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListUsersRequest.ProtoReflect.Descriptor instead. -func (*ListUsersRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{7} -} - -func (x *ListUsersRequest) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -func (x *ListUsersRequest) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *ListUsersRequest) GetEmail() string { - if x != nil { - return x.Email - } - return "" -} - -type ListUsersResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Users []*User `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListUsersResponse) Reset() { - *x = ListUsersResponse{} - mi := &file_headscale_v1_user_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListUsersResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListUsersResponse) ProtoMessage() {} - -func (x *ListUsersResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListUsersResponse.ProtoReflect.Descriptor instead. -func (*ListUsersResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{8} -} - -func (x *ListUsersResponse) GetUsers() []*User { - if x != nil { - return x.Users - } - return nil -} - -var File_headscale_v1_user_proto protoreflect.FileDescriptor - -const file_headscale_v1_user_proto_rawDesc = "" + - "\n" + - "\x17headscale/v1/user.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x83\x02\n" + - "\x04User\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x04R\x02id\x12\x12\n" + - "\x04name\x18\x02 \x01(\tR\x04name\x129\n" + - "\n" + - "created_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12!\n" + - "\fdisplay_name\x18\x04 \x01(\tR\vdisplayName\x12\x14\n" + - "\x05email\x18\x05 \x01(\tR\x05email\x12\x1f\n" + - "\vprovider_id\x18\x06 \x01(\tR\n" + - "providerId\x12\x1a\n" + - "\bprovider\x18\a \x01(\tR\bprovider\x12&\n" + - "\x0fprofile_pic_url\x18\b \x01(\tR\rprofilePicUrl\"\x81\x01\n" + - "\x11CreateUserRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12!\n" + - "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x14\n" + - "\x05email\x18\x03 \x01(\tR\x05email\x12\x1f\n" + - "\vpicture_url\x18\x04 \x01(\tR\n" + - "pictureUrl\"<\n" + - "\x12CreateUserResponse\x12&\n" + - "\x04user\x18\x01 \x01(\v2\x12.headscale.v1.UserR\x04user\"E\n" + - "\x11RenameUserRequest\x12\x15\n" + - "\x06old_id\x18\x01 \x01(\x04R\x05oldId\x12\x19\n" + - "\bnew_name\x18\x02 \x01(\tR\anewName\"<\n" + - "\x12RenameUserResponse\x12&\n" + - "\x04user\x18\x01 \x01(\v2\x12.headscale.v1.UserR\x04user\"#\n" + - "\x11DeleteUserRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x04R\x02id\"\x14\n" + - "\x12DeleteUserResponse\"L\n" + - "\x10ListUsersRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x04R\x02id\x12\x12\n" + - "\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" + - "\x05email\x18\x03 \x01(\tR\x05email\"=\n" + - "\x11ListUsersResponse\x12(\n" + - "\x05users\x18\x01 \x03(\v2\x12.headscale.v1.UserR\x05usersB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_user_proto_rawDescOnce sync.Once - file_headscale_v1_user_proto_rawDescData []byte -) - -func file_headscale_v1_user_proto_rawDescGZIP() []byte { - file_headscale_v1_user_proto_rawDescOnce.Do(func() { - file_headscale_v1_user_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_user_proto_rawDesc), len(file_headscale_v1_user_proto_rawDesc))) - }) - return file_headscale_v1_user_proto_rawDescData -} - -var file_headscale_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 9) -var file_headscale_v1_user_proto_goTypes = []any{ - (*User)(nil), // 0: headscale.v1.User - (*CreateUserRequest)(nil), // 1: headscale.v1.CreateUserRequest - (*CreateUserResponse)(nil), // 2: headscale.v1.CreateUserResponse - (*RenameUserRequest)(nil), // 3: headscale.v1.RenameUserRequest - (*RenameUserResponse)(nil), // 4: headscale.v1.RenameUserResponse - (*DeleteUserRequest)(nil), // 5: headscale.v1.DeleteUserRequest - (*DeleteUserResponse)(nil), // 6: headscale.v1.DeleteUserResponse - (*ListUsersRequest)(nil), // 7: headscale.v1.ListUsersRequest - (*ListUsersResponse)(nil), // 8: headscale.v1.ListUsersResponse - (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp -} -var file_headscale_v1_user_proto_depIdxs = []int32{ - 9, // 0: headscale.v1.User.created_at:type_name -> google.protobuf.Timestamp - 0, // 1: headscale.v1.CreateUserResponse.user:type_name -> headscale.v1.User - 0, // 2: headscale.v1.RenameUserResponse.user:type_name -> headscale.v1.User - 0, // 3: headscale.v1.ListUsersResponse.users:type_name -> headscale.v1.User - 4, // [4:4] is the sub-list for method output_type - 4, // [4:4] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name -} - -func init() { file_headscale_v1_user_proto_init() } -func file_headscale_v1_user_proto_init() { - if File_headscale_v1_user_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_user_proto_rawDesc), len(file_headscale_v1_user_proto_rawDesc)), - NumEnums: 0, - NumMessages: 9, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_headscale_v1_user_proto_goTypes, - DependencyIndexes: file_headscale_v1_user_proto_depIdxs, - MessageInfos: file_headscale_v1_user_proto_msgTypes, - }.Build() - File_headscale_v1_user_proto = out.File - file_headscale_v1_user_proto_goTypes = nil - file_headscale_v1_user_proto_depIdxs = nil -} diff --git a/gen/openapiv2/headscale/v1/apikey.swagger.json b/gen/openapiv2/headscale/v1/apikey.swagger.json deleted file mode 100644 index 8c8596a95..000000000 --- a/gen/openapiv2/headscale/v1/apikey.swagger.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/apikey.proto", - "version": "version not set" - }, - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": {}, - "definitions": { - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - } - } -} diff --git a/gen/openapiv2/headscale/v1/auth.swagger.json b/gen/openapiv2/headscale/v1/auth.swagger.json deleted file mode 100644 index 2e99e1a75..000000000 --- a/gen/openapiv2/headscale/v1/auth.swagger.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/auth.proto", - "version": "version not set" - }, - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": {}, - "definitions": { - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - } - } -} diff --git a/gen/openapiv2/headscale/v1/device.swagger.json b/gen/openapiv2/headscale/v1/device.swagger.json deleted file mode 100644 index 99d20debf..000000000 --- a/gen/openapiv2/headscale/v1/device.swagger.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/device.proto", - "version": "version not set" - }, - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": {}, - "definitions": { - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - } - } -} diff --git a/gen/openapiv2/headscale/v1/headscale.swagger.json b/gen/openapiv2/headscale/v1/headscale.swagger.json deleted file mode 100644 index 545cf0b5b..000000000 --- a/gen/openapiv2/headscale/v1/headscale.swagger.json +++ /dev/null @@ -1,1535 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/headscale.proto", - "version": "version not set" - }, - "tags": [ - { - "name": "HeadscaleService" - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": { - "/api/v1/apikey": { - "get": { - "operationId": "HeadscaleService_ListApiKeys", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ListApiKeysResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "tags": [ - "HeadscaleService" - ] - }, - "post": { - "summary": "--- ApiKeys start ---", - "operationId": "HeadscaleService_CreateApiKey", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1CreateApiKeyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1CreateApiKeyRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/apikey/expire": { - "post": { - "operationId": "HeadscaleService_ExpireApiKey", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ExpireApiKeyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1ExpireApiKeyRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/apikey/{prefix}": { - "delete": { - "operationId": "HeadscaleService_DeleteApiKey", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1DeleteApiKeyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "prefix", - "in": "path", - "required": true, - "type": "string" - }, - { - "name": "id", - "in": "query", - "required": false, - "type": "string", - "format": "uint64" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/auth/approve": { - "post": { - "operationId": "HeadscaleService_AuthApprove", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1AuthApproveResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1AuthApproveRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/auth/register": { - "post": { - "summary": "--- Auth start ---", - "operationId": "HeadscaleService_AuthRegister", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1AuthRegisterResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1AuthRegisterRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/auth/reject": { - "post": { - "operationId": "HeadscaleService_AuthReject", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1AuthRejectResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1AuthRejectRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/debug/node": { - "post": { - "summary": "--- Node start ---", - "operationId": "HeadscaleService_DebugCreateNode", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1DebugCreateNodeResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1DebugCreateNodeRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/health": { - "get": { - "summary": "--- Health start ---", - "operationId": "HeadscaleService_Health", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1HealthResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node": { - "get": { - "operationId": "HeadscaleService_ListNodes", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ListNodesResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "user", - "in": "query", - "required": false, - "type": "string" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node/backfillips": { - "post": { - "operationId": "HeadscaleService_BackfillNodeIPs", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1BackfillNodeIPsResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "confirmed", - "in": "query", - "required": false, - "type": "boolean" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node/register": { - "post": { - "operationId": "HeadscaleService_RegisterNode", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1RegisterNodeResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "user", - "in": "query", - "required": false, - "type": "string" - }, - { - "name": "key", - "in": "query", - "required": false, - "type": "string" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node/{nodeId}": { - "get": { - "operationId": "HeadscaleService_GetNode", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1GetNodeResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "nodeId", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - } - ], - "tags": [ - "HeadscaleService" - ] - }, - "delete": { - "operationId": "HeadscaleService_DeleteNode", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1DeleteNodeResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "nodeId", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node/{nodeId}/approve_routes": { - "post": { - "operationId": "HeadscaleService_SetApprovedRoutes", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1SetApprovedRoutesResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "nodeId", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - }, - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/HeadscaleServiceSetApprovedRoutesBody" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node/{nodeId}/expire": { - "post": { - "operationId": "HeadscaleService_ExpireNode", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ExpireNodeResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "nodeId", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - }, - { - "name": "expiry", - "in": "query", - "required": false, - "type": "string", - "format": "date-time" - }, - { - "name": "disableExpiry", - "description": "When true, sets expiry to null (node will never expire).", - "in": "query", - "required": false, - "type": "boolean" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node/{nodeId}/rename/{newName}": { - "post": { - "operationId": "HeadscaleService_RenameNode", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1RenameNodeResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "nodeId", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - }, - { - "name": "newName", - "in": "path", - "required": true, - "type": "string" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node/{nodeId}/tags": { - "post": { - "operationId": "HeadscaleService_SetTags", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1SetTagsResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "nodeId", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - }, - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/HeadscaleServiceSetTagsBody" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/policy": { - "get": { - "summary": "--- Policy start ---", - "operationId": "HeadscaleService_GetPolicy", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1GetPolicyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "tags": [ - "HeadscaleService" - ] - }, - "put": { - "operationId": "HeadscaleService_SetPolicy", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1SetPolicyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1SetPolicyRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/policy/check": { - "post": { - "operationId": "HeadscaleService_CheckPolicy", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1CheckPolicyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1CheckPolicyRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/preauthkey": { - "get": { - "operationId": "HeadscaleService_ListPreAuthKeys", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ListPreAuthKeysResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "tags": [ - "HeadscaleService" - ] - }, - "delete": { - "operationId": "HeadscaleService_DeletePreAuthKey", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1DeletePreAuthKeyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "id", - "in": "query", - "required": false, - "type": "string", - "format": "uint64" - } - ], - "tags": [ - "HeadscaleService" - ] - }, - "post": { - "summary": "--- PreAuthKeys start ---", - "operationId": "HeadscaleService_CreatePreAuthKey", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1CreatePreAuthKeyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1CreatePreAuthKeyRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/preauthkey/expire": { - "post": { - "operationId": "HeadscaleService_ExpirePreAuthKey", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ExpirePreAuthKeyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1ExpirePreAuthKeyRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/user": { - "get": { - "operationId": "HeadscaleService_ListUsers", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ListUsersResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "id", - "in": "query", - "required": false, - "type": "string", - "format": "uint64" - }, - { - "name": "name", - "in": "query", - "required": false, - "type": "string" - }, - { - "name": "email", - "in": "query", - "required": false, - "type": "string" - } - ], - "tags": [ - "HeadscaleService" - ] - }, - "post": { - "summary": "--- User start ---", - "operationId": "HeadscaleService_CreateUser", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1CreateUserResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1CreateUserRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/user/{id}": { - "delete": { - "operationId": "HeadscaleService_DeleteUser", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1DeleteUserResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/user/{oldId}/rename/{newName}": { - "post": { - "operationId": "HeadscaleService_RenameUser", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1RenameUserResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "oldId", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - }, - { - "name": "newName", - "in": "path", - "required": true, - "type": "string" - } - ], - "tags": [ - "HeadscaleService" - ] - } - } - }, - "definitions": { - "HeadscaleServiceSetApprovedRoutesBody": { - "type": "object", - "properties": { - "routes": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "HeadscaleServiceSetTagsBody": { - "type": "object", - "properties": { - "tags": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - }, - "v1ApiKey": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uint64" - }, - "prefix": { - "type": "string" - }, - "expiration": { - "type": "string", - "format": "date-time" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "lastSeen": { - "type": "string", - "format": "date-time" - } - } - }, - "v1AuthApproveRequest": { - "type": "object", - "properties": { - "authId": { - "type": "string" - } - } - }, - "v1AuthApproveResponse": { - "type": "object" - }, - "v1AuthRegisterRequest": { - "type": "object", - "properties": { - "user": { - "type": "string" - }, - "authId": { - "type": "string" - } - } - }, - "v1AuthRegisterResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1AuthRejectRequest": { - "type": "object", - "properties": { - "authId": { - "type": "string" - } - } - }, - "v1AuthRejectResponse": { - "type": "object" - }, - "v1BackfillNodeIPsResponse": { - "type": "object", - "properties": { - "changes": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "v1CheckPolicyRequest": { - "type": "object", - "properties": { - "policy": { - "type": "string" - } - } - }, - "v1CheckPolicyResponse": { - "type": "object" - }, - "v1CreateApiKeyRequest": { - "type": "object", - "properties": { - "expiration": { - "type": "string", - "format": "date-time" - } - } - }, - "v1CreateApiKeyResponse": { - "type": "object", - "properties": { - "apiKey": { - "type": "string" - } - } - }, - "v1CreatePreAuthKeyRequest": { - "type": "object", - "properties": { - "user": { - "type": "string", - "format": "uint64" - }, - "reusable": { - "type": "boolean" - }, - "ephemeral": { - "type": "boolean" - }, - "expiration": { - "type": "string", - "format": "date-time" - }, - "aclTags": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "v1CreatePreAuthKeyResponse": { - "type": "object", - "properties": { - "preAuthKey": { - "$ref": "#/definitions/v1PreAuthKey" - } - } - }, - "v1CreateUserRequest": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "displayName": { - "type": "string" - }, - "email": { - "type": "string" - }, - "pictureUrl": { - "type": "string" - } - } - }, - "v1CreateUserResponse": { - "type": "object", - "properties": { - "user": { - "$ref": "#/definitions/v1User" - } - } - }, - "v1DebugCreateNodeRequest": { - "type": "object", - "properties": { - "user": { - "type": "string" - }, - "key": { - "type": "string" - }, - "name": { - "type": "string" - }, - "routes": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "v1DebugCreateNodeResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1DeleteApiKeyResponse": { - "type": "object" - }, - "v1DeleteNodeResponse": { - "type": "object" - }, - "v1DeletePreAuthKeyResponse": { - "type": "object" - }, - "v1DeleteUserResponse": { - "type": "object" - }, - "v1ExpireApiKeyRequest": { - "type": "object", - "properties": { - "prefix": { - "type": "string" - }, - "id": { - "type": "string", - "format": "uint64" - } - } - }, - "v1ExpireApiKeyResponse": { - "type": "object" - }, - "v1ExpireNodeResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1ExpirePreAuthKeyRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uint64" - } - } - }, - "v1ExpirePreAuthKeyResponse": { - "type": "object" - }, - "v1GetNodeResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1GetPolicyResponse": { - "type": "object", - "properties": { - "policy": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "v1HealthResponse": { - "type": "object", - "properties": { - "databaseConnectivity": { - "type": "boolean" - } - } - }, - "v1ListApiKeysResponse": { - "type": "object", - "properties": { - "apiKeys": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1ApiKey" - } - } - } - }, - "v1ListNodesResponse": { - "type": "object", - "properties": { - "nodes": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1Node" - } - } - } - }, - "v1ListPreAuthKeysResponse": { - "type": "object", - "properties": { - "preAuthKeys": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PreAuthKey" - } - } - } - }, - "v1ListUsersResponse": { - "type": "object", - "properties": { - "users": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1User" - } - } - } - }, - "v1Node": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uint64" - }, - "machineKey": { - "type": "string" - }, - "nodeKey": { - "type": "string" - }, - "discoKey": { - "type": "string" - }, - "ipAddresses": { - "type": "array", - "items": { - "type": "string" - } - }, - "name": { - "type": "string" - }, - "user": { - "$ref": "#/definitions/v1User" - }, - "lastSeen": { - "type": "string", - "format": "date-time" - }, - "expiry": { - "type": "string", - "format": "date-time" - }, - "preAuthKey": { - "$ref": "#/definitions/v1PreAuthKey" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "registerMethod": { - "$ref": "#/definitions/v1RegisterMethod" - }, - "givenName": { - "type": "string", - "title": "Deprecated\nrepeated string forced_tags = 18;\nrepeated string invalid_tags = 19;\nrepeated string valid_tags = 20;" - }, - "online": { - "type": "boolean" - }, - "approvedRoutes": { - "type": "array", - "items": { - "type": "string" - } - }, - "availableRoutes": { - "type": "array", - "items": { - "type": "string" - } - }, - "subnetRoutes": { - "type": "array", - "items": { - "type": "string" - } - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "v1PreAuthKey": { - "type": "object", - "properties": { - "user": { - "$ref": "#/definitions/v1User" - }, - "id": { - "type": "string", - "format": "uint64" - }, - "key": { - "type": "string" - }, - "reusable": { - "type": "boolean" - }, - "ephemeral": { - "type": "boolean" - }, - "used": { - "type": "boolean" - }, - "expiration": { - "type": "string", - "format": "date-time" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "aclTags": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "v1RegisterMethod": { - "type": "string", - "enum": [ - "REGISTER_METHOD_UNSPECIFIED", - "REGISTER_METHOD_AUTH_KEY", - "REGISTER_METHOD_CLI", - "REGISTER_METHOD_OIDC" - ], - "default": "REGISTER_METHOD_UNSPECIFIED" - }, - "v1RegisterNodeResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1RenameNodeResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1RenameUserResponse": { - "type": "object", - "properties": { - "user": { - "$ref": "#/definitions/v1User" - } - } - }, - "v1SetApprovedRoutesResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1SetPolicyRequest": { - "type": "object", - "properties": { - "policy": { - "type": "string" - } - } - }, - "v1SetPolicyResponse": { - "type": "object", - "properties": { - "policy": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "v1SetTagsResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1User": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uint64" - }, - "name": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "displayName": { - "type": "string" - }, - "email": { - "type": "string" - }, - "providerId": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "profilePicUrl": { - "type": "string" - } - } - } - } -} diff --git a/gen/openapiv2/headscale/v1/node.swagger.json b/gen/openapiv2/headscale/v1/node.swagger.json deleted file mode 100644 index 163213470..000000000 --- a/gen/openapiv2/headscale/v1/node.swagger.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/node.proto", - "version": "version not set" - }, - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": {}, - "definitions": { - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - } - } -} diff --git a/gen/openapiv2/headscale/v1/policy.swagger.json b/gen/openapiv2/headscale/v1/policy.swagger.json deleted file mode 100644 index 63057ed0e..000000000 --- a/gen/openapiv2/headscale/v1/policy.swagger.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/policy.proto", - "version": "version not set" - }, - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": {}, - "definitions": { - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - } - } -} diff --git a/gen/openapiv2/headscale/v1/preauthkey.swagger.json b/gen/openapiv2/headscale/v1/preauthkey.swagger.json deleted file mode 100644 index 17a2be1a7..000000000 --- a/gen/openapiv2/headscale/v1/preauthkey.swagger.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/preauthkey.proto", - "version": "version not set" - }, - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": {}, - "definitions": { - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - } - } -} diff --git a/gen/openapiv2/headscale/v1/user.swagger.json b/gen/openapiv2/headscale/v1/user.swagger.json deleted file mode 100644 index 008ca3e85..000000000 --- a/gen/openapiv2/headscale/v1/user.swagger.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/user.proto", - "version": "version not set" - }, - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": {}, - "definitions": { - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - } - } -} diff --git a/go.mod b/go.mod index bf7794dff..41cf5b057 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/coreos/go-oidc/v3 v3.18.0 github.com/creachadair/command v0.2.6 github.com/creachadair/flax v0.0.6 + github.com/danielgtaylor/huma/v2 v2.38.0 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/docker/docker v28.5.2+incompatible github.com/fsnotify/fsnotify v1.10.1 @@ -20,12 +21,11 @@ require ( github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 github.com/gofrs/uuid/v5 v5.4.0 github.com/google/go-cmp v0.7.0 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/jagottsicher/termcolor v1.0.2 + github.com/oapi-codegen/runtime v1.4.1 github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 github.com/ory/dockertest/v3 v3.12.0 - github.com/philip-bui/grpc-zerolog v1.0.1 github.com/pkg/profile v1.7.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/common v0.68.1 @@ -48,14 +48,12 @@ require ( golang.org/x/net v0.56.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.21.0 - google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad - google.golang.org/grpc v1.81.1 - google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 pgregory.net/rapid v1.3.0 tailscale.com v1.101.0-pre + tailscale.com/client/tailscale/v2 v2.9.0 zombiezen.com/go/postgrestest v1.0.1 ) @@ -104,6 +102,7 @@ require ( github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.7 // indirect @@ -141,7 +140,7 @@ require ( github.com/felixge/fgprof v0.9.5 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fogleman/gg v1.3.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/gaissmai/bart v0.26.1 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/go-jose/go-jose/v3 v3.0.5 // indirect @@ -154,7 +153,6 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-github v17.0.0+incompatible // indirect github.com/google/go-querystring v1.2.0 // indirect @@ -163,6 +161,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/gookit/color v1.6.1 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/huin/goupnp v1.3.0 // indirect @@ -246,7 +245,10 @@ require ( golang.org/x/tools v0.45.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect k8s.io/client-go v0.34.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect diff --git a/go.sum b/go.sum index 2de56cbee..048eba2ca 100644 --- a/go.sum +++ b/go.sum @@ -28,12 +28,15 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/arl/statsviz v0.8.0 h1:O6GjjVxEDxcByAucOSl29HaGYLXsuwA3ujJw8H9E7/U= github.com/arl/statsviz v0.8.0/go.mod h1:XlrbiT7xYT03xaW9JMMfD8KFUhBOESJwfyNJu83PbB0= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -82,6 +85,7 @@ github.com/axiomhq/hyperloglog v0.2.6 h1:sRhvvF3RIXWQgAXaTphLp4yJiX4S0IN3MWTaAgZ github.com/axiomhq/hyperloglog v0.2.6/go.mod h1:YjX/dQqCR/7QYX0g8mu8UZAjpIenz1FKM71UEsjFoTo= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -132,6 +136,8 @@ github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoi github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danielgtaylor/huma/v2 v2.38.0 h1:fb0WZCatnaiHLphMQDDWDjygNxfMkX/ENma3QsRl7vY= +github.com/danielgtaylor/huma/v2 v2.38.0/go.mod h1:k9hwjlgWFt1t2jsmQGlsgXAG2FBTZa4kkjV581qAtfo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -167,8 +173,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= @@ -217,8 +223,6 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF0 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -288,6 +292,7 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90= github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kamstrup/intmap v0.5.2 h1:qnwBm1mh4XAnW9W9Ue9tZtTff8pS6+s6iKF6JRIV2Dk= github.com/kamstrup/intmap v0.5.2/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -353,6 +358,10 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo= +github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 h1:9bCMuD3TcnjeqjPT2gSlha4asp8NvgcFRYExCaikCxk= github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25/go.mod h1:eDjgYHYDJbPLBLsyZ6qRaugP0mX8vePOhZ5id1fdzJw= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -371,8 +380,6 @@ github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyX github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -github.com/philip-bui/grpc-zerolog v1.0.1 h1:EMacvLRUd2O1K0eWod27ZP5CY1iTNkhBDLSN+Q4JEvA= -github.com/philip-bui/grpc-zerolog v1.0.1/go.mod h1:qXbiq/2X4ZUMMshsqlWyTHOcw7ns+GZmlqZZN05ZHcQ= github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pires/go-proxyproto v0.9.2 h1:H1UdHn695zUVVmB0lQ354lOWHOy6TZSpzBl3tgN0s1U= @@ -432,10 +439,9 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -601,8 +607,6 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= -gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad h1:3iLyITS/sySRwbUKoC7ogfj2Yr1Cjs0pfaRKj5U5HEw= google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= @@ -670,5 +674,7 @@ software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= tailscale.com v1.101.0-pre h1:q1eBWxryj7Lz5fMvi7npSbN/fJ3q6/crvbbfMkx89F8= tailscale.com v1.101.0-pre/go.mod h1:DQ9YBy85DpNlSyeU2XRIWzbAu3RsGp/frv+Khg57meE= +tailscale.com/client/tailscale/v2 v2.9.0 h1:zBZIIeIYXL42qvvile7d29O2DKSr3AfNc2gzd1JCf2o= +tailscale.com/client/tailscale/v2 v2.9.0/go.mod h1:FGjvGT3ThHelqo0gfdK3IN3k1dwNbRzYbQh2XO3C47U= zombiezen.com/go/postgrestest v1.0.1 h1:aXoADQAJmZDU3+xilYVut0pHhgc0sF8ZspPW9gFNwP4= zombiezen.com/go/postgrestest v1.0.1/go.mod h1:marlZezr+k2oSJrvXHnZUs1olHqpE9czlz8ZYkVxliQ= diff --git a/hscontrol/api/v1/api.go b/hscontrol/api/v1/api.go new file mode 100644 index 000000000..1ca866947 --- /dev/null +++ b/hscontrol/api/v1/api.go @@ -0,0 +1,160 @@ +// Package apiv1 is the code-first Huma implementation of the Headscale v1 API. +// Handlers are a thin adapter over hscontrol/state; Huma emits the OpenAPI 3.1 +// spec from the Go definitions (see Spec), and that spec drives the client. +// +// It depends only on the domain layer (hscontrol/state, hscontrol/types) via +// Backend, never on the hscontrol server package, so a future hscontrol/api/v2 +// can sit beside it without either importing the other. +package apiv1 + +import ( + "context" + "net/http" + "strings" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humachi" + "github.com/go-chi/chi/v5" + "github.com/juanfont/headscale/hscontrol/state" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/types/change" +) + +// Backend is the dependency surface the v1 API needs from the control plane: +// the state layer, the change-notification sink that distributes updates to +// connected nodes, and the config (only Policy.Mode and Policy.Path are read). +type Backend struct { + State *state.State + Change func(...change.Change) + Cfg *types.Config +} + +// NewAPI builds the v1 Huma API on the given chi router and registers every +// operation. Auth is enforced by a Huma middleware driven by each operation's +// declared bearer security (see authMiddleware); locally-trusted requests +// bypass it via WithLocalTrust. +func NewAPI(router chi.Router, backend Backend) huma.API { + config := huma.DefaultConfig("Headscale API", "v1") + config.Info.Description = "Headscale control server API." + + // Version the OpenAPI/docs routes under /api/v1 so a future v2 owns its own. + // These register as plain mux routes, not operations, so they never appear + // in the emitted spec or client. + config.OpenAPIPath = "/api/v1/openapi" + config.DocsPath = "/api/v1/docs" + + // The v1 API does not emit "$schema". + config.SchemasPath = "" + + // Drop the default schema-link create hook: it injects a "$schema" property + // and Link header into every response, which the v1 contract omits. + config.CreateHooks = nil + + config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ + "bearer": { + Type: "http", + Scheme: "bearer", + }, + } + + api := humachi.New(router, config) + + // Must run before register: Huma snapshots the middleware chain at operation + // registration, so a middleware added afterwards would silently never run. + api.UseMiddleware(authMiddleware(api, backend)) + + register(api, backend) + + return api +} + +// bearerAuth is the security requirement applied to every operation: all +// /api/v1 routes require an API key. +var bearerAuth = []map[string][]string{{"bearer": {}}} + +// registrations is populated by each resource file's init(), so adding a +// resource group means adding a file rather than editing a shared point. Huma +// sorts the emitted spec, so init order does not affect output. +var registrations []func(huma.API, Backend) + +// register wires up every operation contributed by the resource files. +func register(api huma.API, b Backend) { + for _, fn := range registrations { + fn(api, b) + } +} + +// Spec emits the OpenAPI 3.1 document. The zero Backend is safe because +// handlers are registered but never invoked during emission. +func Spec() ([]byte, error) { + api := NewAPI(chi.NewMux(), Backend{}) + return api.OpenAPI().YAML() +} + +// Spec30 emits the document downgraded to OpenAPI 3.0.3, needed because the +// client generator (oapi-codegen v2) cannot yet read the 3.1 spec. +func Spec30() ([]byte, error) { + api := NewAPI(chi.NewMux(), Backend{}) + return api.OpenAPI().DowngradeYAML() +} + +// Handler builds the v1 API on a fresh mux and returns both. Callers mount the +// mux and may use mux.Match to detect which paths this API serves. +func Handler(backend Backend) (*chi.Mux, huma.API) { + mux := chi.NewMux() + api := NewAPI(mux, backend) + + return mux, api +} + +// localTrustKey marks a request as arriving over a locally-trusted transport; +// the auth middleware skips authentication for such requests. +type localTrustKey struct{} + +// WithLocalTrust wraps a handler so its requests bypass API-key authentication. +// The unix socket uses this — access to the socket is the trust boundary — as +// do in-process tests that exercise the mux directly. +func WithLocalTrust(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + next.ServeHTTP(w, req.WithContext( + context.WithValue(req.Context(), localTrustKey{}, struct{}{}), + )) + }) +} + +// authMiddleware is a pure gate enforcing the bearer API key for any operation +// that declares security; the v1 handlers do not read caller identity. +// Locally-trusted requests and operations without declared security pass +// through. b.State is nil only during spec emission, where no request is +// served, so it is never dereferenced there. +func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Context)) { + return func(ctx huma.Context, next func(huma.Context)) { + if ctx.Context().Value(localTrustKey{}) != nil { + next(ctx) + + return + } + + if len(ctx.Operation().Security) == 0 { + next(ctx) + + return + } + + token, ok := strings.CutPrefix(ctx.Header("Authorization"), "Bearer ") + if !ok { + _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized") + + return + } + + valid, err := b.State.ValidateAPIKey(token) + if err != nil || !valid { + _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized") + + return + } + + next(ctx) + } +} diff --git a/hscontrol/api/v1/apikeys.go b/hscontrol/api/v1/apikeys.go new file mode 100644 index 000000000..2411d59d9 --- /dev/null +++ b/hscontrol/api/v1/apikeys.go @@ -0,0 +1,245 @@ +package apiv1 + +import ( + "cmp" + "context" + "net/http" + "slices" + "strconv" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" +) + +func init() { + registrations = append(registrations, registerApiKeys) +} + +// ApiKey is the v1 ApiKey message. Timestamps are pointers so a nil source is +// emitted as JSON null, matching protojson's unset Timestamp (e.g. lastSeen on +// a fresh key). +type ApiKey struct { + ID string `format:"uint64" json:"id"` + Prefix string `json:"prefix"` + Expiration *time.Time `json:"expiration" nullable:"true"` + CreatedAt *time.Time `json:"createdAt" nullable:"true"` + LastSeen *time.Time `json:"lastSeen" nullable:"true"` +} + +// CreateApiKeyRequestBody is the v1.CreateApiKeyRequest body. +type CreateApiKeyRequestBody struct { + Expiration *time.Time `json:"expiration,omitempty"` +} + +// ExpireApiKeyRequestBody is the v1.ExpireApiKeyRequest body. +type ExpireApiKeyRequestBody struct { + Prefix string `json:"prefix,omitempty"` + ID string `format:"uint64" json:"id,omitempty"` +} + +type ( + createApiKeyInput struct { + Body CreateApiKeyRequestBody + } + createApiKeyOutput struct { + Body struct { + APIKey string `json:"apiKey"` + } + } +) + +type ( + expireApiKeyInput struct { + Body ExpireApiKeyRequestBody + } + expireApiKeyOutput struct { + Body struct{} + } +) + +type ( + listApiKeysOutput struct { + Body struct { + APIKeys []ApiKey `json:"apiKeys" nullable:"false"` + } + } +) + +type ( + deleteApiKeyInput struct { + Prefix string `path:"prefix"` + ID string `format:"uint64" query:"id"` + } + deleteApiKeyOutput struct { + Body struct{} + } +) + +func registerApiKeys(api huma.API, b Backend) { + huma.Register(api, huma.Operation{ + OperationID: "createApiKey", + Method: http.MethodPost, + Path: "/api/v1/apikey", + Summary: "Create API key", + Tags: []string{"ApiKeys"}, + Security: bearerAuth, + }, func(ctx context.Context, in *createApiKeyInput) (*createApiKeyOutput, error) { + // CreateAPIKey requires a non-nil pointer; default a missing expiration + // to the zero time as the gRPC handler does. + var expiration time.Time + if in.Body.Expiration != nil { + expiration = *in.Body.Expiration + } + + keyStr, _, err := b.State.CreateAPIKey(&expiration) + if err != nil { + return nil, huma.Error500InternalServerError("creating api key", err) + } + + out := &createApiKeyOutput{} + out.Body.APIKey = keyStr + + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "expireApiKey", + Method: http.MethodPost, + Path: "/api/v1/apikey/expire", + Summary: "Expire API key", + Tags: []string{"ApiKeys"}, + Security: bearerAuth, + }, func(ctx context.Context, in *expireApiKeyInput) (*expireApiKeyOutput, error) { + key, err := lookupApiKey(b, in.Body.ID, in.Body.Prefix) + if err != nil { + return nil, err + } + + err = b.State.ExpireAPIKey(key) + if err != nil { + return nil, huma.Error500InternalServerError("expiring api key", err) + } + + return &expireApiKeyOutput{}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listApiKeys", + Method: http.MethodGet, + Path: "/api/v1/apikey", + Summary: "List API keys", + Tags: []string{"ApiKeys"}, + Security: bearerAuth, + }, func(ctx context.Context, _ *struct{}) (*listApiKeysOutput, error) { + keys, err := b.State.ListAPIKeys() + if err != nil { + return nil, huma.Error500InternalServerError("listing api keys", err) + } + + // Match the gRPC handler's ascending-ID ordering. + slices.SortFunc(keys, func(a, b types.APIKey) int { + return cmp.Compare(a.ID, b.ID) + }) + + out := &listApiKeysOutput{} + + out.Body.APIKeys = make([]ApiKey, len(keys)) + for i := range keys { + out.Body.APIKeys[i] = apiKeyFromState(&keys[i]) + } + + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "deleteApiKey", + Method: http.MethodDelete, + Path: "/api/v1/apikey/{prefix}", + Summary: "Delete API key", + Tags: []string{"ApiKeys"}, + Security: bearerAuth, + }, func(ctx context.Context, in *deleteApiKeyInput) (*deleteApiKeyOutput, error) { + key, err := lookupApiKey(b, in.ID, in.Prefix) + if err != nil { + return nil, err + } + + err = b.State.DestroyAPIKey(*key) + if err != nil { + return nil, huma.Error500InternalServerError("deleting api key", err) + } + + return &deleteApiKeyOutput{}, nil + }) +} + +// lookupApiKey resolves an API key by id or prefix; exactly one must be +// supplied. An empty or zero id counts as "no id". Unknown id/prefix maps to +// 404 via mapError. +func lookupApiKey(b Backend, idStr, prefix string) (*types.APIKey, error) { + id, err := parseApiKeyID(idStr) + if err != nil { + return nil, err + } + + hasID := id != 0 + hasPrefix := prefix != "" + + switch { + case hasID && hasPrefix: + return nil, huma.Error400BadRequest("provide either id or prefix, not both") + case hasID: + key, err := b.State.GetAPIKeyByID(id) + if err != nil { + return nil, mapError("getting api key", err) + } + + return key, nil + case hasPrefix: + key, err := b.State.GetAPIKey(prefix) + if err != nil { + return nil, mapError("getting api key", err) + } + + return key, nil + default: + return nil, huma.Error400BadRequest("must provide id or prefix") + } +} + +// parseApiKeyID decodes the optional uint64 id. Empty maps to zero; non-numeric +// is rejected with 400. +func parseApiKeyID(s string) (uint64, error) { + if s == "" { + return 0, nil + } + + id, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, huma.Error400BadRequest("invalid api key id", err) + } + + return id, nil +} + +// apiKeyFromState converts a domain API key into the v1 response shape, masking +// the prefix so the secret is never returned. +func apiKeyFromState(k *types.APIKey) ApiKey { + return ApiKey{ + ID: formatID(k.ID), + Prefix: apiKeyMaskedPrefix(k.Prefix), + Expiration: k.Expiration, + CreatedAt: k.CreatedAt, + LastSeen: k.LastSeen, + } +} + +// apiKeyMaskedPrefix reproduces the unexported types.APIKey.maskedPrefix. +func apiKeyMaskedPrefix(prefix string) string { + if len(prefix) == types.NewAPIKeyPrefixLength { + return "hskey-api-" + prefix + "-***" + } + + return prefix + "***" +} diff --git a/hscontrol/api/v1/auth.go b/hscontrol/api/v1/auth.go new file mode 100644 index 000000000..e6ff980f7 --- /dev/null +++ b/hscontrol/api/v1/auth.go @@ -0,0 +1,163 @@ +package apiv1 + +import ( + "context" + "errors" + "net/http" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" +) + +func init() { + registrations = append(registrations, registerAuth) +} + +// errAuthRejected is the verdict handed to the waiting registration flow when +// an auth session is rejected. +var errAuthRejected = errors.New("auth request rejected") + +// AuthRegisterRequestBody is the v1.AuthRegisterRequest body. +type AuthRegisterRequestBody struct { + User string `json:"user,omitempty"` + AuthID string `json:"authId,omitempty"` +} + +// AuthApproveRequestBody is the v1.AuthApproveRequest body. +type AuthApproveRequestBody struct { + AuthID string `json:"authId,omitempty"` +} + +// AuthRejectRequestBody is the v1.AuthRejectRequest body. +type AuthRejectRequestBody struct { + AuthID string `json:"authId,omitempty"` +} + +type ( + authRegisterInput struct { + Body AuthRegisterRequestBody + } + authRegisterOutput struct { + Body struct { + Node Node `json:"node"` + } + } +) + +type ( + authApproveInput struct { + Body AuthApproveRequestBody + } + authApproveOutput struct { + Body struct{} + } +) + +type ( + authRejectInput struct { + Body AuthRejectRequestBody + } + authRejectOutput struct { + Body struct{} + } +) + +func registerAuth(api huma.API, b Backend) { + huma.Register(api, huma.Operation{ + OperationID: "authRegister", + Method: http.MethodPost, + Path: "/api/v1/auth/register", + Summary: "Register node via auth flow", + Tags: []string{"Auth"}, + Security: bearerAuth, + }, func(ctx context.Context, in *authRegisterInput) (*authRegisterOutput, error) { + // Malformed auth_id is 400; unknown user and missing pending session are + // 404 via mapError, matching the Approve/Reject handlers. + registrationID, err := types.AuthIDFromString(in.Body.AuthID) + if err != nil { + return nil, huma.Error400BadRequest("registering node", err) + } + + user, err := b.State.GetUserByName(in.Body.User) + if err != nil { + return nil, mapError("looking up user", err) + } + + node, nodeChange, err := b.State.HandleNodeFromAuthPath( + registrationID, + types.UserID(user.ID), + nil, + util.RegisterMethodCLI, + ) + if err != nil { + return nil, mapError("registering node", err) + } + + routeChange, err := b.State.AutoApproveRoutes(node) + if err != nil { + return nil, huma.Error500InternalServerError("auto approving routes", err) + } + + b.Change(nodeChange, routeChange) + + out := &authRegisterOutput{} + out.Body.Node = nodeFromView(node) + + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "authApprove", + Method: http.MethodPost, + Path: "/api/v1/auth/approve", + Summary: "Approve a pending auth session", + Tags: []string{"Auth"}, + Security: bearerAuth, + }, func(ctx context.Context, in *authApproveInput) (*authApproveOutput, error) { + authReq, err := pendingAuthRequest(b, in.Body.AuthID) + if err != nil { + return nil, err + } + + authReq.FinishAuth(types.AuthVerdict{}) + + return &authApproveOutput{}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "authReject", + Method: http.MethodPost, + Path: "/api/v1/auth/reject", + Summary: "Reject a pending auth session", + Tags: []string{"Auth"}, + Security: bearerAuth, + }, func(ctx context.Context, in *authRejectInput) (*authRejectOutput, error) { + authReq, err := pendingAuthRequest(b, in.Body.AuthID) + if err != nil { + return nil, err + } + + authReq.FinishAuth(types.AuthVerdict{ + Err: errAuthRejected, + }) + + return &authRejectOutput{}, nil + }) +} + +// pendingAuthRequest looks up the pending session for auth_id. Malformed +// auth_id is 400, unknown is 404. +func pendingAuthRequest(b Backend, rawID string) (*types.AuthRequest, error) { + authID, err := types.AuthIDFromString(rawID) + if err != nil { + return nil, huma.Error400BadRequest("invalid auth_id", err) + } + + authReq, ok := b.State.GetAuthCacheEntry(authID) + if !ok { + return nil, huma.Error404NotFound("no pending auth session for auth_id " + authID.String()) + } + + return authReq, nil +} diff --git a/hscontrol/api/v1/errors.go b/hscontrol/api/v1/errors.go new file mode 100644 index 000000000..478a0e450 --- /dev/null +++ b/hscontrol/api/v1/errors.go @@ -0,0 +1,49 @@ +package apiv1 + +import ( + "errors" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/db" + "github.com/juanfont/headscale/hscontrol/state" + "gorm.io/gorm" +) + +// mapError translates a state/db-layer error into a Huma HTTP error +// (NotFound→404, invalid input→400, conflict→409, everything else→500). +// Handlers use this default mapping and may return a more specific huma.ErrorN +// directly. msg is a human context prefix, e.g. "getting node". +func mapError(msg string, err error) error { + if err == nil { + return nil + } + + switch { + case errors.Is(err, gorm.ErrRecordNotFound), + errors.Is(err, state.ErrNodeNotFound), + errors.Is(err, state.ErrNodeNotInNodeStore), + errors.Is(err, db.ErrUserNotFound), + errors.Is(err, db.ErrNodeNotFoundRegistrationCache), + errors.Is(err, state.ErrRegistrationExpired): + return huma.Error404NotFound(msg, err) + + case errors.Is(err, state.ErrGivenNameInvalid), + errors.Is(err, state.ErrGivenNameTaken), + errors.Is(err, state.ErrNodeNameNotUnique), + errors.Is(err, state.ErrNodeMarkedTaggedButHasNoTags), + errors.Is(err, state.ErrNodeHasNeitherUserNorTags), + errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted), + errors.Is(err, db.ErrUserStillHasNodes), + errors.Is(err, db.ErrCannotChangeOIDCUser), + errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned), + errors.Is(err, db.ErrSingleUseAuthKeyHasBeenUsed): + return huma.Error400BadRequest(msg, err) + + case errors.Is(err, state.ErrNodeKeyInUse), + errors.Is(err, state.ErrAmbiguousNodeOwnership): + return huma.Error409Conflict(msg, err) + + default: + return huma.Error500InternalServerError(msg, err) + } +} diff --git a/hscontrol/api/v1/health.go b/hscontrol/api/v1/health.go new file mode 100644 index 000000000..7da72e708 --- /dev/null +++ b/hscontrol/api/v1/health.go @@ -0,0 +1,41 @@ +package apiv1 + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" +) + +// HealthResponseBody mirrors the v1 HealthResponse message. database_connectivity +// is reported true only when the database responds to a ping. +type HealthResponseBody struct { + DatabaseConnectivity bool `json:"databaseConnectivity"` +} + +type healthOutput struct { + Body HealthResponseBody +} + +func init() { + registrations = append(registrations, registerHealth) +} + +func registerHealth(api huma.API, b Backend) { + huma.Register(api, huma.Operation{ + OperationID: "health", + Method: http.MethodGet, + Path: "/api/v1/health", + Summary: "Health check", + Description: "Reports server health, including database connectivity.", + Tags: []string{"Health"}, + Security: bearerAuth, + }, func(ctx context.Context, _ *struct{}) (*healthOutput, error) { + err := b.State.PingDB(ctx) + if err != nil { + return nil, mapError("pinging database", err) + } + + return &healthOutput{Body: HealthResponseBody{DatabaseConnectivity: true}}, nil + }) +} diff --git a/hscontrol/api/v1/nodes.go b/hscontrol/api/v1/nodes.go new file mode 100644 index 000000000..dbc1adfc5 --- /dev/null +++ b/hscontrol/api/v1/nodes.go @@ -0,0 +1,702 @@ +package apiv1 + +import ( + "context" + "errors" + "net/http" + "net/netip" + "slices" + "strconv" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" + "tailscale.com/net/tsaddr" + "tailscale.com/tailcfg" + "tailscale.com/types/key" +) + +func init() { + registrations = append(registrations, registerNodes) +} + +// errBackfillNotConfirmed guards BackfillNodeIPs behind explicit confirmed=true. +var errBackfillNotConfirmed = errors.New("not confirmed, aborting") + +// registerMethodToV1Enum maps the stored register method onto the +// SCREAMING_SNAKE enum string the v1 contract emits. +var registerMethodToV1Enum = map[string]string{ + util.RegisterMethodAuthKey: "REGISTER_METHOD_AUTH_KEY", + util.RegisterMethodOIDC: "REGISTER_METHOD_OIDC", + util.RegisterMethodCLI: "REGISTER_METHOD_CLI", +} + +// Node mirrors the v1 Node message. The protojson contract emits unpopulated +// fields: scalars and slices always (no omitempty), nested messages and optional +// timestamps as JSON null when unset. +type Node struct { + ID string `format:"uint64" json:"id"` + MachineKey string `json:"machineKey"` + NodeKey string `json:"nodeKey"` + DiscoKey string `json:"discoKey"` + IPAddresses []string `json:"ipAddresses" nullable:"false"` + Name string `json:"name"` + User *User `json:"user"` + LastSeen *time.Time `json:"lastSeen" nullable:"true"` + Expiry *time.Time `json:"expiry" nullable:"true"` + PreAuthKey *NodePreAuthKey `json:"preAuthKey"` + CreatedAt time.Time `json:"createdAt"` + RegisterMethod string `enum:"REGISTER_METHOD_UNSPECIFIED,REGISTER_METHOD_AUTH_KEY,REGISTER_METHOD_CLI,REGISTER_METHOD_OIDC" json:"registerMethod"` + GivenName string `json:"givenName"` + Online bool `json:"online"` + ApprovedRoutes []string `json:"approvedRoutes" nullable:"false"` + AvailableRoutes []string `json:"availableRoutes" nullable:"false"` + SubnetRoutes []string `json:"subnetRoutes" nullable:"false"` + Tags []string `json:"tags" nullable:"false"` +} + +// NodePreAuthKey is the PreAuthKey shape embedded in a Node response. The +// /preauthkey endpoints own the standalone request/response surface. +type NodePreAuthKey struct { + User *User `json:"user"` + ID string `format:"uint64" json:"id"` + Key string `json:"key"` + Reusable bool `json:"reusable"` + Ephemeral bool `json:"ephemeral"` + Used bool `json:"used"` + Expiration *time.Time `json:"expiration" nullable:"true"` + CreatedAt *time.Time `json:"createdAt" nullable:"true"` + AclTags []string `json:"aclTags" nullable:"false"` +} + +// SetTagsRequestBody mirrors v1.SetTagsRequest. +type SetTagsRequestBody struct { + Tags []string `json:"tags,omitempty"` +} + +// SetApprovedRoutesRequestBody mirrors v1.SetApprovedRoutesRequest. +type SetApprovedRoutesRequestBody struct { + Routes []string `json:"routes,omitempty"` +} + +// DebugCreateNodeRequestBody mirrors v1.DebugCreateNodeRequest. +type DebugCreateNodeRequestBody struct { + User string `json:"user,omitempty"` + Key string `json:"key,omitempty"` + Name string `json:"name,omitempty"` + Routes []string `json:"routes,omitempty"` +} + +type ( + getNodeInput struct { + NodeID string `format:"uint64" path:"nodeId"` + } + nodeOutput struct { + Body struct { + Node Node `json:"node"` + } + } +) + +type ( + listNodesInput struct { + User string `query:"user"` + } + listNodesOutput struct { + Body struct { + Nodes []Node `json:"nodes" nullable:"false"` + } + } +) + +type ( + deleteNodeInput struct { + NodeID string `format:"uint64" path:"nodeId"` + } + deleteNodeOutput struct { + Body struct{} + } +) + +// ExpireNodeRequestBody mirrors v1.ExpireNodeRequest. Both fields are optional; +// an absent or all-zero body expires the node immediately, as gRPC does. +type ExpireNodeRequestBody struct { + Expiry *time.Time `json:"expiry,omitempty"` + DisableExpiry bool `json:"disableExpiry,omitempty"` +} + +type expireNodeInput struct { + NodeID string `format:"uint64" path:"nodeId"` + Body *ExpireNodeRequestBody `required:"false"` +} + +type renameNodeInput struct { + NodeID string `format:"uint64" path:"nodeId"` + NewName string `path:"newName"` +} + +type setTagsInput struct { + NodeID string `format:"uint64" path:"nodeId"` + Body SetTagsRequestBody +} + +type setApprovedRoutesInput struct { + NodeID string `format:"uint64" path:"nodeId"` + Body SetApprovedRoutesRequestBody +} + +type registerNodeInput struct { + User string `query:"user"` + Key string `query:"key"` +} + +type backfillNodeIPsInput struct { + Confirmed bool `query:"confirmed"` +} + +type backfillNodeIPsOutput struct { + Body struct { + Changes []string `json:"changes" nullable:"false"` + } +} + +type debugCreateNodeInput struct { + Body DebugCreateNodeRequestBody +} + +func registerNodes(api huma.API, b Backend) { + registerNodeReadOps(api, b) + registerNodeWriteOps(api, b) + registerNodeAdminOps(api, b) +} + +func registerNodeReadOps(api huma.API, b Backend) { + huma.Register(api, huma.Operation{ + OperationID: "getNode", + Method: http.MethodGet, + Path: "/api/v1/node/{nodeId}", + Summary: "Get node", + Tags: []string{"Nodes"}, + Security: bearerAuth, + }, func(ctx context.Context, in *getNodeInput) (*nodeOutput, error) { + nodeID, err := parseNodeID(in.NodeID) + if err != nil { + return nil, err + } + + node, ok := b.State.GetNodeByID(nodeID) + if !ok { + return nil, huma.Error404NotFound("node not found") + } + + out := &nodeOutput{} + out.Body.Node = nodeFromView(node) + + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listNodes", + Method: http.MethodGet, + Path: "/api/v1/node", + Summary: "List nodes", + Tags: []string{"Nodes"}, + Security: bearerAuth, + }, func(ctx context.Context, in *listNodesInput) (*listNodesOutput, error) { + nodes := b.State.ListNodes() + if in.User != "" { + user, err := b.State.GetUserByName(in.User) + if err != nil { + return nil, mapError("listing nodes", err) + } + + nodes = b.State.ListNodesByUser(types.UserID(user.ID)) + } + + out := &listNodesOutput{} + out.Body.Nodes = make([]Node, nodes.Len()) + + for i, node := range nodes.All() { + n := nodeFromView(node) + + // Tags-as-identity: tagged nodes are presented as the special + // TaggedDevices user. + if node.IsTagged() { + user := userFromView(types.TaggedDevices.View()) + n.User = &user + } + + // SubnetRoutes is the routes actively served, exit routes included. + n.SubnetRoutes = util.PrefixesToString( + append(b.State.GetNodePrimaryRoutes(node.ID()), node.ExitRoutes()...), + ) + + out.Body.Nodes[i] = n + } + + // Match the gRPC handler's ascending-ID ordering. + slices.SortFunc(out.Body.Nodes, func(a, b Node) int { + return cmpNodeID(a.ID, b.ID) + }) + + return out, nil + }) +} + +func registerNodeWriteOps(api huma.API, b Backend) { + huma.Register(api, huma.Operation{ + OperationID: "deleteNode", + Method: http.MethodDelete, + Path: "/api/v1/node/{nodeId}", + Summary: "Delete node", + Tags: []string{"Nodes"}, + Security: bearerAuth, + }, func(ctx context.Context, in *deleteNodeInput) (*deleteNodeOutput, error) { + nodeID, err := parseNodeID(in.NodeID) + if err != nil { + return nil, err + } + + node, ok := b.State.GetNodeByID(nodeID) + if !ok { + return nil, huma.Error404NotFound("node not found") + } + + nodeChange, err := b.State.DeleteNode(node) + if err != nil { + return nil, huma.Error500InternalServerError("deleting node", err) + } + + b.Change(nodeChange) + + return &deleteNodeOutput{}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "expireNode", + Method: http.MethodPost, + Path: "/api/v1/node/{nodeId}/expire", + Summary: "Expire node", + Tags: []string{"Nodes"}, + Security: bearerAuth, + }, func(ctx context.Context, in *expireNodeInput) (*nodeOutput, error) { + nodeID, err := parseNodeID(in.NodeID) + if err != nil { + return nil, err + } + + // gRPC parity: disableExpiry => nil expiry (never expires); explicit + // expiry honoured; absent/zero body expires now. Both set is a 400. + var ( + disableExpiry bool + customExpiry *time.Time + ) + + if in.Body != nil { + disableExpiry = in.Body.DisableExpiry + customExpiry = in.Body.Expiry + } + + if disableExpiry && customExpiry != nil { + return nil, huma.Error400BadRequest("cannot set both disable_expiry and expiry") + } + + expiry := time.Now() + + switch { + case disableExpiry: + node, nodeChange, expErr := b.State.SetNodeExpiry(nodeID, nil) + if expErr != nil { + return nil, mapError("expiring node", expErr) + } + + b.Change(nodeChange) + + out := &nodeOutput{} + out.Body.Node = nodeFromView(node) + + return out, nil + case customExpiry != nil: + expiry = *customExpiry + } + + node, nodeChange, err := b.State.SetNodeExpiry(nodeID, &expiry) + if err != nil { + return nil, mapError("expiring node", err) + } + + b.Change(nodeChange) + + out := &nodeOutput{} + out.Body.Node = nodeFromView(node) + + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "renameNode", + Method: http.MethodPost, + Path: "/api/v1/node/{nodeId}/rename/{newName}", + Summary: "Rename node", + Tags: []string{"Nodes"}, + Security: bearerAuth, + }, func(ctx context.Context, in *renameNodeInput) (*nodeOutput, error) { + nodeID, err := parseNodeID(in.NodeID) + if err != nil { + return nil, err + } + + node, nodeChange, err := b.State.RenameNode(nodeID, in.NewName) + if err != nil { + return nil, mapError("renaming node", err) + } + + b.Change(nodeChange) + + out := &nodeOutput{} + out.Body.Node = nodeFromView(node) + + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "setTags", + Method: http.MethodPost, + Path: "/api/v1/node/{nodeId}/tags", + Summary: "Set tags", + Tags: []string{"Nodes"}, + Security: bearerAuth, + }, func(ctx context.Context, in *setTagsInput) (*nodeOutput, error) { + nodeID, err := parseNodeID(in.NodeID) + if err != nil { + return nil, err + } + + // Tagged nodes must keep at least one tag, so reject an empty set + // before touching state, as gRPC does. + if len(in.Body.Tags) == 0 { + return nil, huma.Error400BadRequest( + "cannot remove all tags from a node - tagged nodes must have at least one tag", + ) + } + + for _, tag := range in.Body.Tags { + tagErr := validateTag(tag) + if tagErr != nil { + return nil, huma.Error400BadRequest("setting tags", tagErr) + } + } + + _, found := b.State.GetNodeByID(nodeID) + if !found { + return nil, huma.Error404NotFound("node not found") + } + + node, nodeChange, err := b.State.SetNodeTags(nodeID, in.Body.Tags) + if err != nil { + return nil, huma.Error400BadRequest("setting tags", err) + } + + b.Change(nodeChange) + + out := &nodeOutput{} + out.Body.Node = nodeFromView(node) + + return out, nil + }) +} + +func registerNodeAdminOps(api huma.API, b Backend) { + huma.Register(api, huma.Operation{ + OperationID: "setApprovedRoutes", + Method: http.MethodPost, + Path: "/api/v1/node/{nodeId}/approve_routes", + Summary: "Set approved routes", + Tags: []string{"Nodes"}, + Security: bearerAuth, + }, func(ctx context.Context, in *setApprovedRoutesInput) (*nodeOutput, error) { + nodeID, err := parseNodeID(in.NodeID) + if err != nil { + return nil, err + } + + var newApproved []netip.Prefix + + for _, route := range in.Body.Routes { + prefix, parseErr := netip.ParsePrefix(route) + if parseErr != nil { + return nil, huma.Error400BadRequest("parsing route", parseErr) + } + + // One exit route implies both families, else the client won't + // annotate the node as an exit node. + if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() { + newApproved = append(newApproved, tsaddr.AllIPv4(), tsaddr.AllIPv6()) + } else { + newApproved = append(newApproved, prefix) + } + } + + slices.SortFunc(newApproved, netip.Prefix.Compare) + newApproved = slices.Compact(newApproved) + + node, nodeChange, err := b.State.SetApprovedRoutes(nodeID, newApproved) + if err != nil { + return nil, mapError("setting approved routes", err) + } + + b.Change(nodeChange) + + out := &nodeOutput{} + out.Body.Node = nodeFromView(node) + // SubnetRoutes here excludes exit routes, unlike the list handler. + out.Body.Node.SubnetRoutes = util.PrefixesToString( + b.State.GetNodePrimaryRoutes(node.ID()), + ) + + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "registerNode", + Method: http.MethodPost, + Path: "/api/v1/node/register", + Summary: "Register node", + Tags: []string{"Nodes"}, + Security: bearerAuth, + }, func(ctx context.Context, in *registerNodeInput) (*nodeOutput, error) { + registrationID, err := types.AuthIDFromString(in.Key) + if err != nil { + return nil, huma.Error400BadRequest("registering node", err) + } + + user, err := b.State.GetUserByName(in.User) + if err != nil { + return nil, mapError("looking up user", err) + } + + node, nodeChange, err := b.State.HandleNodeFromAuthPath( + registrationID, + types.UserID(user.ID), + nil, + util.RegisterMethodCLI, + ) + if err != nil { + return nil, mapError("registering node", err) + } + + routeChange, err := b.State.AutoApproveRoutes(node) + if err != nil { + return nil, huma.Error500InternalServerError("auto approving routes", err) + } + + // Empty changes are ignored by the change sink. + b.Change(nodeChange, routeChange) + + out := &nodeOutput{} + out.Body.Node = nodeFromView(node) + + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "backfillNodeIPs", + Method: http.MethodPost, + Path: "/api/v1/node/backfillips", + Summary: "Backfill node IPs", + Tags: []string{"Nodes"}, + Security: bearerAuth, + }, func(ctx context.Context, in *backfillNodeIPsInput) (*backfillNodeIPsOutput, error) { + if !in.Confirmed { + return nil, huma.Error400BadRequest("backfilling node IPs", errBackfillNotConfirmed) + } + + changes, err := b.State.BackfillNodeIPs() + if err != nil { + return nil, huma.Error500InternalServerError("backfilling node IPs", err) + } + + out := &backfillNodeIPsOutput{} + out.Body.Changes = changes + + if out.Body.Changes == nil { + out.Body.Changes = []string{} + } + + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "debugCreateNode", + Method: http.MethodPost, + Path: "/api/v1/debug/node", + Summary: "Debug create node", + Tags: []string{"Nodes"}, + Security: bearerAuth, + }, func(ctx context.Context, in *debugCreateNodeInput) (*nodeOutput, error) { + user, err := b.State.GetUserByName(in.Body.User) + if err != nil { + return nil, mapError("looking up user", err) + } + + routes, err := util.StringToIPPrefix(in.Body.Routes) + if err != nil { + return nil, huma.Error400BadRequest("parsing routes", err) + } + + registrationID, err := types.AuthIDFromString(in.Body.Key) + if err != nil { + return nil, huma.Error400BadRequest("debug creating node", err) + } + + regData := &types.RegistrationData{ + NodeKey: key.NewNode().Public(), + MachineKey: key.NewMachine().Public(), + Hostname: in.Body.Name, + Expiry: &time.Time{}, // zero time, not nil, to keep proto JSON round-trip semantics + } + + authRegReq := types.NewRegisterAuthRequest(regData) + b.State.SetAuthCacheEntry(registrationID, authRegReq) + + // Synthetic echo; the real node is created later via the auth path + // from the cached registration data. + echoNode := types.Node{ + NodeKey: regData.NodeKey, + MachineKey: regData.MachineKey, + Hostname: regData.Hostname, + User: user, + Expiry: &time.Time{}, + LastSeen: &time.Time{}, + Hostinfo: &tailcfg.Hostinfo{ + Hostname: in.Body.Name, + OS: "TestOS", + RoutableIPs: routes, + }, + } + + out := &nodeOutput{} + out.Body.Node = nodeFromView(echoNode.View()) + + return out, nil + }) +} + +// nodeFromView builds the Node response from a NodeView, reading through the +// view accessors. SubnetRoutes is left empty; callers that serve routes set it +// explicitly. +func nodeFromView(view types.NodeView) Node { + n := Node{ + ID: view.StringID(), + MachineKey: view.MachineKey().String(), + NodeKey: view.NodeKey().String(), + DiscoKey: view.DiscoKey().String(), + IPAddresses: nonNilStrings(view.IPsAsString()), + Name: view.Hostname(), + CreatedAt: view.CreatedAt(), + RegisterMethod: registerMethodEnum(view.RegisterMethod()), + GivenName: view.GivenName(), + Online: view.IsOnline().Valid() && view.IsOnline().Get(), + ApprovedRoutes: nonNilStrings(util.PrefixesToString(view.ApprovedRoutes().AsSlice())), + AvailableRoutes: nonNilStrings(util.PrefixesToString(view.AnnouncedRoutes())), + SubnetRoutes: []string{}, + Tags: nonNilStrings(view.Tags().AsSlice()), + } + + if view.User().Valid() { + user := userFromView(view.User()) + n.User = &user + } + + if view.AuthKey().Valid() { + n.PreAuthKey = nodePreAuthKeyFromView(view.AuthKey()) + } + + if view.LastSeen().Valid() { + ls := view.LastSeen().Get() + n.LastSeen = &ls + } + + if view.Expiry().Valid() { + exp := view.Expiry().Get() + n.Expiry = &exp + } + + return n +} + +// nodePreAuthKeyFromView builds the embedded NodePreAuthKey, masking the key to +// its prefix (legacy plaintext keys are shown in full). +func nodePreAuthKeyFromView(key types.PreAuthKeyView) *NodePreAuthKey { + pak := &NodePreAuthKey{ + ID: formatID(key.ID()), + Key: maskedPreAuthKey(key), + Reusable: key.Reusable(), + Ephemeral: key.Ephemeral(), + Used: key.Used(), + AclTags: nonNilStrings(key.Tags().AsSlice()), + } + + if key.User().Valid() { + user := userFromView(key.User()) + pak.User = &user + } + + if key.Expiration().Valid() { + exp := key.Expiration().Get() + pak.Expiration = &exp + } + + if key.CreatedAt().Valid() { + created := key.CreatedAt().Get() + pak.CreatedAt = &created + } + + return pak +} + +// registerMethodEnum maps the stored register method onto the v1 enum string, +// defaulting to REGISTER_METHOD_UNSPECIFIED for unknown values. +func registerMethodEnum(method string) string { + if enum, ok := registerMethodToV1Enum[method]; ok { + return enum + } + + return "REGISTER_METHOD_UNSPECIFIED" +} + +func nonNilStrings(s []string) []string { + if s == nil { + return []string{} + } + + return s +} + +// cmpNodeID orders two decimal node-ID strings numerically, matching the gRPC +// handler's ascending-ID ordering. +func cmpNodeID(a, b string) int { + ai, _ := strconv.ParseUint(a, 10, 64) + bi, _ := strconv.ParseUint(b, 10, 64) + + switch { + case ai < bi: + return -1 + case ai > bi: + return 1 + default: + return 0 + } +} + +func parseNodeID(s string) (types.NodeID, error) { + id, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, huma.Error400BadRequest( + "type mismatch, parameter: node_id, error: " + err.Error(), + ) + } + + return types.NodeID(id), nil +} diff --git a/hscontrol/api/v1/policy.go b/hscontrol/api/v1/policy.go new file mode 100644 index 000000000..452148113 --- /dev/null +++ b/hscontrol/api/v1/policy.go @@ -0,0 +1,191 @@ +package apiv1 + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/danielgtaylor/huma/v2" + policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" +) + +func init() { + registrations = append(registrations, registerPolicy) +} + +// PolicyRequestBody carries the HuJSON policy document as a string, for both +// v1.SetPolicyRequest and v1.CheckPolicyRequest. +type PolicyRequestBody struct { + Policy string `json:"policy,omitempty"` +} + +// PolicyResponseBody is the v1.GetPolicyResponse/SetPolicyResponse body. Fields +// carry no omitempty so zero values are emitted (EmitUnpopulated parity). +type PolicyResponseBody struct { + Policy string `json:"policy"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ( + getPolicyInput struct{} + getPolicyOutput struct { + Body PolicyResponseBody + } + + setPolicyInput struct { + Body PolicyRequestBody + } + setPolicyOutput struct { + Body PolicyResponseBody + } + + checkPolicyInput struct { + Body PolicyRequestBody + } + checkPolicyOutput struct { + Body struct{} + } +) + +func registerPolicy(api huma.API, b Backend) { + huma.Register(api, huma.Operation{ + OperationID: "getPolicy", + Method: http.MethodGet, + Path: "/api/v1/policy", + Summary: "Get policy", + Tags: []string{"Policy"}, + Security: bearerAuth, + }, func(ctx context.Context, _ *getPolicyInput) (*getPolicyOutput, error) { + switch b.Cfg.Policy.Mode { + case types.PolicyModeDB: + p, err := b.State.GetPolicy() + if err != nil { + return nil, huma.Error500InternalServerError("loading ACL from database", err) + } + + out := &getPolicyOutput{} + out.Body.Policy = p.Data + out.Body.UpdatedAt = p.UpdatedAt + + return out, nil + case types.PolicyModeFile: + absPath := util.AbsolutePathFromConfigPath(b.Cfg.Policy.Path) + + f, err := os.Open(absPath) + if err != nil { + return nil, huma.Error500InternalServerError( + fmt.Sprintf("reading policy from path %q", absPath), err, + ) + } + defer f.Close() + + data, err := io.ReadAll(f) + if err != nil { + return nil, huma.Error500InternalServerError("reading policy from file", err) + } + + out := &getPolicyOutput{} + out.Body.Policy = string(data) + + return out, nil + } + + return nil, huma.Error500InternalServerError(fmt.Sprintf( + "no supported policy mode found in configuration, policy.mode: %q", + b.Cfg.Policy.Mode, + ), nil) + }) + + huma.Register(api, huma.Operation{ + OperationID: "setPolicy", + Method: http.MethodPut, + Path: "/api/v1/policy", + Summary: "Set policy", + Tags: []string{"Policy"}, + Security: bearerAuth, + }, func(ctx context.Context, in *setPolicyInput) (*setPolicyOutput, error) { + if b.Cfg.Policy.Mode != types.PolicyModeDB { + // Policy updates are only valid in DB mode; otherwise 400. + return nil, huma.Error400BadRequest( + types.ErrPolicyUpdateIsDisabled.Error(), types.ErrPolicyUpdateIsDisabled, + ) + } + + p := in.Body.Policy + + // Reject policy that would fail when building a map response. SSH rule + // validation needs a node, so a server with no nodes can't catch every + // case here. + nodes := b.State.ListNodes() + + _, err := b.State.SetPolicy([]byte(p)) + if err != nil { + return nil, huma.Error400BadRequest("setting policy", err) + } + + if nodes.Len() > 0 { + _, err = b.State.SSHPolicy(nodes.At(0)) + if err != nil { + return nil, huma.Error400BadRequest("verifying SSH rules", err) + } + } + + updated, err := b.State.SetPolicyInDB(p) + if err != nil { + return nil, huma.Error500InternalServerError("setting policy", err) + } + + // Reload even when content is unchanged: routes manually disabled before + // may now qualify for auto-approval, so they must be re-evaluated. + cs, err := b.State.ReloadPolicy() + if err != nil { + return nil, huma.Error500InternalServerError("reloading policy", err) + } + + if len(cs) > 0 { + b.Change(cs...) + } + + out := &setPolicyOutput{} + out.Body.Policy = updated.Data + out.Body.UpdatedAt = updated.UpdatedAt + + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "checkPolicy", + Method: http.MethodPost, + Path: "/api/v1/policy/check", + Summary: "Check policy", + Description: "Validates the given policy against the server's live users and nodes without persisting it.", + Tags: []string{"Policy"}, + Security: bearerAuth, + }, func(ctx context.Context, in *checkPolicyInput) (*checkPolicyOutput, error) { + polB := []byte(in.Body.Policy) + + users, err := b.State.ListAllUsers() + if err != nil { + return nil, huma.Error500InternalServerError("loading users", err) + } + + nodes := b.State.ListNodes() + + pm, err := policyv2.NewPolicyManager(polB, users, nodes) + if err != nil { + return nil, huma.Error400BadRequest(err.Error(), err) + } + + _, err = pm.SetPolicy(polB) + if err != nil { + return nil, huma.Error400BadRequest(err.Error(), err) + } + + return &checkPolicyOutput{}, nil + }) +} diff --git a/hscontrol/api/v1/preauthkeys.go b/hscontrol/api/v1/preauthkeys.go new file mode 100644 index 000000000..9afb0aa22 --- /dev/null +++ b/hscontrol/api/v1/preauthkeys.go @@ -0,0 +1,318 @@ +package apiv1 + +import ( + "cmp" + "context" + "net/http" + "slices" + "strconv" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" +) + +func init() { + registrations = append(registrations, registerPreAuthKeys) +} + +// PreAuthKey is the v1 PreAuthKey message. User is a pointer with no omitempty +// so tagged (system-created) keys emit "user":null. Expiration and CreatedAt +// are always emitted, zero-stamped when unset. +type PreAuthKey struct { + User *User `json:"user"` + ID string `format:"uint64" json:"id"` + Key string `json:"key"` + Reusable bool `json:"reusable"` + Ephemeral bool `json:"ephemeral"` + Used bool `json:"used"` + Expiration time.Time `json:"expiration"` + CreatedAt time.Time `json:"createdAt"` + ACLTags []string `json:"aclTags" nullable:"false"` +} + +// CreatePreAuthKeyRequestBody is the v1.CreatePreAuthKeyRequest body. Every +// field is optional, hence omitempty throughout. +type CreatePreAuthKeyRequestBody struct { + User string `format:"uint64" json:"user,omitempty"` + Reusable bool `json:"reusable,omitempty"` + Ephemeral bool `json:"ephemeral,omitempty"` + Expiration *time.Time `json:"expiration,omitempty"` + ACLTags []string `json:"aclTags,omitempty"` +} + +// ExpirePreAuthKeyRequestBody is the v1.ExpirePreAuthKeyRequest body. +type ExpirePreAuthKeyRequestBody struct { + ID string `format:"uint64" json:"id,omitempty"` +} + +type ( + createPreAuthKeyInput struct { + Body CreatePreAuthKeyRequestBody + } + preAuthKeyOutput struct { + Body struct { + PreAuthKey PreAuthKey `json:"preAuthKey"` + } + } +) + +type ( + expirePreAuthKeyInput struct { + Body ExpirePreAuthKeyRequestBody + } + expirePreAuthKeyOutput struct { + Body struct{} + } +) + +type ( + deletePreAuthKeyInput struct { + ID string `format:"uint64" query:"id"` + } + deletePreAuthKeyOutput struct { + Body struct{} + } +) + +type listPreAuthKeysOutput struct { + Body struct { + PreAuthKeys []PreAuthKey `json:"preAuthKeys" nullable:"false"` + } +} + +func registerPreAuthKeys(api huma.API, b Backend) { + huma.Register(api, huma.Operation{ + OperationID: "createPreAuthKey", + Method: http.MethodPost, + Path: "/api/v1/preauthkey", + Summary: "Create pre-auth key", + Tags: []string{"PreAuthKeys"}, + Security: bearerAuth, + }, func(ctx context.Context, in *createPreAuthKeyInput) (*preAuthKeyOutput, error) { + user, err := parsePreAuthKeyUser(in.Body.User) + if err != nil { + return nil, err + } + + for _, tag := range in.Body.ACLTags { + tagErr := validateTag(tag) + if tagErr != nil { + return nil, huma.Error400BadRequest("invalid tag", tagErr) + } + } + + // CreatePreAuthKey requires a non-nil pointer; zero-stamp when unset. + var expiration time.Time + if in.Body.Expiration != nil { + expiration = *in.Body.Expiration + } + + var userID *types.UserID + + if user != 0 { + u, getErr := b.State.GetUserByID(user) + if getErr != nil { + return nil, mapError("creating pre-auth key", getErr) + } + + userID = u.TypedID() + } + + preAuthKey, err := b.State.CreatePreAuthKey( + userID, + in.Body.Reusable, + in.Body.Ephemeral, + &expiration, + in.Body.ACLTags, + ) + if err != nil { + // A key that is neither tagged nor user-owned is invalid input (400). + return nil, mapError("creating pre-auth key", err) + } + + out := &preAuthKeyOutput{} + out.Body.PreAuthKey = preAuthKeyNewToResponse(preAuthKey) + + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "expirePreAuthKey", + Method: http.MethodPost, + Path: "/api/v1/preauthkey/expire", + Summary: "Expire pre-auth key", + Tags: []string{"PreAuthKeys"}, + Security: bearerAuth, + }, func(ctx context.Context, in *expirePreAuthKeyInput) (*expirePreAuthKeyOutput, error) { + id, err := parsePreAuthKeyID(in.Body.ID) + if err != nil { + return nil, err + } + + err = b.State.ExpirePreAuthKey(id) + if err != nil { + // An unknown key id maps to 404. + return nil, mapError("expiring pre-auth key", err) + } + + return &expirePreAuthKeyOutput{}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "deletePreAuthKey", + Method: http.MethodDelete, + Path: "/api/v1/preauthkey", + Summary: "Delete pre-auth key", + Tags: []string{"PreAuthKeys"}, + Security: bearerAuth, + }, func(ctx context.Context, in *deletePreAuthKeyInput) (*deletePreAuthKeyOutput, error) { + // DELETE has no body: id is bound from the query string. + id, err := parsePreAuthKeyID(in.ID) + if err != nil { + return nil, err + } + + err = b.State.DeletePreAuthKey(id) + if err != nil { + // An unknown key id maps to 404. + return nil, mapError("deleting pre-auth key", err) + } + + return &deletePreAuthKeyOutput{}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listPreAuthKeys", + Method: http.MethodGet, + Path: "/api/v1/preauthkey", + Summary: "List pre-auth keys", + Tags: []string{"PreAuthKeys"}, + Security: bearerAuth, + }, func(ctx context.Context, _ *struct{}) (*listPreAuthKeysOutput, error) { + preAuthKeys, err := b.State.ListPreAuthKeys() + if err != nil { + return nil, huma.Error500InternalServerError("listing pre-auth keys", err) + } + + // Match the gRPC handler's ascending-ID ordering. + slices.SortFunc(preAuthKeys, func(a, b types.PreAuthKey) int { + return cmp.Compare(a.ID, b.ID) + }) + + out := &listPreAuthKeysOutput{} + + out.Body.PreAuthKeys = make([]PreAuthKey, len(preAuthKeys)) + for i := range preAuthKeys { + out.Body.PreAuthKeys[i] = preAuthKeyToResponse(&preAuthKeys[i]) + } + + return out, nil + }) +} + +// preAuthKeyNewToResponse builds the v1 response for a freshly created key. The +// plaintext key is returned only here; Used is always false. +func preAuthKeyNewToResponse(key *types.PreAuthKeyNew) PreAuthKey { + out := PreAuthKey{ + ID: formatID(key.ID), + Key: key.Key, + Reusable: key.Reusable, + Ephemeral: key.Ephemeral, + ACLTags: nonNilTags(key.Tags), + } + + if key.User != nil { + u := userFromView(key.User.View()) + out.User = &u + } + + if key.Expiration != nil { + out.Expiration = *key.Expiration + } + + if key.CreatedAt != nil { + out.CreatedAt = *key.CreatedAt + } + + return out +} + +// preAuthKeyToResponse builds the v1 response for a stored key, with its key +// field masked (see maskedPreAuthKey). +func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey { + out := PreAuthKey{ + ID: formatID(key.ID), + Key: maskedPreAuthKey(key.View()), + Reusable: key.Reusable, + Ephemeral: key.Ephemeral, + Used: key.Used, + ACLTags: nonNilTags(key.Tags), + } + + if key.User != nil { + u := userFromView(key.User.View()) + out.User = &u + } + + if key.Expiration != nil { + out.Expiration = *key.Expiration + } + + if key.CreatedAt != nil { + out.CreatedAt = *key.CreatedAt + } + + return out +} + +// maskedPreAuthKey masks new keys (those with a stored prefix) so the secret is +// never returned; legacy plaintext keys are returned in full for backwards +// compatibility. +func maskedPreAuthKey(key types.PreAuthKeyView) string { + if key.Prefix() != "" { + return "hskey-auth-" + key.Prefix() + "-***" + } + + return key.Key() +} + +// nonNilTags ensures aclTags serializes as [] rather than null, matching +// EmitUnpopulated output. +func nonNilTags(tags []string) []string { + if tags == nil { + return []string{} + } + + return tags +} + +// parsePreAuthKeyUser parses the optional uint64 user field. Empty means "no +// user" (user 0); non-numeric is rejected with 400. +func parsePreAuthKeyUser(s string) (types.UserID, error) { + if s == "" { + return 0, nil + } + + id, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, huma.Error400BadRequest("invalid user id", err) + } + + return types.UserID(id), nil +} + +// parsePreAuthKeyID parses the uint64 key id. Empty means id 0; non-numeric is +// rejected with 400. +func parsePreAuthKeyID(s string) (uint64, error) { + if s == "" { + return 0, nil + } + + id, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, huma.Error400BadRequest("invalid pre-auth key id", err) + } + + return id, nil +} diff --git a/hscontrol/api/v1/tags.go b/hscontrol/api/v1/tags.go new file mode 100644 index 000000000..bd3b4b94c --- /dev/null +++ b/hscontrol/api/v1/tags.go @@ -0,0 +1,29 @@ +package apiv1 + +import ( + "errors" + "strings" +) + +// ACL tag validation, shared by the node and pre-auth-key resources. These +// reproduce the gRPC validateTag checks and messages. +var ( + errTagMissingPrefix = errors.New("tag must start with the string 'tag:'") + errTagNotLowercase = errors.New("tag should be lowercase") + errTagHasSpaces = errors.New("tags must not contain spaces") +) + +// validateTag reports whether an ACL tag is well formed: it must start with +// "tag:", be lowercase, and contain no spaces. +func validateTag(tag string) error { + switch { + case !strings.HasPrefix(tag, "tag:"): + return errTagMissingPrefix + case strings.ToLower(tag) != tag: + return errTagNotLowercase + case len(strings.Fields(tag)) > 1: + return errTagHasSpaces + default: + return nil + } +} diff --git a/hscontrol/api/v1/types.go b/hscontrol/api/v1/types.go new file mode 100644 index 000000000..e8ffbd879 --- /dev/null +++ b/hscontrol/api/v1/types.go @@ -0,0 +1,52 @@ +package apiv1 + +import ( + "strconv" + "time" + + "github.com/juanfont/headscale/hscontrol/types" +) + +// The v1 contract follows protojson: 64-bit integers are JSON strings (avoiding +// precision loss above 2^53), timestamps are RFC 3339, and zero values are +// emitted. Hence response fields carry NO omitempty; request types keep +// omitempty so their fields stay optional in the spec. + +// formatID renders a uint64 identifier as the contract's decimal string. +func formatID[T ~uint64 | ~uint](id T) string { + return strconv.FormatUint(uint64(id), 10) +} + +// User mirrors the v1 User message. +type User struct { + ID string `format:"uint64" json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + ProviderID string `json:"providerId"` + Provider string `json:"provider"` + ProfilePicURL string `json:"profilePicUrl"` +} + +// userFromView converts a domain user into the v1 response shape, reading +// through the [types.UserView] accessors: Name falls back to Username() +// (email/provider/id) when the stored Name is empty, so OIDC users display +// their email. +func userFromView(u types.UserView) User { + name := u.Name() + if name == "" { + name = u.Username() + } + + return User{ + ID: formatID(u.ID()), + Name: name, + CreatedAt: u.CreatedAt(), + DisplayName: u.DisplayName(), + Email: u.Email(), + ProviderID: u.ProviderIdentifier().String, + Provider: u.Provider(), + ProfilePicURL: u.ProfilePicURL(), + } +} diff --git a/hscontrol/api/v1/users.go b/hscontrol/api/v1/users.go new file mode 100644 index 000000000..9468d67a1 --- /dev/null +++ b/hscontrol/api/v1/users.go @@ -0,0 +1,233 @@ +package apiv1 + +import ( + "cmp" + "context" + "net/http" + "slices" + "strconv" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" + "gorm.io/gorm" +) + +func init() { + registrations = append(registrations, registerUsers) +} + +// CreateUserRequestBody mirrors v1.CreateUserRequest. +type CreateUserRequestBody struct { + Name string `json:"name,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Email string `json:"email,omitempty"` + PictureURL string `json:"pictureUrl,omitempty"` +} + +type ( + createUserInput struct { + Body CreateUserRequestBody + } + userOutput struct { + Body struct { + User User `json:"user"` + } + } +) + +type ( + renameUserInput struct { + OldID string `format:"uint64" path:"oldId"` + NewName string `path:"newName"` + } + + deleteUserInput struct { + ID string `format:"uint64" path:"id"` + } + deleteUserOutput struct { + Body struct{} + } +) + +type ( + listUsersInput struct { + ID string `format:"uint64" query:"id"` + Name string `query:"name"` + Email string `query:"email"` + } + listUsersOutput struct { + Body struct { + Users []User `json:"users" nullable:"false"` + } + } +) + +func registerUsers(api huma.API, b Backend) { + huma.Register(api, huma.Operation{ + OperationID: "createUser", + Method: http.MethodPost, + Path: "/api/v1/user", + Summary: "Create user", + Tags: []string{"Users"}, + Security: bearerAuth, + }, func(ctx context.Context, in *createUserInput) (*userOutput, error) { + // Pre-check yields a 409 for the common case; the DB unique constraint + // is the real guard. + if in.Body.Name != "" { + _, err := b.State.GetUserByName(in.Body.Name) + if err == nil { + return nil, huma.Error409Conflict("user already exists") + } + } + + user, policyChanged, err := b.State.CreateUser(types.User{ + Name: in.Body.Name, + DisplayName: in.Body.DisplayName, + Email: in.Body.Email, + ProfilePicURL: in.Body.PictureURL, + }) + if err != nil { + return nil, mapError("creating user", err) + } + + b.Change(policyChanged) + + out := &userOutput{} + out.Body.User = userFromView(user.View()) + + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "renameUser", + Method: http.MethodPost, + Path: "/api/v1/user/{oldId}/rename/{newName}", + Summary: "Rename user", + Tags: []string{"Users"}, + Security: bearerAuth, + }, func(ctx context.Context, in *renameUserInput) (*userOutput, error) { + oldID, err := parseUserID(in.OldID) + if err != nil { + return nil, err + } + + oldUser, err := b.State.GetUserByID(oldID) + if err != nil { + return nil, mapError("renaming user", err) + } + + _, c, err := b.State.RenameUser(types.UserID(oldUser.ID), in.NewName) + if err != nil { + return nil, mapError("renaming user", err) + } + + b.Change(c) + + newUser, err := b.State.GetUserByName(in.NewName) + if err != nil { + return nil, huma.Error500InternalServerError("renaming user", err) + } + + out := &userOutput{} + out.Body.User = userFromView(newUser.View()) + + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "deleteUser", + Method: http.MethodDelete, + Path: "/api/v1/user/{id}", + Summary: "Delete user", + Tags: []string{"Users"}, + Security: bearerAuth, + }, func(ctx context.Context, in *deleteUserInput) (*deleteUserOutput, error) { + id, err := parseUserID(in.ID) + if err != nil { + return nil, err + } + + user, err := b.State.GetUserByID(id) + if err != nil { + return nil, mapError("deleting user", err) + } + + policyChanged, err := b.State.DeleteUser(types.UserID(user.ID)) + if err != nil { + return nil, mapError("deleting user", err) + } + + b.Change(policyChanged) + + return &deleteUserOutput{}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listUsers", + Method: http.MethodGet, + Path: "/api/v1/user", + Summary: "List users", + Tags: []string{"Users"}, + Security: bearerAuth, + }, func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) { + // Gateway parity: a non-numeric id is a 400 even when other filters win. + if in.ID != "" { + _, err := strconv.ParseUint(in.ID, 10, 64) + if err != nil { + return nil, huma.Error400BadRequest("invalid id", err) + } + } + + users, err := listUsersFiltered(b, in) + if err != nil { + return nil, huma.Error500InternalServerError("listing users", err) + } + + // Match the gRPC handler's ascending-ID ordering. + slices.SortFunc(users, func(a, b types.User) int { + return cmp.Compare(a.ID, b.ID) + }) + + out := &listUsersOutput{} + + out.Body.Users = make([]User, len(users)) + for i := range users { + out.Body.Users[i] = userFromView(users[i].View()) + } + + return out, nil + }) +} + +// listUsersFiltered reproduces the gRPC ListUsers precedence: name, then email, +// then id, otherwise all users. +func listUsersFiltered(b Backend, in *listUsersInput) ([]types.User, error) { + switch { + case in.Name != "": + return b.State.ListUsersWithFilter(&types.User{Name: in.Name}) + case in.Email != "": + return b.State.ListUsersWithFilter(&types.User{Email: in.Email}) + case in.ID != "": + id, err := strconv.ParseUint(in.ID, 10, 64) + if err != nil { + return nil, err + } + + if id == 0 { + return b.State.ListAllUsers() + } + + return b.State.ListUsersWithFilter(&types.User{Model: gorm.Model{ID: uint(id)}}) + default: + return b.State.ListAllUsers() + } +} + +func parseUserID(s string) (types.UserID, error) { + id, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, huma.Error400BadRequest("invalid user id", err) + } + + return types.UserID(id), nil +} diff --git a/hscontrol/api/v2/README.md b/hscontrol/api/v2/README.md new file mode 100644 index 000000000..90fad3638 --- /dev/null +++ b/hscontrol/api/v2/README.md @@ -0,0 +1,124 @@ +# API v2: Headscale's v2 API + +This is Headscale's v2 HTTP API, served at `/api/v2`. Some of its endpoints are +**ported from Tailscale's API**, reusing Tailscale's wire shapes, so the +Tailscale ecosystem that cannot talk to Headscale today works: the +[Terraform/OpenTofu provider], [tscli], and the official [Go client] +(`tailscale.com/client/tailscale/v2`). + +It is **not** a port of the whole Tailscale API. Ported endpoints are added one +at a time, only as we need them; a headscale-native v2 endpoint may use +headscale's own conventions. The headscale-native admin API stays at `/api/v1` +(`hscontrol/api/v1`). This guide is for the endpoints **ported from Tailscale**. + +[Terraform/OpenTofu provider]: https://registry.terraform.io/providers/tailscale/tailscale/latest +[tscli]: https://github.com/jaxxstorm/tscli +[Go client]: https://pkg.go.dev/tailscale.com/client/tailscale/v2 + +## Conventions + +- Operations derived from Tailscale carry the `Tailscale compat` tag. +- The `{tailnet}` path segment must be `-` (the single Headscale tailnet); + anything else is `404`. See `requireDefaultTailnet`. +- Errors use **Tailscale's** body (`{"message","data","status"}`), installed as + a per-API transform (`tailscaleErrorTransformer` in `errors.go`). A future + headscale-native v2 operation would keep Huma's RFC 9457 problem+json. +- Auth accepts a credential as **HTTP Basic** (key as username, what the SDK + sends) or **Bearer**: an admin API key (`hskey-api-…`), or an OAuth access + token (`hskey-oauthtok-…`). See `authMiddleware`. +- Each operation declares the Tailscale scope it requires (`auth_keys`, + `oauth_keys`, `devices:core`, `devices:routes`, `policy_file`, + `feature_settings`, each with a `:read` subset, plus `all`/`all:read`). + `requireScope` records it both for the middleware and in the generated + OpenAPI, as an `x-required-scope` extension and a sentence in the operation + description, so the scope shows up in the docs and spec. Enforcement: an + **admin API key is all-access** (scope checks skipped); an **OAuth access + token is scope-limited**, the middleware checks the operation's declared scope + against the token's grant (`scope.Grants`, where a write scope subsumes its + `:read` and `all`/`all:read` are super-scopes). The two are told apart by + credential prefix. +- Resolve one entity by id with a typed getter (`GetNodeByID`, `GetUserByID`, + `GetAPIKeyByID`, `GetPreAuthKeyByID`); add one to state/db if it is missing + rather than scanning a `List`. Build responses from the view accessors + (`NodeView`/`UserView`/`PreAuthKeyView`), never `AsStruct()`. +- Reuse upstream wire shapes, but declare the request/response structs here: + Huma reflects these to build the OpenAPI schema, and the upstream `Key`'s + `ExpirySeconds *time.Duration` marshals as nanoseconds, which the spec and + every client read as seconds. + +## OAuth clients & scopes + +Most of the Tailscale ecosystem (the Terraform provider, `tscli`, the Go client) +accepts **either** an API key **or OAuth 2.0 client-credentials**; the Kubernetes +operator is OAuth-only. Supporting OAuth lets all of them drive Headscale. + +- **OAuth clients** are not a separate resource; they are `keyType:"client"` on + the keys endpoint, exactly as Tailscale does it. Create + (`POST /api/v2/tailnet/-/keys` with `{"keyType":"client","scopes":[…],"tags":[…]}`) + returns a `Key` whose `id` is the client id and whose `key` is the secret, + **shown once**; get/list never re-expose it. The secret is + `hskey-client--`, embedding the client id so the token + endpoint derives it from the secret (Tailscale's `get-authkey` trick). See + `keys.go` (`createOAuthClient`) and `db/oauth.go`. +- **Token endpoint** `POST /api/v2/oauth/token` (`oauth.go`) is a plain handler, + not a Huma operation: it takes `application/x-www-form-urlencoded` and emits + RFC 6749 OAuth2 error bodies (`{"error","error_description"}`). Credentials + arrive in the body or HTTP Basic; optional space-delimited `scope`/`tags` + narrow the token to a subset of the client's grant. It returns a 1-hour + `Bearer` access token (`hskey-oauthtok-…`). +- **Scope enforcement** is the one seam in `authMiddleware`. **Tag enforcement**: + an auth key minted by a token may only carry tags the token holds, or tags + owned-by them via the policy `tagOwners` (`State.TagOwnedByTags` → + `policy/v2`), so e.g. an operator token tagged `tag:k8s-operator` may mint + `tag:k8s` keys. +- Credentials/tokens are stored like API keys: a public id/prefix plus an + **Argon2id** hash of the secret (no JWT, no signing keys). `OAuthClient` and + `OAuthAccessToken` live in `types/oauth.go` and `db/oauth.go`. + +## Adding an endpoint + +Worked example: the keys resource (`keys.go`) = Tailscale auth keys = Headscale +pre-auth keys. + +1. **Read the Tailscale spec.** Find the operation in the [Tailscale API + reference](https://tailscale.com/api) (OpenAPI 3.1). Note method, path, + request/response schema, and which variant(s) Headscale supports (auth keys + only, for keys). + +2. **Capture golden samples.** Pull the request + response JSON examples from the + spec, prune to the variant, and use them as the assertion in the contract + test. _Acceptance: the captured request and response are recorded in the + test._ + +3. **Map to Headscale.** Write the field ↔ field ↔ `state` call mapping. Record + gaps and the decision for each (e.g. Tailscale `preauthorized` has no + Headscale equivalent: accepted, ignored, echoed back). _Acceptance: every + request field is consumed or deliberately ignored; every response field has a + source._ + +4. **Implement the Huma operation.** Declare named request/response structs with + validation/`default`/`example`/`doc` tags; tag the operation `Tailscale +compat`; declare its `Errors`; enforce the tailnet and scope. Map state + errors with `mapError`. _Acceptance: `go build ./hscontrol/api/v2/` and the + operation appears in `Spec()`._ + +5. **Contract test (in-process, `humatest`).** Assert the server accepts the + golden request and returns the golden response shape, with secrets and + timestamps neutralised. Pin the wire facts (e.g. `expirySeconds` in seconds, + the list `{"keys":[...]}` envelope, the error `message`). See + `hscontrol/apiv2_keys_test.go`. _Acceptance: the test is green._ + +6. **Roundtrip the real clients.** Add a `t.Run` subtest to `TestAPIv2` + (`hscontrol/servertest/apiv2_test.go`) for each of the Go client, tscli, and + OpenTofu, full create→read→list→delete against one shared server on a real + loopback port (`servertest.WithRealListener`). tscli and tofu come from the + nix dev shell; a missing binary fails the test. _Acceptance: `nix develop -c +go test ./hscontrol/servertest/ -run TestAPIv2` is green._ + +7. **Update the CLI** only if the v2 operation fully replaces a v1 one. Tailscale + has no separate key-expire verb (its `DELETE` _is_ the revoke), so v2 maps + `DELETE` to a soft revoke: the key stays retrievable with `invalid: true` + until the collector reaps it (`preauth_keys.revoked_retention`), the + equivalent of v1 `preauthkeys expire`. `headscale preauthkeys` still stays on + v1 for now (it is the cross-user admin surface), but the verb gap that + previously blocked migration is closed. diff --git a/hscontrol/api/v2/acl.go b/hscontrol/api/v2/acl.go new file mode 100644 index 000000000..ff6917933 --- /dev/null +++ b/hscontrol/api/v2/acl.go @@ -0,0 +1,229 @@ +package apiv2 + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "os" + "strings" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" +) + +func init() { + registrations = append(registrations, registerACL) +} + +const ( + contentTypeJSON = "application/json" + contentTypeHuJSON = "application/hujson" +) + +// defaultPolicy is served by GET when no policy is set, and is what an If-Match +// of "ts-default" matches against. Allow-all, matching the cluster's behaviour +// with no configured policy. +const defaultPolicy = `{ + // Headscale default policy. Allows all communication. + "acls": [ + {"action": "accept", "src": ["*"], "dst": ["*:*"]}, + ], +} +` + +type getACLInput struct { + Tailnet string `path:"tailnet"` + Accept string `header:"Accept"` + Details bool `doc:"Accepted for compatibility; ignored." query:"details"` +} + +type setACLInput struct { + Tailnet string `path:"tailnet"` + IfMatch string `header:"If-Match"` + Accept string `header:"Accept"` + // RawBody captures the raw HuJSON or JSON policy bytes; huma feeds them here + // regardless of Content-Type. The declared type only shapes the OpenAPI schema. + RawBody []byte `contentType:"application/json"` +} + +func registerACL(api huma.API, b Backend) { + aclTags := []string{"Policy", "Tailscale compat"} + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getACL", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/acl", + Summary: "Get the policy file", + Tags: aclTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusInternalServerError}, + }, scope.PolicyFileRead), func(ctx context.Context, in *getACLInput) (*huma.StreamResponse, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + data, err := currentPolicy(b) + if err != nil { + return nil, err + } + + return streamPolicy(data, aclContentType(in.Accept)), nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "setACL", + Method: http.MethodPost, + Path: "/api/v2/tailnet/{tailnet}/acl", + Summary: "Set the policy file", + Tags: aclTags, + Security: security, + DefaultStatus: http.StatusOK, + // The body is an opaque HuJSON document captured raw; skip huma's + // schema validation (which would expect a base64 string). + SkipValidateBody: true, + Errors: []int{ + http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, + http.StatusNotFound, http.StatusPreconditionFailed, http.StatusInternalServerError, + }, + }, scope.PolicyFile), func(ctx context.Context, in *setACLInput) (*huma.StreamResponse, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + if b.Cfg.Policy.Mode != types.PolicyModeDB { + return nil, huma.Error400BadRequest( + types.ErrPolicyUpdateIsDisabled.Error(), types.ErrPolicyUpdateIsDisabled, + ) + } + + if in.IfMatch != "" { + current, err := currentPolicy(b) + if err != nil { + return nil, err + } + + if !etagMatches(in.IfMatch, current) { + return nil, huma.Error412PreconditionFailed("precondition failed, invalid old hash") + } + } + + // Mirror the v1 setPolicy flow: validate, SSH-check, persist, reload. + nodes := b.State.ListNodes() + + _, err = b.State.SetPolicy(in.RawBody) + if err != nil { + return nil, huma.Error400BadRequest("setting policy", err) + } + + if nodes.Len() > 0 { + _, err = b.State.SSHPolicy(nodes.At(0)) + if err != nil { + return nil, huma.Error400BadRequest("verifying SSH rules", err) + } + } + + updated, err := b.State.SetPolicyInDB(string(in.RawBody)) + if err != nil { + return nil, huma.Error500InternalServerError("setting policy", err) + } + + cs, err := b.State.ReloadPolicy() + if err != nil { + return nil, huma.Error500InternalServerError("reloading policy", err) + } + + if len(cs) > 0 { + b.Change(cs...) + } + + return streamPolicy([]byte(updated.Data), aclContentType(in.Accept)), nil + }) +} + +// currentPolicy returns the stored policy bytes, or the allow-all default when +// none is set. File mode reads the configured path; DB mode reads the database. +func currentPolicy(b Backend) ([]byte, error) { + switch b.Cfg.Policy.Mode { + case types.PolicyModeDB: + p, err := b.State.GetPolicy() + if err != nil { + if errors.Is(err, types.ErrPolicyNotFound) { + return []byte(defaultPolicy), nil + } + + return nil, huma.Error500InternalServerError("loading policy", err) + } + + if p.Data == "" { + return []byte(defaultPolicy), nil + } + + return []byte(p.Data), nil + + case types.PolicyModeFile: + path := util.AbsolutePathFromConfigPath(b.Cfg.Policy.Path) + if path == "" { + return []byte(defaultPolicy), nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, huma.Error500InternalServerError("reading policy file", err) + } + + return data, nil + } + + return nil, huma.Error500InternalServerError("unsupported policy mode", nil) +} + +// streamPolicy writes the policy bytes as-is with the chosen content type and +// a content-addressed ETag, bypassing huma's JSON marshaler so HuJSON survives. +func streamPolicy(data []byte, contentType string) *huma.StreamResponse { + return &huma.StreamResponse{Body: func(ctx huma.Context) { + ctx.SetHeader("Content-Type", contentType) + ctx.SetHeader("ETag", policyETag(data)) + ctx.SetStatus(http.StatusOK) + _, _ = ctx.BodyWriter().Write(data) + }} +} + +// aclContentType serves HuJSON only when explicitly asked; everything else +// (including an empty Accept) gets application/json. The bytes are identical. +func aclContentType(accept string) string { + if strings.Contains(accept, contentTypeHuJSON) { + return contentTypeHuJSON + } + + return contentTypeJSON +} + +// policyETag is the quoted hex SHA-256 of the policy bytes: stable across reads, +// changes iff the policy changes. +func policyETag(data []byte) string { + sum := sha256.Sum256(data) + + return `"` + hex.EncodeToString(sum[:]) + `"` +} + +// etagMatches reports whether an If-Match header satisfies the current policy. +// "*" always matches; "ts-default" matches only when no policy is set (an +// approximation of Tailscale's untouched-default semantics). +func etagMatches(ifMatch string, current []byte) bool { + ifMatch = strings.TrimSpace(ifMatch) + + switch ifMatch { + case "*": + return true + case `"ts-default"`, "ts-default": + return string(current) == defaultPolicy + default: + return ifMatch == policyETag(current) + } +} diff --git a/hscontrol/api/v2/api.go b/hscontrol/api/v2/api.go new file mode 100644 index 000000000..e42c51b83 --- /dev/null +++ b/hscontrol/api/v2/api.go @@ -0,0 +1,325 @@ +// Package apiv2 is Headscale's v2 HTTP API, served at /api/v2. +// +// Where the v1 API (hscontrol/api/v1) is the headscale-native admin surface, v2 +// additionally ports selected endpoints from Tailscale's API, reusing +// Tailscale's wire shapes (paths, request/response JSON, error body), so the +// existing Tailscale ecosystem (the Terraform/OpenTofu provider, tscli, and +// tailscale.com/client/tailscale/v2) can drive Headscale unchanged. Ported +// operations carry the "Tailscale compat" tag; a headscale-native v2 operation +// may use headscale's own conventions instead. See README.md for the porting +// guide. +// +// It depends only on the domain layer (hscontrol/state, hscontrol/types, and +// the db error sentinels), never on the hscontrol server package, so it sits +// beside the v1 API without either importing the other. +package apiv2 + +import ( + "context" + "encoding/base64" + "maps" + "net/http" + "strings" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humachi" + "github.com/go-chi/chi/v5" + "github.com/juanfont/headscale/hscontrol/scope" + "github.com/juanfont/headscale/hscontrol/state" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/types/change" +) + +// Backend is the dependency surface the v2 API needs from the control plane: +// the state layer, the change-notification sink that distributes node/policy +// updates to connected clients, and the config (Policy.Mode/Path, Node.Expiry, +// and TLS are read by the ACL and settings handlers). +type Backend struct { + State *state.State + Change func(...change.Change) + Cfg *types.Config +} + +// security is the requirement applied to authenticated operations: an API key +// presented as HTTP Basic (the key as username, what the Tailscale SDK sends) +// or as a Bearer token. +var security = []map[string][]string{{"basicAuth": {}}, {"bearerAuth": {}}} + +// registrations is populated by each resource file's init(), so adding a +// resource means adding a file rather than editing a shared list. +var registrations []func(huma.API, Backend) + +// Register wires up every operation contributed by the resource files. Exported +// so tests can register the v2 operations onto a humatest API. +func Register(api huma.API, b Backend) { + for _, fn := range registrations { + fn(api, b) + } +} + +// Config returns the Huma configuration shared by the production API and tests: +// the v2 OpenAPI/docs paths, the basic+bearer security schemes, and the +// Tailscale error transform. Suppressing SchemasPath/CreateHooks keeps "$schema" +// out of the emitted bodies, matching the Tailscale wire contract. +func Config() huma.Config { + config := huma.DefaultConfig("Headscale API", "v2") + config.Info.Description = "Headscale v2 API. Some endpoints are ported from / compatible with the Tailscale API (tagged \"Tailscale compat\")." + + config.OpenAPIPath = "/api/v2/openapi" + config.DocsPath = "/api/v2/docs" + config.SchemasPath = "" + config.CreateHooks = nil + + config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ + "basicAuth": {Type: "http", Scheme: "basic"}, + "bearerAuth": {Type: "http", Scheme: "bearer"}, + } + + config.Transformers = append(config.Transformers, tailscaleErrorTransformer) + + // Accept application/hujson request bodies (the Tailscale SDK sends the + // policy file that way). The bytes are captured raw by the ACL handler, so + // reusing the JSON format is only to satisfy huma's content-type check. + // Clone first: config.Formats aliases huma's shared DefaultFormats map. + formats := maps.Clone(config.Formats) + formats["application/hujson"] = formats["application/json"] + config.Formats = formats + + return config +} + +// NewAPI builds the v2 Huma API on router, installs the auth+scope middleware, +// and registers every operation. +func NewAPI(router chi.Router, backend Backend) huma.API { + api := humachi.New(router, Config()) + + // Must run before Register: Huma snapshots the middleware chain at operation + // registration, so a middleware added afterwards would silently never run. + api.UseMiddleware(authMiddleware(api, backend)) + + Register(api, backend) + + // The OAuth token endpoint is a plain route, not a Huma operation (see + // oauth.go); register it on the same router. + registerOAuthToken(router, backend) + + return api +} + +// Handler builds the v2 API on a fresh mux and returns both, for mounting. +func Handler(backend Backend) (*chi.Mux, huma.API) { + mux := chi.NewMux() + api := NewAPI(mux, backend) + + return mux, api +} + +// Spec emits the OpenAPI 3.1 document. The zero Backend is safe because +// handlers are registered but never invoked during emission. +func Spec() ([]byte, error) { + api := NewAPI(chi.NewMux(), Backend{}) + + return api.OpenAPI().YAML() +} + +// Spec30 emits the document downgraded to OpenAPI 3.0.3, needed because +// oapi-codegen cannot yet read 3.1; the typed client is generated from this. +func Spec30() ([]byte, error) { + api := NewAPI(chi.NewMux(), Backend{}) + + return api.OpenAPI().DowngradeYAML() +} + +type contextKey int + +const ( + localTrustKey contextKey = iota + ownerUserKey + principalScopesKey + principalTagsKey +) + +// WithLocalTrust marks a request as arriving over a locally-trusted transport +// (the unix socket), bypassing API-key authentication. Reserved for a future +// v2 socket mount; the network listener always authenticates. +func WithLocalTrust(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + next.ServeHTTP(w, req.WithContext( + context.WithValue(req.Context(), localTrustKey, struct{}{}), + )) + }) +} + +// authMiddleware authenticates the API key (HTTP Basic with the key as the +// username, what the Tailscale SDK sends, or Bearer), records the key's owning +// user for handlers, and enforces the operation's required scope. +func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Context)) { + return func(ctx huma.Context, next func(huma.Context)) { + if ctx.Context().Value(localTrustKey) != nil { + next(ctx) + + return + } + + if len(ctx.Operation().Security) == 0 { + next(ctx) + + return + } + + token, ok := bearerOrBasicToken(ctx.Header("Authorization")) + if !ok { + _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") + + return + } + + // An OAuth access token is scope-limited; an admin API key is all-access. + // They are told apart by prefix so a scoped token can never be mistaken + // for an all-access key. + if strings.HasPrefix(token, types.AccessTokenPrefix) { + at, err := b.State.AuthenticateAccessToken(token) + if err != nil { + _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") + + return + } + + if want, ok := requiredScope(ctx.Operation()); ok && !scope.Grants(scope.Parse(at.Scopes), want) { + _ = huma.WriteErr(api, ctx, http.StatusForbidden, + "token is missing the required scope "+string(want)) + + return + } + + // The keys handler multiplexes on keyType, so its required scope and + // permitted tags depend on the body; carry the token's scopes and tags + // for it to finish the check the static middleware cannot. + ctx = huma.WithValue(ctx, principalScopesKey, at.Scopes) + ctx = huma.WithValue(ctx, principalTagsKey, at.Tags) + + next(ctx) + + return + } + + key, err := b.State.AuthenticateAPIKey(token) + if err != nil { + _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") + + return + } + + // An admin API key is all-access: its operations are not scope-checked. + // Record its owning user (may be unset) so handlers can create user-owned + // keys on its behalf. + if key.UserID != nil { + ctx = huma.WithValue(ctx, ownerUserKey, types.UserID(*key.UserID)) + } + + next(ctx) + } +} + +// bearerOrBasicToken extracts the API key from an Authorization header. The +// Tailscale SDK sends the key as the Basic-auth username with an empty +// password; curl and humans may use Bearer. +func bearerOrBasicToken(header string) (string, bool) { + if token, ok := strings.CutPrefix(header, "Bearer "); ok { + return token, token != "" + } + + if encoded, ok := strings.CutPrefix(header, "Basic "); ok { + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", false + } + + username, _, _ := strings.Cut(string(raw), ":") + + return username, username != "" + } + + return "", false +} + +// ownerUser returns the user the request's API key belongs to, if any. +func ownerUser(ctx context.Context) (types.UserID, bool) { + uid, ok := ctx.Value(ownerUserKey).(types.UserID) + + return uid, ok +} + +// principalScopes returns the scopes granted to the request's OAuth access +// token, and whether the request authenticated with one. ok is false for an +// admin API key, which is all-access and not scope-checked. +func principalScopes(ctx context.Context) ([]string, bool) { + scopes, ok := ctx.Value(principalScopesKey).([]string) + + return scopes, ok +} + +// principalTags returns the tags granted to the request's OAuth access token, +// and whether the request authenticated with one. An admin API key is not an +// OAuth token, so ok is false and its key creation is unrestricted by tags. +func principalTags(ctx context.Context) ([]string, bool) { + tags, ok := ctx.Value(principalTagsKey).([]string) + + return tags, ok +} + +// requireDefaultTailnet rejects any tailnet other than "-". Headscale is +// single-tailnet; the Tailscale SDK sends "-" (its default tailnet). A non-"-" +// value is "no such tailnet", a 404, which lets the SDK's IsNotFound behave. +func requireDefaultTailnet(tailnet string) error { + if tailnet != "-" { + return huma.Error404NotFound("tailnet not found") + } + + return nil +} + +// The scope vocabulary and the grant predicate live in the hscontrol/scope +// package; this file only wires a required scope onto each huma operation and +// reads it back in the middleware. + +// scopeMetaKey keys the per-operation required scope in huma.Operation.Metadata. +const scopeMetaKey = "headscale.scope" + +// requireScope records op's required scope, both in its Metadata (where the auth +// middleware reads it back) and in the generated OpenAPI document: an +// x-required-scope extension for machine consumers and a Description line so the +// rendered docs state what each operation needs. +func requireScope(op huma.Operation, s scope.Scope) huma.Operation { + if op.Metadata == nil { + op.Metadata = map[string]any{} + } + + op.Metadata[scopeMetaKey] = s + + if op.Extensions == nil { + op.Extensions = map[string]any{} + } + + op.Extensions["x-required-scope"] = string(s) + + note := "Requires the `" + string(s) + "` OAuth scope (an admin API key is all-access)." + if op.Description == "" { + op.Description = note + } else { + op.Description += "\n\n" + note + } + + return op +} + +// requiredScope returns the scope an operation declared via requireScope, if any. +func requiredScope(op *huma.Operation) (scope.Scope, bool) { + if op == nil || op.Metadata == nil { + return "", false + } + + s, ok := op.Metadata[scopeMetaKey].(scope.Scope) + + return s, ok +} diff --git a/hscontrol/api/v2/devices.go b/hscontrol/api/v2/devices.go new file mode 100644 index 000000000..5bdb553f8 --- /dev/null +++ b/hscontrol/api/v2/devices.go @@ -0,0 +1,486 @@ +package apiv2 + +import ( + "context" + "net/http" + "net/netip" + "slices" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" + "tailscale.com/net/tsaddr" +) + +func init() { + registrations = append(registrations, registerDevices) +} + +// Device is the Tailscale device response. Headscale nodes map onto it; fields +// Headscale does not track are emitted as their zero value. The route slices +// are only populated for the fields=all variant, matching Tailscale. +type Device struct { + Addresses []string `json:"addresses" nullable:"false"` + Name string `json:"name"` + ID string `json:"id"` + NodeID string `json:"nodeId"` + Authorized bool `json:"authorized"` + User string `json:"user"` + Tags []string `json:"tags" nullable:"false"` + KeyExpiryDisabled bool `json:"keyExpiryDisabled"` + Created time.Time `json:"created"` + Expires *time.Time `json:"expires,omitempty"` + Hostname string `json:"hostname"` + IsEphemeral bool `json:"isEphemeral"` + LastSeen *time.Time `json:"lastSeen,omitempty"` + MachineKey string `json:"machineKey"` + NodeKey string `json:"nodeKey"` + OS string `json:"os"` + ClientVersion string `json:"clientVersion"` + UpdateAvailable bool `json:"updateAvailable"` + + // Populated only when fields=all is requested. + AdvertisedRoutes []string `json:"advertisedRoutes,omitempty"` + EnabledRoutes []string `json:"enabledRoutes,omitempty"` +} + +// DeviceRoutes is the GET /device/{id}/routes response: what the node announces +// vs which of those are approved (Tailscale calls approved routes "enabled"). +type DeviceRoutes struct { + Advertised []string `json:"advertisedRoutes" nullable:"false"` + Enabled []string `json:"enabledRoutes" nullable:"false"` +} + +// Request bodies match the Tailscale SDK wire shapes. +type ( + setAuthorizedRequest struct { + Authorized bool `json:"authorized"` + } + setNameRequest struct { + Name string `json:"name"` + } + // setTagsRequest.Tags is intentionally not nullable:"false": the SDK sends + // "tags":null for "make untagged", which the handler accepts as a no-op + // rather than failing to decode (Headscale cannot untag a node). + setTagsRequest struct { + Tags []string `json:"tags"` + } + setKeyRequest struct { + KeyExpiryDisabled bool `json:"keyExpiryDisabled"` + } + setSubnetRoutesRequest struct { + Routes []string `json:"routes" nullable:"false"` + } +) + +type ( + deviceByIDInput struct { + DeviceID string `doc:"Device id (the decimal node id)." path:"id"` + Fields string `doc:"Set to \"all\" for route fields." query:"fields"` + } + listDevicesInput struct { + Tailnet string `doc:"Tailnet; must be \"-\"." path:"tailnet"` + Fields string `query:"fields"` + } + setAuthorizedInput struct { + DeviceID string `path:"id"` + Body setAuthorizedRequest + } + setNameInput struct { + DeviceID string `path:"id"` + Body setNameRequest + } + setTagsInput struct { + DeviceID string `path:"id"` + Body setTagsRequest + } + setKeyInput struct { + DeviceID string `path:"id"` + Body setKeyRequest + } + setSubnetRoutesInput struct { + DeviceID string `path:"id"` + Body setSubnetRoutesRequest + } + + deviceOutput struct{ Body Device } + deviceRoutesOutput struct{ Body DeviceRoutes } + listDevicesOutput struct { + Body struct { + Devices []Device `json:"devices" nullable:"false"` + } + } + emptyOutput struct{ Body struct{} } +) + +func registerDevices(api huma.API, b Backend) { + deviceTags := []string{"Devices", "Tailscale compat"} + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getDevice", + Method: http.MethodGet, + Path: "/api/v2/device/{id}", + Summary: "Get a device", + Tags: deviceTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, scope.DevicesCoreRead), func(ctx context.Context, in *deviceByIDInput) (*deviceOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + return &deviceOutput{Body: deviceFromView(node, in.Fields == "all")}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "listDevices", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/devices", + Summary: "List devices", + Tags: deviceTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, scope.DevicesCoreRead), func(ctx context.Context, in *listDevicesInput) (*listDevicesOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + nodes := b.State.ListNodes() + allFields := in.Fields == "all" + + out := &listDevicesOutput{} + out.Body.Devices = make([]Device, 0, nodes.Len()) + + for _, node := range nodes.All() { + out.Body.Devices = append(out.Body.Devices, deviceFromView(node, allFields)) + } + + return out, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "deleteDevice", + Method: http.MethodDelete, + Path: "/api/v2/device/{id}", + Summary: "Delete a device", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, scope.DevicesCore), func(ctx context.Context, in *deviceByIDInput) (*emptyOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + nodeChange, err := b.State.DeleteNode(node) + if err != nil { + return nil, mapError("deleting device", err) + } + + b.Change(nodeChange) + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "authorizeDevice", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/authorized", + Summary: "Authorize a device", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, scope.DevicesCore), func(ctx context.Context, in *setAuthorizedInput) (*emptyOutput, error) { + _, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + // Headscale nodes are authorized the moment they register; there is no + // de-authorize state. Accept authorized=true as a no-op; reject false so + // callers are not misled into thinking the device is fenced off. + if !in.Body.Authorized { + return nil, huma.Error400BadRequest( + "Headscale does not support de-authorizing a device; delete or expire it instead", + ) + } + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "setDeviceName", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/name", + Summary: "Set a device's name", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, scope.DevicesCore), func(ctx context.Context, in *setNameInput) (*emptyOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + _, nodeChange, err := b.State.RenameNode(node.ID(), in.Body.Name) + if err != nil { + return nil, mapError("renaming device", err) + } + + b.Change(nodeChange) + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "setDeviceTags", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/tags", + Summary: "Set a device's tags", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, scope.DevicesCore), func(ctx context.Context, in *setTagsInput) (*emptyOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + // Headscale cannot make a node untagged (tags-as-identity is one-way), so + // an empty/null tag set is accepted as a no-op rather than rejected. This + // keeps tooling lifecycles such as Terraform destroy working; the SDK + // sends "tags":null for "make untagged". + if len(in.Body.Tags) == 0 { + return &emptyOutput{}, nil + } + + // An OAuth token may only assign tags within its grant (held directly or + // owned by a held tag per policy); an admin API key is unrestricted. The + // devices:core scope alone must not let a token stamp an arbitrary policy + // tag (e.g. tag:prod) onto any node. SetNodeTags still enforces that each + // tag exists in policy. + if tokenTags, isOAuth := principalTags(ctx); isOAuth { + for _, tag := range in.Body.Tags { + if !b.State.TagOwnedByTags(tag, tokenTags) { + return nil, huma.Error403Forbidden("token may not assign tag " + tag) + } + } + } + + _, nodeChange, err := b.State.SetNodeTags(node.ID(), in.Body.Tags) + if err != nil { + return nil, mapError("setting device tags", err) + } + + b.Change(nodeChange) + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "setDeviceKey", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/key", + Summary: "Set a device's key settings", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, scope.DevicesCore), func(ctx context.Context, in *setKeyInput) (*emptyOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + // Only disabling expiry maps cleanly (expiry=nil never expires). + // Re-enabling has no target expiry in the Tailscale request and Headscale + // stores no original, so it is accepted as a no-op (keeps Terraform + // destroy working) rather than guessing a lifetime. + if !in.Body.KeyExpiryDisabled { + return &emptyOutput{}, nil + } + + _, nodeChange, err := b.State.SetNodeExpiry(node.ID(), nil) + if err != nil { + return nil, mapError("setting device key expiry", err) + } + + b.Change(nodeChange) + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "setDeviceRoutes", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/routes", + Summary: "Set a device's enabled subnet routes", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, scope.DevicesRoutes), func(ctx context.Context, in *setSubnetRoutesInput) (*deviceRoutesOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + approved, err := parseRoutes(in.Body.Routes) + if err != nil { + return nil, err + } + + updated, nodeChange, err := b.State.SetApprovedRoutes(node.ID(), approved) + if err != nil { + return nil, mapError("setting device routes", err) + } + + b.Change(nodeChange) + + return &deviceRoutesOutput{Body: routesFromView(updated)}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getDeviceRoutes", + Method: http.MethodGet, + Path: "/api/v2/device/{id}/routes", + Summary: "Get a device's subnet routes", + Tags: deviceTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, scope.DevicesRoutesRead), func(ctx context.Context, in *deviceByIDInput) (*deviceRoutesOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + return &deviceRoutesOutput{Body: routesFromView(node)}, nil + }) +} + +// lookupNode resolves a device id to its NodeView, mapping a malformed or +// unknown id to 404 (the Tailscale SDK keys IsNotFound off the status code). +func lookupNode(b Backend, rawID string) (types.NodeView, error) { + nodeID, err := parseNodeID(rawID) + if err != nil { + return types.NodeView{}, err + } + + node, ok := b.State.GetNodeByID(nodeID) + if !ok { + return types.NodeView{}, huma.Error404NotFound("device not found") + } + + return node, nil +} + +// parseRoutes parses the enabled-route strings, expanding an exit route to both +// families (else the node is not annotated as an exit node), then sorts/dedups. +func parseRoutes(routes []string) ([]netip.Prefix, error) { + var approved []netip.Prefix + + for _, route := range routes { + prefix, err := netip.ParsePrefix(route) + if err != nil { + return nil, huma.Error400BadRequest("parsing route", err) + } + + if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() { + approved = append(approved, tsaddr.AllIPv4(), tsaddr.AllIPv6()) + } else { + approved = append(approved, prefix) + } + } + + slices.SortFunc(approved, netip.Prefix.Compare) + + return slices.Compact(approved), nil +} + +// deviceFromView maps a Headscale node onto the Tailscale Device, reading +// through the [types.NodeView] accessors. allFields gates the route slices, +// which Tailscale only returns for fields=all. +func deviceFromView(view types.NodeView, allFields bool) Device { + id := view.StringID() + + d := Device{ + Addresses: emptyIfNil(view.IPsAsString()), + Name: view.GivenName(), + ID: id, + NodeID: id, + Authorized: true, // Headscale has no post-registration de-auth state. + User: deviceUser(view), + Tags: emptyIfNil(view.Tags().AsSlice()), + KeyExpiryDisabled: !view.Expiry().Valid(), + Created: view.CreatedAt(), + Hostname: view.Hostname(), + IsEphemeral: view.IsEphemeral(), + MachineKey: view.MachineKey().String(), + NodeKey: view.NodeKey().String(), + OS: hostinfoOS(view), + ClientVersion: hostinfoVersion(view), + } + + if view.Expiry().Valid() { + exp := view.Expiry().Get() + d.Expires = &exp + } + + // LastSeen is reported only when the device is offline, matching tailcfg. + if view.LastSeen().Valid() && view.IsOnline().Valid() && !view.IsOnline().Get() { + ls := view.LastSeen().Get() + d.LastSeen = &ls + } + + if allFields { + d.AdvertisedRoutes = emptyIfNil(util.PrefixesToString(view.AnnouncedRoutes())) + d.EnabledRoutes = emptyIfNil(util.PrefixesToString(view.ApprovedRoutes().AsSlice())) + } + + return d +} + +func routesFromView(view types.NodeView) DeviceRoutes { + return DeviceRoutes{ + Advertised: emptyIfNil(util.PrefixesToString(view.AnnouncedRoutes())), + Enabled: emptyIfNil(util.PrefixesToString(view.ApprovedRoutes().AsSlice())), + } +} + +// deviceUser is the owning login. [types.NodeView.Owner] already resolves the +// tags-as-identity rule: tagged nodes present the special TaggedDevices user, +// user-owned nodes present their login, orphaned nodes present nothing. +func deviceUser(view types.NodeView) string { + owner := view.Owner() + if owner.Valid() { + return owner.Username() + } + + return "" +} + +// hostinfoOS / hostinfoVersion read Hostinfo fields, returning "" when the node +// has not reported Hostinfo yet. +func hostinfoOS(view types.NodeView) string { + if hi := view.Hostinfo(); hi.Valid() { + return hi.OS() + } + + return "" +} + +func hostinfoVersion(view types.NodeView) string { + if hi := view.Hostinfo(); hi.Valid() { + return hi.IPNVersion() + } + + return "" +} diff --git a/hscontrol/api/v2/errors.go b/hscontrol/api/v2/errors.go new file mode 100644 index 000000000..cf5110e2f --- /dev/null +++ b/hscontrol/api/v2/errors.go @@ -0,0 +1,84 @@ +package apiv2 + +import ( + "errors" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/db" + "github.com/juanfont/headscale/hscontrol/state" + "gorm.io/gorm" +) + +// apiError is the Tailscale API error body. The official Tailscale Go client +// (and therefore the Terraform provider and tscli built on it) decodes 4xx/5xx +// responses into this shape. Huma's default RFC 9457 problem+json would reach +// them with an empty message, so tailscaleErrorTransformer rewrites every v2 +// error into this shape. The HTTP status is read from the response code, not +// from the body's status field. +type apiError struct { + Message string `json:"message"` + Data []apiErrorData `json:"data,omitempty"` + Status int `json:"status"` +} + +type apiErrorData struct { + User string `json:"user,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +// tailscaleErrorTransformer rewrites Huma's RFC 9457 error model into the +// Tailscale error shape. It is registered on the v2 API config only, so the +// headscale-native v1 API keeps emitting problem+json. Non-error bodies pass +// through untouched. +func tailscaleErrorTransformer(_ huma.Context, _ string, v any) (any, error) { + em, ok := v.(*huma.ErrorModel) + if !ok { + return v, nil + } + + message := em.Detail + if message == "" { + message = em.Title + } + + out := apiError{Message: message, Status: em.Status} + + if len(em.Errors) > 0 { + details := make([]string, 0, len(em.Errors)) + for _, d := range em.Errors { + details = append(details, d.Error()) + } + + out.Data = []apiErrorData{{Errors: details}} + } + + return out, nil +} + +// mapError translates a state/db-layer error into a Huma HTTP error +// (not-found→404, invalid input→400, everything else→500). The transformer +// then reshapes it into the Tailscale body. +func mapError(msg string, err error) error { + if err == nil { + return nil + } + + switch { + case errors.Is(err, gorm.ErrRecordNotFound), + errors.Is(err, db.ErrPreAuthKeyNotFound), + errors.Is(err, db.ErrUserNotFound), + errors.Is(err, state.ErrNodeNotFound): + return huma.Error404NotFound(msg, err) + + case errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned), + errors.Is(err, db.ErrPreAuthKeyACLTagInvalid), + errors.Is(err, state.ErrGivenNameInvalid), + errors.Is(err, state.ErrGivenNameTaken), + errors.Is(err, state.ErrNodeNameNotUnique), + errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted): + return huma.Error400BadRequest(msg, err) + + default: + return huma.Error500InternalServerError(msg, err) + } +} diff --git a/hscontrol/api/v2/guard_test.go b/hscontrol/api/v2/guard_test.go new file mode 100644 index 000000000..7d52e0c79 --- /dev/null +++ b/hscontrol/api/v2/guard_test.go @@ -0,0 +1,58 @@ +package apiv2 + +import ( + "testing" + + "github.com/danielgtaylor/huma/v2" + "github.com/go-chi/chi/v5" + "github.com/juanfont/headscale/hscontrol/scope" +) + +// selfEnforcedKeyOps are the authenticated operations that intentionally declare +// NO static scope because they multiplex on keyType and authorize inside the +// handler via requireKeyScope (see keys.go). Every other authenticated operation +// must declare a scope. +var selfEnforcedKeyOps = map[string]bool{ + "POST /api/v2/tailnet/{tailnet}/keys": true, + "GET /api/v2/tailnet/{tailnet}/keys": true, + "GET /api/v2/tailnet/{tailnet}/keys/{keyId}": true, + "DELETE /api/v2/tailnet/{tailnet}/keys/{keyId}": true, +} + +// TestEveryAuthenticatedOperationDeclaresScope is the structural guarantee that no +// v2 operation ships unprotected: any operation that requires authentication +// (non-empty Security) must either declare a required scope via requireScope, or +// be one of the keyType-multiplexed keys operations that self-enforce. A new +// operation added without scope protection fails this test. +func TestEveryAuthenticatedOperationDeclaresScope(t *testing.T) { + api := NewAPI(chi.NewMux(), Backend{}) + + for path, item := range api.OpenAPI().Paths { + for method, op := range humaOperations(item) { + if op == nil || len(op.Security) == 0 { + continue // unregistered method or a public operation + } + + key := method + " " + path + if selfEnforcedKeyOps[key] { + continue + } + + if _, ok := op.Metadata[scopeMetaKey].(scope.Scope); !ok { + t.Errorf("operation %q is authenticated but declares no required scope; "+ + "wrap it in requireScope, or add it to selfEnforcedKeyOps if it "+ + "authorizes inside the handler", key) + } + } + } +} + +func humaOperations(item *huma.PathItem) map[string]*huma.Operation { + return map[string]*huma.Operation{ + "GET": item.Get, + "POST": item.Post, + "PUT": item.Put, + "DELETE": item.Delete, + "PATCH": item.Patch, + } +} diff --git a/hscontrol/api/v2/helpers.go b/hscontrol/api/v2/helpers.go new file mode 100644 index 000000000..aad0392d0 --- /dev/null +++ b/hscontrol/api/v2/helpers.go @@ -0,0 +1,69 @@ +package apiv2 + +import ( + "math" + "strconv" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" +) + +// emptyIfNil ensures a slice serializes as [] rather than null, which the +// Tailscale clients expect for empty list fields. +func emptyIfNil(s []string) []string { + if s == nil { + return []string{} + } + + return s +} + +// parseID parses a decimal entity id from a path segment. A non-numeric id is +// simply an unknown entity, surfaced as a 404 naming subject (e.g. "auth key"), +// so the Tailscale SDK's IsNotFound behaves. +func parseID(rawID, subject string) (uint64, error) { + id, err := strconv.ParseUint(rawID, util.Base10, util.BitSize64) + if err != nil { + return 0, huma.Error404NotFound(subject + " not found") + } + + return id, nil +} + +// parseNodeID parses a device id path segment into a [types.NodeID]. +func parseNodeID(rawID string) (types.NodeID, error) { + id, err := parseID(rawID, "device") + if err != nil { + return 0, err + } + + return types.NodeID(id), nil +} + +// timeOrZero dereferences a time pointer, returning the zero time for nil. +func timeOrZero(t *time.Time) time.Time { + if t == nil { + return time.Time{} + } + + return *t +} + +// expirySeconds reports the lifetime between created and expires in whole +// seconds, the unit the Tailscale spec documents for the response field. The +// lifetime is rounded because the stored expiration is stamped a hair before +// CreatedAt, so an 86400s request would otherwise read back as 86399. +func expirySeconds(created, expires *time.Time) int64 { + if created == nil || expires == nil { + return 0 + } + + secs := int64(math.Round(expires.Sub(*created).Seconds())) + if secs < 0 { + return 0 + } + + return secs +} diff --git a/hscontrol/api/v2/keys.go b/hscontrol/api/v2/keys.go new file mode 100644 index 000000000..552109136 --- /dev/null +++ b/hscontrol/api/v2/keys.go @@ -0,0 +1,593 @@ +package apiv2 + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" +) + +func init() { + registrations = append(registrations, registerKeys) +} + +// defaultExpiry is the auth-key lifetime when the request omits expirySeconds: +// 90 days, matching Tailscale and the Terraform provider default. +const defaultExpiry = 90 * 24 * time.Hour + +const ( + // keyTypeAuth is a machine auth key (Headscale pre-auth key); the default. + keyTypeAuth = "auth" + // keyTypeClient is an OAuth client (client-credentials). Multiplexed onto the + // keys resource exactly as Tailscale does. + keyTypeClient = "client" +) + +// KeyCapabilities maps a resource to the actions a key permits. Headscale +// populates only devices.create (auth keys); the named types (vs Tailscale's +// anonymous nesting) give Huma stable schema names. +type KeyCapabilities struct { + Devices KeyDeviceCapabilities `json:"devices"` +} + +type KeyDeviceCapabilities struct { + Create KeyDeviceCreateCapabilities `json:"create"` +} + +type KeyDeviceCreateCapabilities struct { + Reusable bool `json:"reusable"` + Ephemeral bool `json:"ephemeral"` + Preauthorized bool `json:"preauthorized"` + // Tags is not nullable:"false": the Tailscale clients send "tags":null for + // an untagged key, which must be accepted. The response always emits [] via + // emptyIfNil. + Tags []string `json:"tags"` +} + +// CreateKeyRequest is the POST body. It is multiplexed by keyType: "auth" +// (default) creates a machine auth key from Capabilities; "client" creates an +// OAuth client from the top-level Scopes and Tags. expirySeconds is plain +// seconds (unlike the response, see Key). +type CreateKeyRequest struct { + KeyType string `doc:"Key kind: \"auth\" (default) or \"client\" (OAuth client)." json:"keyType,omitempty"` + // Capabilities is optional: an auth key carries device-create capabilities, + // but an OAuth client (keyType:"client") has none and the Tailscale clients + // omit the field entirely, so it must not be required. + Capabilities *KeyCapabilities `json:"capabilities,omitempty"` + ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days. Auth keys only." json:"expirySeconds,omitempty"` + Description string `json:"description,omitempty" maxLength:"50"` + // Scopes and Tags are top-level and apply only to keyType "client" (an OAuth + // client). Auth-key tags live under Capabilities.Devices.Create.Tags. + Scopes []string `doc:"OAuth scopes granted to the client. keyType=client only." json:"scopes,omitempty"` + Tags []string `doc:"Tags the client may assign. keyType=client only." json:"tags,omitempty"` +} + +// Key is the Tailscale key response, shared by auth keys and OAuth clients. +// expirySeconds is emitted in seconds to match the Tailscale spec; the secret +// key is present only at creation. +type Key struct { + ID string `json:"id"` + KeyType string `json:"keyType"` + Key string `json:"key,omitempty"` + Description string `json:"description,omitempty"` + ExpirySeconds int64 `json:"expirySeconds,omitempty"` + Created time.Time `json:"created"` + Expires *time.Time `json:"expires,omitempty"` + Revoked *time.Time `json:"revoked,omitempty"` + Invalid bool `json:"invalid"` + Capabilities KeyCapabilities `json:"capabilities"` + Scopes []string `json:"scopes,omitempty"` + Tags []string `json:"tags,omitempty"` + UserID string `json:"userId,omitempty"` +} + +type ( + createKeyInput struct { + Tailnet string `doc:"Tailnet; must be \"-\" (the single Headscale tailnet)." path:"tailnet"` + Body CreateKeyRequest + } + + listKeysInput struct { + Tailnet string `path:"tailnet"` + All bool `doc:"Accepted for compatibility; Headscale returns all keys." query:"all"` + } + + keyByIDInput struct { + Tailnet string `path:"tailnet"` + KeyID string `path:"keyId"` + } + + keyOutput struct { + Body Key + } + + listKeysOutput struct { + Body struct { + Keys []Key `json:"keys" nullable:"false"` + } + } + + deleteKeyOutput struct { + Body struct{} + } +) + +// The keys resource is multiplexed by keyType (auth key vs OAuth client), so the +// scope an operation requires depends on the request rather than being fixed. +// requireScope (which the middleware enforces statically) is therefore omitted +// here; each handler authorizes via requireKeyScope once the kind is known. +func registerKeys(api huma.API, b Backend) { + keysTags := []string{"Keys", "Tailscale compat"} + + huma.Register(api, huma.Operation{ + OperationID: "createKey", + Method: http.MethodPost, + Path: "/api/v2/tailnet/{tailnet}/keys", + Summary: "Create an auth key or OAuth client", + Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).", + Tags: keysTags, + Security: security, + Errors: []int{ + http.StatusBadRequest, + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + }, + }, func(ctx context.Context, in *createKeyInput) (*keyOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + if in.Body.KeyType == keyTypeClient { + return createOAuthClient(ctx, b, in.Body) + } + + return createAuthKey(ctx, b, in.Body) + }) + + huma.Register(api, huma.Operation{ + OperationID: "listKeys", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/keys", + Summary: "List auth keys and OAuth clients", + Description: "A token sees the kinds it can read: `auth_keys:read` for auth keys, `oauth_keys:read` for OAuth clients (an admin API key sees all).", + Tags: keysTags, + Security: security, + Errors: []int{ + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + }, + }, func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + scopes, isOAuth := principalScopes(ctx) + + out := &listKeysOutput{} + out.Body.Keys = []Key{} + + // A token sees the key kinds it has read scope for; an admin key sees all. + if !isOAuth || scope.Grants(scope.Parse(scopes), scope.AuthKeysRead) { + keys, err := b.State.ListPreAuthKeys() + if err != nil { + return nil, huma.Error500InternalServerError("listing auth keys", err) + } + + for i := range keys { + out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i])) + } + } + + if !isOAuth || scope.Grants(scope.Parse(scopes), scope.OAuthKeysRead) { + clients, err := b.State.ListOAuthClients() + if err != nil { + return nil, huma.Error500InternalServerError("listing oauth clients", err) + } + + for i := range clients { + out.Body.Keys = append(out.Body.Keys, oauthClientToKey(&clients[i], "")) + } + } + + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "getKey", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}", + Summary: "Get an auth key or OAuth client", + Description: "Requires `auth_keys:read` for an auth key, or `oauth_keys:read` for an OAuth client (an admin API key is all-access).", + Tags: keysTags, + Security: security, + Errors: []int{ + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + }, + }, func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + // An OAuth client id is a hex string distinct from a numeric auth-key id, + // so a client lookup that hits is authoritative; otherwise fall through to + // the auth-key path. The lookup is gated on the caller actually holding + // oauth_keys:read so a token without it cannot tell a real client id (403) + // from an unknown key (404) — i.e. no client-existence oracle. + if requireKeyScope(ctx, scope.OAuthKeysRead) == nil { + client, err := b.State.GetOAuthClientByClientID(in.KeyID) + if err == nil { + return &keyOutput{Body: oauthClientToKey(client, "")}, nil + } + } + + err = requireKeyScope(ctx, scope.AuthKeysRead) + if err != nil { + return nil, err + } + + key, err := findKeyByID(b, in.KeyID) + if err != nil { + return nil, err + } + + return &keyOutput{Body: keyFromStored(key)}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "deleteKey", + Method: http.MethodDelete, + Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}", + Summary: "Delete an auth key or OAuth client", + Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).", + Tags: keysTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{ + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + }, + }, func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + // Gated on oauth_keys (write) for the same no-existence-oracle reason as + // getKey: a token without it must not learn that an id is an OAuth client. + if requireKeyScope(ctx, scope.OAuthKeys) == nil { + _, err = b.State.GetOAuthClientByClientID(in.KeyID) + if err == nil { + err = b.State.RevokeOAuthClient(in.KeyID) + if err != nil { + return nil, mapError("deleting oauth client", err) + } + + return &deleteKeyOutput{}, nil + } + } + + err = requireKeyScope(ctx, scope.AuthKeys) + if err != nil { + return nil, err + } + + id, err := parseID(in.KeyID, "auth key") + if err != nil { + return nil, err + } + + // Tailscale's DELETE revokes the key but keeps it retrievable (invalid) + // rather than destroying it; the collector reaps it after the retention + // window. + err = b.State.RevokePreAuthKey(id) + if err != nil { + return nil, mapError("revoking auth key", err) + } + + return &deleteKeyOutput{}, nil + }) +} + +// requireKeyScope authorizes a keys operation for an OAuth access token. An admin +// API key carries no OAuth scopes and is all-access, so it always passes. +func requireKeyScope(ctx context.Context, need scope.Scope) error { + scopes, isOAuth := principalScopes(ctx) + if !isOAuth { + return nil + } + + if !scope.Grants(scope.Parse(scopes), need) { + return huma.Error403Forbidden("token is missing the required scope " + string(need)) + } + + return nil +} + +// createAuthKey creates a machine auth key (pre-auth key). Ownership: tags -> a +// tagged key; no tags -> owned by the API key's user. An OAuth access token must +// mint tagged keys, and each tag must be within the token's grant. +func createAuthKey(ctx context.Context, b Backend, body CreateKeyRequest) (*keyOutput, error) { + err := requireKeyScope(ctx, scope.AuthKeys) + if err != nil { + return nil, err + } + + var create KeyDeviceCreateCapabilities + if body.Capabilities != nil { + create = body.Capabilities.Devices.Create + } + + tokenTags, isOAuth := principalTags(ctx) + + var userID *types.UserID + + switch { + case len(create.Tags) > 0: + // A key minted by an OAuth token may only carry tags within the token's + // grant (held directly, or owned by a held tag) and defined in policy, + // matching SetNodeTags. An admin key keeps the historical behaviour of + // validating only tag syntax (db.validateACLTags). + if isOAuth { + for _, tag := range create.Tags { + if !b.State.TagExists(tag) { + return nil, huma.Error400BadRequest("tag " + tag + " is not defined in policy") + } + + if !b.State.TagOwnedByTags(tag, tokenTags) { + return nil, huma.Error403Forbidden( + "token may not assign tag " + tag, + ) + } + } + } + + case isOAuth: + // OAuth-minted keys are tailnet/tag-owned; an untagged (user-owned) key + // cannot be created from a token. + return nil, huma.Error403Forbidden("an OAuth client must create tagged auth keys") + + default: + uid, ok := ownerUser(ctx) + if !ok { + return nil, huma.Error400BadRequest( + "an auth key without tags must be created with a user-owned API key", + ) + } + + userID = &uid + } + + expiration := time.Now().Add(expiryDuration(body.ExpirySeconds)) + + pak, err := b.State.CreatePreAuthKey( + userID, + create.Reusable, + create.Ephemeral, + &expiration, + create.Tags, + ) + if err != nil { + return nil, mapError("creating auth key", err) + } + + if body.Description != "" { + err := b.State.SetPreAuthKeyDescription(pak.ID, body.Description) + if err != nil { + return nil, huma.Error500InternalServerError("setting auth key description", err) + } + } + + return &keyOutput{Body: keyFromNew(pak, body)}, nil +} + +// createOAuthClient creates an OAuth client (keyType:"client"). The client secret +// is returned once, here. +func createOAuthClient(ctx context.Context, b Backend, body CreateKeyRequest) (*keyOutput, error) { + err := requireKeyScope(ctx, scope.OAuthKeys) + if err != nil { + return nil, err + } + + if len(body.Scopes) == 0 { + return nil, huma.Error400BadRequest("an OAuth client must declare at least one scope") + } + + // Tailscale: tags are mandatory when the scopes include devices:core or + // auth_keys, because such a client mints tagged, tailnet-owned credentials. + if scope.RequiresTags(scope.Parse(body.Scopes)) && len(body.Tags) == 0 { + return nil, huma.Error400BadRequest( + "tags are required when scopes include devices:core or auth_keys", + ) + } + + // A client created by an OAuth token may not be granted authority the token + // lacks: its scopes must each be within the token's grant, and its tags within + // the token's tags and defined in policy (matching SetNodeTags). Otherwise an + // oauth_keys token could mint an all-access client and escalate. An admin API + // key (not an OAuth token) is unrestricted and keeps the historical tag + // behaviour (syntax-only validation). + if tokenScopes, isOAuth := principalScopes(ctx); isOAuth { + for _, s := range body.Scopes { + if !scope.Grants(scope.Parse(tokenScopes), scope.Scope(s)) { + return nil, huma.Error403Forbidden( + "client may not be granted scope " + s + " beyond the creating token", + ) + } + } + + tokenTags, _ := principalTags(ctx) + + for _, tag := range body.Tags { + if !b.State.TagExists(tag) { + return nil, huma.Error400BadRequest("tag " + tag + " is not defined in policy") + } + + if !b.State.TagOwnedByTags(tag, tokenTags) { + return nil, huma.Error403Forbidden( + "client may not be granted tag " + tag + " beyond the creating token", + ) + } + } + } + + var creator *uint + + if uid, ok := ownerUser(ctx); ok { + u := uint(uid) + creator = &u + } + + secret, client, err := b.State.CreateOAuthClient(body.Scopes, body.Tags, body.Description, creator) + if err != nil { + return nil, mapError("creating oauth client", err) + } + + return &keyOutput{Body: oauthClientToKey(client, secret)}, nil +} + +// findKeyByID looks up a stored pre-auth key by its (stringified) id with a +// direct by-id query; an unknown id surfaces as gorm.ErrRecordNotFound, which +// mapError turns into a 404. +func findKeyByID(b Backend, rawID string) (*types.PreAuthKey, error) { + id, err := parseID(rawID, "auth key") + if err != nil { + return nil, err + } + + pak, err := b.State.GetPreAuthKeyByID(id) + if err != nil { + return nil, mapError("looking up auth key", err) + } + + return pak, nil +} + +// keyFromNew builds the create response from the freshly created key. The +// plaintext secret is returned only here. preauthorized is reported true to +// match keyFromStored: Headscale always authorizes pre-auth-key nodes, so the +// create and read paths must agree or the Terraform provider sees a diff. +func keyFromNew(pak *types.PreAuthKeyNew, req CreateKeyRequest) Key { + var create KeyDeviceCreateCapabilities + if req.Capabilities != nil { + create = req.Capabilities.Devices.Create + } + + key := Key{ + ID: pak.StringID(), + KeyType: keyTypeAuth, + Key: pak.Key, + Description: req.Description, + Created: timeOrZero(pak.CreatedAt), + Capabilities: capabilities( + create.Reusable, + create.Ephemeral, + true, + pak.Tags, + ), + Tags: pak.Tags, + } + + if pak.Expiration != nil { + key.Expires = pak.Expiration + key.ExpirySeconds = expirySeconds(pak.CreatedAt, pak.Expiration) + } + + // A tagged key presents no owner; a user-owned key reports its user id. + if len(pak.Tags) == 0 && pak.User != nil { + key.UserID = pak.User.StringID() + } + + return key +} + +// keyFromStored builds the get/list response from a stored key. The secret is +// never returned here. preauthorized is reported true: Headscale has no separate +// device-approval step, so every pre-auth key authorizes its nodes. Reporting it +// stably keeps the Terraform provider from seeing a forced-replacement diff. +func keyFromStored(pak *types.PreAuthKey) Key { + key := Key{ + ID: pak.StringID(), + KeyType: keyTypeAuth, + Description: pak.Description, + Created: timeOrZero(pak.CreatedAt), + Invalid: pak.Validate() != nil, + Capabilities: capabilities(pak.Reusable, pak.Ephemeral, true, pak.Tags), + Tags: pak.Tags, + } + + if pak.Expiration != nil { + key.Expires = pak.Expiration + key.ExpirySeconds = expirySeconds(pak.CreatedAt, pak.Expiration) + } + + if pak.Revoked != nil { + key.Revoked = pak.Revoked + } + + if len(pak.Tags) == 0 && pak.User != nil { + key.UserID = pak.User.StringID() + } + + return key +} + +// oauthClientToKey builds the keys response for an OAuth client. secret is the +// plaintext client secret, set only on the create response and empty on get/list +// (the secret is never re-exposed). +func oauthClientToKey(client *types.OAuthClient, secret string) Key { + key := Key{ + ID: client.ClientID, + KeyType: keyTypeClient, + Key: secret, + Description: client.Description, + Created: timeOrZero(client.CreatedAt), + Scopes: emptyIfNil(client.Scopes), + Tags: emptyIfNil(client.Tags), + } + + if client.Revoked != nil { + key.Revoked = client.Revoked + key.Invalid = true + } + + if client.UserID != nil { + key.UserID = strconv.FormatUint(uint64(*client.UserID), util.Base10) + } + + return key +} + +func capabilities( + reusable, ephemeral, preauthorized bool, + tags []string, +) KeyCapabilities { + return KeyCapabilities{ + Devices: KeyDeviceCapabilities{Create: KeyDeviceCreateCapabilities{ + Reusable: reusable, + Ephemeral: ephemeral, + Preauthorized: preauthorized, + Tags: emptyIfNil(tags), + }}, + } +} + +func expiryDuration(seconds int64) time.Duration { + if seconds <= 0 { + return defaultExpiry + } + + return time.Duration(seconds) * time.Second +} diff --git a/hscontrol/api/v2/oauth.go b/hscontrol/api/v2/oauth.go new file mode 100644 index 000000000..330c94f96 --- /dev/null +++ b/hscontrol/api/v2/oauth.go @@ -0,0 +1,178 @@ +package apiv2 + +import ( + "encoding/json" + "net/http" + "slices" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/juanfont/headscale/hscontrol/scope" + "github.com/juanfont/headscale/hscontrol/types" +) + +// accessTokenTTL is the fixed lifetime of a minted access token, matching +// Tailscale's non-configurable one hour. +const accessTokenTTL = time.Hour + +// registerOAuthToken mounts POST /api/v2/oauth/token on the router. It is a plain +// handler, not a Huma operation: it consumes application/x-www-form-urlencoded +// and emits RFC 6749 OAuth2 error bodies ({"error","error_description"}), neither +// of which fits Huma's JSON-in / Tailscale-error-out machinery. +// ponytail: one bespoke OAuth endpoint isn't worth bending Huma around. +func registerOAuthToken(router chi.Router, b Backend) { + router.Post("/api/v2/oauth/token", oauthTokenHandler(b)) +} + +// tokenResponse is the OAuth 2.0 client-credentials success body. +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope,omitempty"` +} + +// oauthTokenHandler implements the client-credentials grant (RFC 6749 §4.4): +// authenticate the client, optionally narrow the granted scopes/tags, and mint a +// short-lived bearer token. +func oauthTokenHandler(b Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "could not parse request body") + + return + } + + // grant_type defaults to client_credentials: Tailscale's documented curl + // omits it, and the x/oauth2 client always sends it. + if gt := r.PostForm.Get("grant_type"); gt != "" && gt != "client_credentials" { + writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type", + "only the client_credentials grant is supported") + + return + } + + // Credentials may arrive in the body or as HTTP Basic (the x/oauth2 + // auto-detect probes Basic first). The secret embeds the client id, so a + // separate client_id is not required. + secret := r.PostForm.Get("client_secret") + if secret == "" { + if _, pass, ok := r.BasicAuth(); ok { + secret = pass + } + } + + if secret == "" { + writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "missing client credentials") + + return + } + + client, err := b.State.AuthenticateOAuthClient(secret) + if err != nil { + writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "invalid client credentials") + + return + } + + // Optional space-delimited scope/tags narrow the token to a subset of the + // client's grant. + scopes, badScope, ok := narrowScopes(client.Scopes, strings.Fields(r.PostForm.Get("scope"))) + if !ok { + writeOAuthError(w, http.StatusBadRequest, "invalid_scope", + "scope "+badScope+" is not granted to this client") + + return + } + + tags, badTag, ok := narrowTags(client, strings.Fields(r.PostForm.Get("tags"))) + if !ok { + writeOAuthError(w, http.StatusBadRequest, "invalid_target", + "tag "+badTag+" is not granted to this client") + + return + } + + expiry := time.Now().Add(accessTokenTTL) + + tokenStr, _, err := b.State.MintAccessToken(client.ClientID, scopes, tags, &expiry) + if err != nil { + writeOAuthError(w, http.StatusInternalServerError, "server_error", "could not mint access token") + + return + } + + writeJSON(w, http.StatusOK, tokenResponse{ + AccessToken: tokenStr, + TokenType: "Bearer", + ExpiresIn: int(accessTokenTTL.Seconds()), + Scope: strings.Join(scopes, " "), + }) + } +} + +// narrowScopes returns the requested scopes if each is granted by the client (an +// empty request means "the client's full grant"), otherwise the offending scope +// and false. A client holding a broad scope (e.g. "all") may mint a token limited +// to a narrower one. +func narrowScopes(granted, requested []string) ([]string, string, bool) { + if len(requested) == 0 { + return granted, "", true + } + + for _, req := range requested { + if !scope.Grants(scope.Parse(granted), scope.Scope(req)) { + return nil, req, false + } + } + + return requested, "", true +} + +// narrowTags returns the requested tags if each is within the client's grant (an +// empty request means "the client's full tag set"), otherwise the offending tag +// and false. A client with the "all" scope may assign any tag, matching Tailscale. +func narrowTags(client *types.OAuthClient, requested []string) ([]string, string, bool) { + if len(requested) == 0 { + return client.Tags, "", true + } + + allScope := slices.Contains(client.Scopes, string(scope.All)) + + for _, req := range requested { + // Reject malformed tags at the trust boundary. A client's own tags are + // validated at creation, so this only matters for an "all"-scope client, + // whose tags would otherwise skip the membership check below and flow + // unvalidated into auth-key creation. + if !strings.HasPrefix(req, "tag:") { + return nil, req, false + } + + if !allScope && !slices.Contains(client.Tags, req) { + return nil, req, false + } + } + + return requested, "", true +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + // Token responses carry bearer credentials; RFC 6749 §5.1 forbids caching + // them. writeJSON serves only the token endpoint, so set it unconditionally. + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) //nolint:errchkjson // best-effort response write of a known-safe value +} + +// writeOAuthError emits an RFC 6749 §5.2 error body, which the x/oauth2 client +// parses into its RetrieveError. +func writeOAuthError(w http.ResponseWriter, status int, code, desc string) { + writeJSON(w, status, map[string]string{ + "error": code, + "error_description": desc, + }) +} diff --git a/hscontrol/api/v2/oauth_test.go b/hscontrol/api/v2/oauth_test.go new file mode 100644 index 000000000..1abbacf87 --- /dev/null +++ b/hscontrol/api/v2/oauth_test.go @@ -0,0 +1,31 @@ +package apiv2 + +import ( + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" +) + +// TestNarrowTagsRejectsMalformed asserts the token endpoint validates tag +// format at the trust boundary. An "all"-scope client may assign any tag, but a +// malformed tag (missing the "tag:" prefix) must still be rejected rather than +// flowing unvalidated into auth-key creation. +func TestNarrowTagsRejectsMalformed(t *testing.T) { + all := &types.OAuthClient{Scopes: []string{"all"}} + + _, bad, ok := narrowTags(all, []string{"not-a-tag"}) + assert.False(t, ok, "malformed tag must be rejected even with all scope") + assert.Equal(t, "not-a-tag", bad) + + got, _, ok := narrowTags(all, []string{"tag:anything"}) + assert.True(t, ok, "all scope may assign any well-formed tag") + assert.Equal(t, []string{"tag:anything"}, got) + + // A scoped client may only assign its own well-formed tags. + scoped := &types.OAuthClient{Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"}} + + _, bad, ok = narrowTags(scoped, []string{"tag:other"}) + assert.False(t, ok) + assert.Equal(t, "tag:other", bad) +} diff --git a/hscontrol/api/v2/settings.go b/hscontrol/api/v2/settings.go new file mode 100644 index 000000000..262ec554c --- /dev/null +++ b/hscontrol/api/v2/settings.go @@ -0,0 +1,100 @@ +package apiv2 + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" + "github.com/juanfont/headscale/hscontrol/types" +) + +func init() { + registrations = append(registrations, registerSettings) +} + +// TailnetSettings is the Tailscale tailnet-settings response. Headscale's config +// is file-based and mostly not runtime-mutable, so only a few fields carry a +// real value; the rest report the default "off". +type TailnetSettings struct { + ACLsExternallyManagedOn bool `json:"aclsExternallyManagedOn"` + ACLsExternalLink string `json:"aclsExternalLink"` + + DevicesApprovalOn bool `json:"devicesApprovalOn"` + DevicesAutoUpdatesOn bool `json:"devicesAutoUpdatesOn"` + DevicesKeyDurationDays int `json:"devicesKeyDurationDays"` + + UsersApprovalOn bool `json:"usersApprovalOn"` + UsersRoleAllowedToJoinExternalTailnets string `json:"usersRoleAllowedToJoinExternalTailnets"` + + NetworkFlowLoggingOn bool `json:"networkFlowLoggingOn"` + RegionalRoutingOn bool `json:"regionalRoutingOn"` + PostureIdentityCollectionOn bool `json:"postureIdentityCollectionOn"` + HTTPSEnabled bool `json:"httpsEnabled"` +} + +type ( + getSettingsInput struct { + Tailnet string `path:"tailnet"` + } + settingsOutput struct { + Body TailnetSettings + } + patchSettingsInput struct { + Tailnet string `path:"tailnet"` + // Accepted and ignored; updating settings is not supported. + Body json.RawMessage + } +) + +func registerSettings(api huma.API, b Backend) { + settingsTags := []string{"TailnetSettings", "Tailscale compat"} + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getTailnetSettings", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/settings", + Summary: "Get tailnet settings", + Tags: settingsTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, scope.FeatureSettingsRead), func(ctx context.Context, in *getSettingsInput) (*settingsOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + cfg := b.Cfg + + return &settingsOutput{Body: TailnetSettings{ + // File-mode policy is genuinely externally managed (read-only via API). + ACLsExternallyManagedOn: cfg.Policy.Mode == types.PolicyModeFile, + DevicesKeyDurationDays: int(cfg.Node.Expiry / (24 * time.Hour)), + HTTPSEnabled: cfg.TLS.CertPath != "" || cfg.TLS.LetsEncrypt.Hostname != "", + UsersRoleAllowedToJoinExternalTailnets: "none", + }}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "updateTailnetSettings", + Method: http.MethodPatch, + Path: "/api/v2/tailnet/{tailnet}/settings", + Summary: "Update tailnet settings", + Tags: settingsTags, + Security: security, + // The body is accepted but ignored; skip validation. + SkipValidateBody: true, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented}, + }, scope.FeatureSettings), func(ctx context.Context, in *patchSettingsInput) (*settingsOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + return nil, huma.Error501NotImplemented( + "updating tailnet settings is not supported by Headscale", + ) + }) +} diff --git a/hscontrol/api/v2/users.go b/hscontrol/api/v2/users.go new file mode 100644 index 000000000..2890f432e --- /dev/null +++ b/hscontrol/api/v2/users.go @@ -0,0 +1,176 @@ +package apiv2 + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" + "github.com/juanfont/headscale/hscontrol/types" +) + +func init() { + registrations = append(registrations, registerUsers) +} + +// Headscale models none of type/role/status and has a single tailnet, so these +// fields are fixed strings. Every account is an active member. +const ( + userTypeMember = "member" + userRoleMember = "member" + userStatusActive = "active" + singleTailnetID = "1" + + // tagTailscaleCompat marks operations ported from the Tailscale API. + tagTailscaleCompat = "Tailscale compat" +) + +// User is the Tailscale user response. Identity fields map from the Headscale +// user; type/role/status/tailnetId are constants (see above); the device fields +// are aggregated from the user's nodes. +type User struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + LoginName string `json:"loginName"` + ProfilePicURL string `json:"profilePicUrl"` + TailnetID string `json:"tailnetId"` + Created time.Time `json:"created"` + Type string `json:"type"` + Role string `json:"role"` + Status string `json:"status"` + DeviceCount int `json:"deviceCount"` + LastSeen time.Time `json:"lastSeen"` + CurrentlyConnected bool `json:"currentlyConnected"` +} + +type ( + userByIDInput struct { + UserID string `doc:"User id (the decimal user id)." path:"id"` + } + listUsersInput struct { + Tailnet string `doc:"Tailnet; must be \"-\" (the single Headscale tailnet)." path:"tailnet"` + Type string `doc:"Filter by user type; Headscale users are all \"member\"." query:"type"` + Role string `doc:"Filter by user role; Headscale users are all \"member\"." query:"role"` + } + + userOutput struct{ Body User } + listUsersOutput struct { + Body struct { + Users []User `json:"users" nullable:"false"` + } + } +) + +func registerUsers(api huma.API, b Backend) { + usersTags := []string{"Users", tagTailscaleCompat} + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getUser", + Method: http.MethodGet, + Path: "/api/v2/users/{id}", + Summary: "Get a user", + Tags: usersTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, scope.UsersRead), func(ctx context.Context, in *userByIDInput) (*userOutput, error) { + view, err := lookupUser(b, in.UserID) + if err != nil { + return nil, err + } + + return &userOutput{Body: userFromView(b, view)}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "listUsers", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/users", + Summary: "List users", + Tags: usersTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, scope.UsersRead), func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + out := &listUsersOutput{} + out.Body.Users = []User{} + + // Headscale has only "member" users. A filter for any other type/role + // matches nothing, so return the empty envelope. + if !matchesMember(in.Type) || !matchesMember(in.Role) { + return out, nil + } + + users, err := b.State.ListAllUsers() + if err != nil { + return nil, huma.Error500InternalServerError("listing users", err) + } + + out.Body.Users = make([]User, 0, len(users)) + for i := range users { + out.Body.Users = append(out.Body.Users, userFromView(b, users[i].View())) + } + + return out, nil + }) +} + +// lookupUser resolves a user id to its UserView, mapping a malformed or unknown +// id to 404 (the Tailscale SDK keys IsNotFound off the status code), exactly as +// lookupNode does for devices. +func lookupUser(b Backend, rawID string) (types.UserView, error) { + id, err := parseID(rawID, "user") + if err != nil { + return types.UserView{}, err + } + + user, err := b.State.GetUserByID(types.UserID(id)) + if err != nil { + return types.UserView{}, mapError("looking up user", err) + } + + return user.View(), nil +} + +// matchesMember reports whether an optional type/role filter selects Headscale's +// only user kind. An empty value means "no filter". +func matchesMember(filter string) bool { + return filter == "" || filter == userTypeMember +} + +// userFromView maps a Headscale user onto the Tailscale User through the +// UserView accessors. deviceCount, lastSeen, and currentlyConnected are +// aggregated from the user's nodes in the NodeStore. +func userFromView(b Backend, view types.UserView) User { + u := User{ + ID: strconv.FormatUint(uint64(view.ID()), 10), + DisplayName: view.Display(), + LoginName: view.Username(), + ProfilePicURL: view.ProfilePicURL(), + TailnetID: singleTailnetID, + Created: view.CreatedAt(), + Type: userTypeMember, + Role: userRoleMember, + Status: userStatusActive, + } + + nodes := b.State.ListNodesByUser(types.UserID(view.ID())) + u.DeviceCount = nodes.Len() + + for _, node := range nodes.All() { + if node.IsOnline().Valid() && node.IsOnline().Get() { + u.CurrentlyConnected = true + } + + if ls := node.LastSeen(); ls.Valid() && ls.Get().After(u.LastSeen) { + u.LastSeen = ls.Get() + } + } + + return u +} diff --git a/hscontrol/apiv1_apikeys_test.go b/hscontrol/apiv1_apikeys_test.go new file mode 100644 index 000000000..74b4634ae --- /dev/null +++ b/hscontrol/apiv1_apikeys_test.go @@ -0,0 +1,212 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedAPIKey creates a single API key and returns its database ID and the +// masked display prefix the API exposes, so tests can address it by either. +func seedAPIKey(t *testing.T, app *Headscale) (uint64, string) { + t.Helper() + + _, key, err := app.state.CreateAPIKey(nil) + require.NoError(t, err) + + return key.ID, "hskey-api-" + key.Prefix + "-***" +} + +func TestAPIV1ApiKeyCreate(t *testing.T) { + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodPost, "/api/v1/apikey", []byte(`{}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + APIKey string `json:"apiKey"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + // The full secret is returned exactly once, on creation. + assert.NotEmpty(t, got.APIKey) + assert.Contains(t, got.APIKey, "hskey-api-") + }) + + t.Run("creates secret", func(t *testing.T) { + // The secret is random, so it can't be captured in a golden; just assert + // success and that a secret is returned. + humaApp := createTestApp(t) + + hum := callHandler(newHumaTestHandler(humaApp), http.MethodPost, "/api/v1/apikey", []byte(`{}`)) + + assert.Equal(t, http.StatusOK, hum.status) + + var humBody struct { + APIKey string `json:"apiKey"` + } + require.NoError(t, json.Unmarshal(hum.body, &humBody)) + assert.NotEmpty(t, humBody.APIKey) + }) +} + +func TestAPIV1ApiKeyList(t *testing.T) { + t.Run("empty returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodGet, "/api/v1/apikey", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"apiKeys":[]}`, string(res.body)) + }) + + t.Run("empty parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil) + }) + + t.Run("populated parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedAPIKey(t, h.app) + seedAPIKey(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil) + }) + + t.Run("nil timestamps emitted as null parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedAPIKey(t, h.app) + // Nil expiration: lastSeen and expiration are unset and must emit as + // JSON null. + res := h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil) + + var got struct { + APIKeys []map[string]any `json:"apiKeys"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + require.Len(t, got.APIKeys, 1) + assert.Nil(t, got.APIKeys[0]["lastSeen"]) + assert.Nil(t, got.APIKeys[0]["expiration"]) + assert.Contains(t, got.APIKeys[0], "createdAt") + assert.Contains(t, got.APIKeys[0]["prefix"], "hskey-api-") + }) + + t.Run("zero expiration emitted as zero instant parity", func(t *testing.T) { + h := newAPIV1Harness(t) + // Non-nil zero expiration (as gRPC CreateApiKey does for a missing one) + // must render as the zero instant, not null. + var zero time.Time + + _, _, err := h.app.state.CreateAPIKey(&zero) + require.NoError(t, err) + + res := h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil) + + var got struct { + APIKeys []map[string]any `json:"apiKeys"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + require.Len(t, got.APIKeys, 1) + assert.Equal(t, "0001-01-01T00:00:00Z", got.APIKeys[0]["expiration"]) + }) +} + +func TestAPIV1ApiKeyExpire(t *testing.T) { + t.Run("by id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + id, _ := seedAPIKey(t, h.app) + h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", + []byte(`{"id":"`+strconv.FormatUint(id, 10)+`"}`)) + }) + + t.Run("by prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + _, prefix := seedAPIKey(t, h.app) + h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", + []byte(`{"prefix":"`+prefix+`"}`)) + }) + + t.Run("neither id nor prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", []byte(`{}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("both id and prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", + []byte(`{"id":"1","prefix":"abc"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("not found by id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", []byte(`{"id":"999"}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("expires the key", func(t *testing.T) { + h := newAPIV1Harness(t) + id, _ := seedAPIKey(t, h.app) + + res := h.callHuma(http.MethodPost, "/api/v1/apikey/expire", + []byte(`{"id":"`+strconv.FormatUint(id, 10)+`"}`)) + require.Equal(t, http.StatusOK, res.status) + + listRes := h.callHuma(http.MethodGet, "/api/v1/apikey", nil) + + var got struct { + APIKeys []struct { + Expiration *time.Time `json:"expiration"` + } `json:"apiKeys"` + } + require.NoError(t, json.Unmarshal(listRes.body, &got)) + require.Len(t, got.APIKeys, 1) + require.NotNil(t, got.APIKeys[0].Expiration) + assert.False(t, got.APIKeys[0].Expiration.IsZero(), "expiration should be set") + }) +} + +func TestAPIV1ApiKeyDelete(t *testing.T) { + t.Run("by prefix deletes the key", func(t *testing.T) { + h := newAPIV1Harness(t) + _, prefix := seedAPIKey(t, h.app) + + res := h.callHuma(http.MethodDelete, "/api/v1/apikey/"+prefix, nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{}`, string(res.body)) + + listRes := h.callHuma(http.MethodGet, "/api/v1/apikey", nil) + assert.JSONEq(t, `{"apiKeys":[]}`, string(listRes.body)) + }) + + t.Run("path prefix with id query is both -> 400 parity", func(t *testing.T) { + h := newAPIV1Harness(t) + id, prefix := seedAPIKey(t, h.app) + res := h.assertParity(t, http.MethodDelete, + "/api/v1/apikey/"+prefix+"?id="+strconv.FormatUint(id, 10), nil) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("not found by prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/apikey/doesnotexist", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("both id and prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/apikey/abc?id=1", nil) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/apikey/abc?id=notanumber", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} diff --git a/hscontrol/apiv1_auth_test.go b/hscontrol/apiv1_auth_test.go new file mode 100644 index 000000000..1f157f414 --- /dev/null +++ b/hscontrol/apiv1_auth_test.go @@ -0,0 +1,187 @@ +package hscontrol + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" + "tailscale.com/types/key" +) + +// seedAuthRequest stores a pending registration auth cache entry under authID so +// an AuthRegister/Approve/Reject call has a real session to act on. Capturing +// the keys/authID at the call site keeps isolated apps identical. +func seedAuthRequest( + user string, + authID types.AuthID, + machineKey key.MachinePublic, + nodeKey key.NodePublic, + discoKey key.DiscoPublic, + hostname string, +) func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + app.state.CreateUserForTest(user) + + regData := &types.RegistrationData{ + MachineKey: machineKey, + NodeKey: nodeKey, + DiscoKey: discoKey, + Hostname: hostname, + Hostinfo: &tailcfg.Hostinfo{Hostname: hostname}, + } + + app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData)) + } +} + +func TestAPIV1AuthRegister(t *testing.T) { + t.Run("happy path parity", func(t *testing.T) { + // Capture keys/authID once so both isolated apps register the identical + // node and produce byte-equal bodies. + authID := types.MustAuthID() + machineKey := key.NewMachine().Public() + nodeKey := key.NewNode().Public() + discoKey := key.NewDisco().Public() + + seed := seedAuthRequest("alice", authID, machineKey, nodeKey, discoKey, "regnode") + body := fmt.Appendf(nil, `{"user":"alice","authId":%q}`, authID.String()) + + assertParityIsolated(t, seed, http.MethodPost, "/api/v1/auth/register", body) + }) + + t.Run("happy path response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + + authID := types.MustAuthID() + seedAuthRequest( + "alice", authID, + key.NewMachine().Public(), + key.NewNode().Public(), + key.NewDisco().Public(), + "regnode", + )(t, h.app) + + body := fmt.Appendf(nil, `{"user":"alice","authId":%q}`, authID.String()) + res := h.callHuma(http.MethodPost, "/api/v1/auth/register", body) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.Node["id"]) + assert.Equal(t, "REGISTER_METHOD_CLI", got.Node["registerMethod"]) + // EmitUnpopulated parity: an unset expiry (we passed none) is null, an + // unset embedded pre-auth key is null, and repeated fields are [] + // rather than omitted. + assert.Contains(t, got.Node, "expiry") + assert.Nil(t, got.Node["expiry"]) + assert.Contains(t, got.Node, "preAuthKey") + assert.Nil(t, got.Node["preAuthKey"]) + assert.Equal(t, []any{}, got.Node["subnetRoutes"]) + assert.NotNil(t, got.Node["user"]) + }) + + // A malformed auth_id is bad input (400), matching AuthApprove/AuthReject. + t.Run("invalid auth_id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice")(t, h.app) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/register", + []byte(`{"user":"alice","authId":"not-a-valid-auth-id"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("unknown user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + body := fmt.Appendf(nil, `{"user":"ghost","authId":%q}`, types.MustAuthID().String()) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/register", body) + assertStatus(t, res, http.StatusNotFound) + }) + + // Valid auth_id but no cached session: HandleNodeFromAuthPath returns + // ErrNodeNotFoundRegistrationCache, which maps to 404. + t.Run("no pending session parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice")(t, h.app) + + body := fmt.Appendf(nil, `{"user":"alice","authId":%q}`, types.MustAuthID().String()) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/register", body) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1AuthApprove(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + h := newAPIV1Harness(t) + + authID := types.MustAuthID() + authReq := types.NewAuthRequest() + h.app.state.SetAuthCacheEntry(authID, authReq) + + body := fmt.Appendf(nil, `{"authId":%q}`, authID.String()) + res := h.callHuma(http.MethodPost, "/api/v1/auth/approve", body) + + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{}`, string(res.body)) + + verdict := <-authReq.WaitForAuth() + assert.True(t, verdict.Accept(), "approve must finish the session with a passing verdict") + }) + + // Malformed auth_id: AuthApprove returns codes.InvalidArgument → 400. + t.Run("invalid auth_id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/approve", + []byte(`{"authId":"not-a-valid-auth-id"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + // Well-formed auth_id with no pending session: codes.NotFound → 404. + t.Run("no pending session parity", func(t *testing.T) { + h := newAPIV1Harness(t) + body := fmt.Appendf(nil, `{"authId":%q}`, types.MustAuthID().String()) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/approve", body) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1AuthReject(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + h := newAPIV1Harness(t) + + authID := types.MustAuthID() + authReq := types.NewAuthRequest() + h.app.state.SetAuthCacheEntry(authID, authReq) + + body := fmt.Appendf(nil, `{"authId":%q}`, authID.String()) + res := h.callHuma(http.MethodPost, "/api/v1/auth/reject", body) + + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{}`, string(res.body)) + + verdict := <-authReq.WaitForAuth() + assert.False(t, verdict.Accept(), "reject must finish the session with a failing verdict") + }) + + t.Run("invalid auth_id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/reject", + []byte(`{"authId":"not-a-valid-auth-id"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("no pending session parity", func(t *testing.T) { + h := newAPIV1Harness(t) + body := fmt.Appendf(nil, `{"authId":%q}`, types.MustAuthID().String()) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/reject", body) + assertStatus(t, res, http.StatusNotFound) + }) +} diff --git a/hscontrol/apiv1_authmw_test.go b/hscontrol/apiv1_authmw_test.go new file mode 100644 index 000000000..814f839d0 --- /dev/null +++ b/hscontrol/apiv1_authmw_test.go @@ -0,0 +1,110 @@ +package hscontrol + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAPIV1AuthMiddleware proves the v1 API, mounted on the real router, is +// guarded by the Huma security middleware: missing or bad Bearer tokens are +// rejected with 401 before reaching a handler; a valid key passes to 200. The +// per-endpoint harness bypasses auth via WithLocalTrust, so this is the one +// place the middleware itself is exercised. +func TestAPIV1AuthMiddleware(t *testing.T) { + app := createTestApp(t) + handler := app.HTTPHandler() + + expiry := time.Now().Add(time.Hour) + valid, _, err := app.state.CreateAPIKey(&expiry) + require.NoError(t, err) + + tests := []struct { + name string + authHeader string + wantStatus int + }{ + {name: "missing bearer", authHeader: "", wantStatus: http.StatusUnauthorized}, + {name: "no bearer prefix", authHeader: "tskey-invalid", wantStatus: http.StatusUnauthorized}, + {name: "invalid bearer token", authHeader: "Bearer tskey-invalid", wantStatus: http.StatusUnauthorized}, + {name: "valid api key", authHeader: "Bearer " + valid, wantStatus: http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, "/api/v1/node", nil, + ) + if tt.authHeader != "" { + req.Header.Set("Authorization", tt.authHeader) + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equalf(t, tt.wantStatus, rec.Code, "body: %s", rec.Body.String()) + }) + } +} + +// TestAPIV1DocsArePublic proves the OpenAPI document and docs UI live under +// /api/v1 and are reachable without an API key, while the operations beside them +// stay key-gated. The docs page points at the versioned spec so a future +// /api/v2 can carry its own. +func TestAPIV1DocsArePublic(t *testing.T) { + app := createTestApp(t) + handler := app.HTTPHandler() + + tests := []struct { + path string + contains string + }{ + {path: "/api/v1/openapi.yaml", contains: "openapi:"}, + {path: "/api/v1/docs", contains: "/api/v1/openapi.yaml"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + // No Authorization header: these must be public. + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, tt.path, nil, + ) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equalf(t, http.StatusOK, rec.Code, "%s should be public; body: %s", tt.path, rec.Body.String()) + assert.Containsf(t, rec.Body.String(), tt.contains, "%s body", tt.path) + }) + } +} + +// TestAPIV1Unauthorized401 pins the 401 body: RFC 7807 JSON containing +// "Unauthorized", under 100 bytes and leaking no data, as the integration +// auth-bypass tests require. +func TestAPIV1Unauthorized401(t *testing.T) { + app := createTestApp(t) + handler := app.HTTPHandler() + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, "/api/v1/node", nil, + ) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + + body := rec.Body.Bytes() + assert.Contains(t, string(body), "Unauthorized") + assert.Lessf(t, len(body), 100, "401 body must stay small (no data leak): %s", body) + + var problem map[string]any + require.NoError(t, json.Unmarshal(body, &problem), "401 body must be JSON: %s", body) + assert.NotContains(t, problem, "users") +} diff --git a/hscontrol/apiv1_client_test.go b/hscontrol/apiv1_client_test.go new file mode 100644 index 000000000..536d6177d --- /dev/null +++ b/hscontrol/apiv1_client_test.go @@ -0,0 +1,50 @@ +package hscontrol + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + clientv1 "github.com/juanfont/headscale/gen/client/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAPIV1GeneratedClient smoke-tests the generated client against the Huma +// service over a real HTTP server, exercising the typed request/response path +// end to end. +func TestAPIV1GeneratedClient(t *testing.T) { + app := createTestApp(t) + srv := httptest.NewServer(newHumaTestHandler(app)) + t.Cleanup(srv.Close) + + client, err := clientv1.NewClientWithResponses(srv.URL) + require.NoError(t, err) + + ctx := context.Background() + + health, err := client.HealthWithResponse(ctx) + require.NoError(t, err) + require.Equal(t, http.StatusOK, health.StatusCode()) + require.NotNil(t, health.JSON200) + assert.True(t, health.JSON200.DatabaseConnectivity) + + name := "alice" + created, err := client.CreateUserWithResponse(ctx, clientv1.CreateUserJSONRequestBody{ + Name: &name, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, created.StatusCode()) + require.NotNil(t, created.JSON200) + require.NotNil(t, created.JSON200.User) + assert.Equal(t, "alice", created.JSON200.User.Name) + assert.Equal(t, "1", created.JSON200.User.Id) + + listed, err := client.ListUsersWithResponse(ctx, &clientv1.ListUsersParams{}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, listed.StatusCode()) + require.NotNil(t, listed.JSON200) + require.Len(t, listed.JSON200.Users, 1) + assert.Equal(t, "alice", listed.JSON200.Users[0].Name) +} diff --git a/hscontrol/apiv1_harness_test.go b/hscontrol/apiv1_harness_test.go new file mode 100644 index 000000000..256ccf89b --- /dev/null +++ b/hscontrol/apiv1_harness_test.go @@ -0,0 +1,276 @@ +package hscontrol + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + apiv1 "github.com/juanfont/headscale/hscontrol/api/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// apiV1Harness drives a request through the Huma service and compares the result +// against a golden keyed by the test name. Goldens neutralise timestamps so they +// stay stable across runs; refresh with HEADSCALE_UPDATE_GOLDEN=1 after an +// intentional contract change. assertParity suits reads/errors; mutations use +// assertParityIsolated against a freshly-seeded app. +type apiV1Harness struct { + app *Headscale + huma http.Handler +} + +func newAPIV1Harness(t *testing.T) *apiV1Harness { + t.Helper() + + app := createTestApp(t) + + return &apiV1Harness{ + app: app, + huma: newHumaTestHandler(app), + } +} + +// newHumaTestHandler wraps the Huma handler in WithLocalTrust to bypass the +// bearer-key middleware — the same local-trust the unix socket gets — so these +// tests can exercise response shapes. Auth itself is covered against the full +// router in TestAPIV1AuthMiddleware. +func newHumaTestHandler(app *Headscale) http.Handler { + mux, _ := apiv1.Handler(apiv1.Backend{ + State: app.state, + Change: app.Change, + Cfg: app.cfg, + }) + + return apiv1.WithLocalTrust(mux) +} + +// httpResult is one HTTP exchange captured for comparison. +type httpResult struct { + status int + body []byte +} + +func callHandler(handler http.Handler, method, path string, body []byte) httpResult { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + + req := httptest.NewRequestWithContext(context.Background(), method, path, reader) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + return httpResult{status: rec.Code, body: rec.Body.Bytes()} +} + +func (h *apiV1Harness) callHuma(method, path string, body []byte) httpResult { + return callHandler(h.huma, method, path, body) +} + +// assertParity asserts the Huma response matches the golden: equal status, and +// for success an equal JSON body (timestamps neutralised). Error bodies are not +// compared — the shape deliberately deviates from legacy +// ({code,message,details} vs Huma RFC7807). +func (h *apiV1Harness) assertParity(t *testing.T, method, path string, body []byte) httpResult { + t.Helper() + + hum := h.callHuma(method, path, body) + + assertAgainstGolden(t, method, path, hum) + + return hum +} + +// assertParityIsolated runs the request against a fresh app seeded by seed (may +// be nil) and compares to the golden. Use for mutations. +func assertParityIsolated( + t *testing.T, + seed func(t *testing.T, app *Headscale), + method, path string, + body []byte, +) httpResult { + t.Helper() + + humaApp := createTestApp(t) + if seed != nil { + seed(t, humaApp) + } + + hum := callHandler(newHumaTestHandler(humaApp), method, path, body) + + assertAgainstGolden(t, method, path, hum) + + return hum +} + +// assertStatus pins the exact HTTP status independently of the golden, so a +// regression fails even if the golden is blindly regenerated. +func assertStatus(t *testing.T, got httpResult, want int) { + t.Helper() + + assert.Equalf(t, want, got.status, + "unexpected HTTP status (golden-independent guard); body: %s", got.body) +} + +func isSuccess(status int) bool { + return status >= 200 && status < 300 +} + +const goldenDir = "testdata/apiv1_golden" + +// goldenRecord is the persisted shape of a captured response: the HTTP status +// and, when present, the normalised JSON body. +type goldenRecord struct { + Status int `json:"status"` + Body json.RawMessage `json:"body"` +} + +func goldenPath(t *testing.T) string { + t.Helper() + + name := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + + return filepath.Join(goldenDir, name+".json") +} + +// assertAgainstGolden compares the Huma response to the recorded golden: status +// always, body only for success responses. +func assertAgainstGolden(t *testing.T, method, path string, hum httpResult) { + t.Helper() + + gpath := goldenPath(t) + + if os.Getenv("HEADSCALE_UPDATE_GOLDEN") != "" { + writeGolden(t, gpath, hum) + + return + } + + raw, err := os.ReadFile(gpath) + require.NoErrorf(t, err, + "missing golden %s for %s %s — run with HEADSCALE_UPDATE_GOLDEN=1 to generate", + gpath, method, path) + + var golden goldenRecord + require.NoErrorf(t, json.Unmarshal(raw, &golden), "decoding golden %s", gpath) + + assert.Equalf(t, golden.Status, hum.status, + "status mismatch for %s %s\nhuma body: %s", method, path, hum.body) + + if isSuccess(golden.Status) { + var goldenBody any + if len(golden.Body) > 0 { + goldenBody = normalizeJSON(t, golden.Body, true) + } + + assert.Equalf(t, goldenBody, normalizeJSON(t, hum.body, true), + "body mismatch for %s %s", method, path) + } +} + +// writeGolden persists the response under HEADSCALE_UPDATE_GOLDEN. Success +// bodies are normalised the same way the comparison path does, so the file stays +// stable across runs. Error responses are stored status-only, since the reader +// never compares error bodies. +func writeGolden(t *testing.T, gpath string, hum httpResult) { + t.Helper() + + rec := goldenRecord{Status: hum.status} + + if isSuccess(hum.status) { + if normalised := normalizeJSON(t, hum.body, true); normalised != nil { + raw, err := json.Marshal(normalised) + require.NoErrorf(t, err, "marshalling golden body for %s", gpath) + + rec.Body = raw + } + } + + out, err := json.MarshalIndent(rec, "", " ") + require.NoErrorf(t, err, "marshalling golden record for %s", gpath) + + require.NoErrorf(t, os.MkdirAll(filepath.Dir(gpath), 0o755), + "creating golden dir for %s", gpath) + require.NoErrorf(t, os.WriteFile(gpath, append(out, '\n'), 0o600), + "writing golden %s", gpath) + + t.Logf("updated golden %s", gpath) +} + +// nonDeterministicRe matches values that embed per-app random material and so +// cannot match byte-for-byte across apps: masked key/secret prefixes +// (hskey-auth--***, hskey-api--***) and Tailscale key material +// (mkey:/nodekey:/discokey:). Neutralised to a sentinel when neutralise is +// true. +var nonDeterministicRe = regexp.MustCompile( + `^(hskey-(auth|api)-[0-9a-f]+-\*\*\*|(mkey|nodekey|discokey):[0-9a-f]+)$`, +) + +// normalizeJSON decodes JSON for order-independent, type-aware comparison. +// Numbers decode as json.Number so "5" and 5 are not conflated, surfacing +// string-encoding differences. With neutralise, timestamps and per-app random +// values (see nonDeterministicRe) become sentinels; otherwise timestamps are +// canonicalised to a UTC instant. +func normalizeJSON(t *testing.T, b []byte, neutralise bool) any { + t.Helper() + + if len(bytes.TrimSpace(b)) == 0 { + return nil + } + + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + + var v any + require.NoErrorf(t, dec.Decode(&v), "decoding JSON: %s", b) + + return canonicalizeTimestamps(v, neutralise) +} + +func canonicalizeTimestamps(v any, neutralise bool) any { + switch val := v.(type) { + case map[string]any: + for k, child := range val { + val[k] = canonicalizeTimestamps(child, neutralise) + } + + return val + case []any: + for i, child := range val { + val[i] = canonicalizeTimestamps(child, neutralise) + } + + return val + case string: + if neutralise && nonDeterministicRe.MatchString(val) { + return "" + } + + ts, err := time.Parse(time.RFC3339Nano, val) + if err != nil { + return val + } + + if neutralise { + return "" + } + + return ts.UTC().Format(time.RFC3339Nano) + default: + return v + } +} diff --git a/hscontrol/apiv1_health_test.go b/hscontrol/apiv1_health_test.go new file mode 100644 index 000000000..6e25ddb24 --- /dev/null +++ b/hscontrol/apiv1_health_test.go @@ -0,0 +1,24 @@ +package hscontrol + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIV1Health(t *testing.T) { + h := newAPIV1Harness(t) + + t.Run("huma returns healthy with database connectivity", func(t *testing.T) { + res := h.callHuma(http.MethodGet, "/api/v1/health", nil) + + assert.Equal(t, http.StatusOK, res.status) + require.JSONEq(t, `{"databaseConnectivity":true}`, string(res.body)) + }) + + t.Run("parity with gateway", func(t *testing.T) { + h.assertParity(t, http.MethodGet, "/api/v1/health", nil) + }) +} diff --git a/hscontrol/apiv1_nodes_test.go b/hscontrol/apiv1_nodes_test.go new file mode 100644 index 000000000..dc6e22228 --- /dev/null +++ b/hscontrol/apiv1_nodes_test.go @@ -0,0 +1,416 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" + "tailscale.com/types/key" +) + +// nodeSeed carries fixed key material so isolated apps in a mutation parity test +// register byte-identical nodes; otherwise random keys would defeat body +// comparison. +type nodeSeed struct { + user string + machineKey key.MachinePrivate + nodeKey key.NodePrivate + hostname string + tags []string +} + +func newNodeSeed(user, hostname string, tags ...string) nodeSeed { + return nodeSeed{ + user: user, + machineKey: key.NewMachine(), + nodeKey: key.NewNode(), + hostname: hostname, + tags: tags, + } +} + +// register inserts the user, a matching pre-auth key, and the node itself using +// the seed's fixed keys, returning the created node ID. +func (s nodeSeed) register(t *testing.T, app *Headscale) types.NodeID { + t.Helper() + + user := app.state.CreateUserForTest(s.user) + + pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, s.tags) + require.NoError(t, err) + + req := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: s.nodeKey.Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: s.hostname}, + } + + _, err = app.handleRegisterWithAuthKey(req, s.machineKey.Public()) + require.NoError(t, err) + + node, found := app.state.GetNodeByNodeKey(s.nodeKey.Public()) + require.True(t, found) + + return node.ID() +} + +func seedNodes(seeds ...nodeSeed) func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + for _, s := range seeds { + s.register(t, app) + } + } +} + +func TestAPIV1NodeGet(t *testing.T) { + t.Run("happy parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/node/1", nil) + }) + + t.Run("tagged node parity", func(t *testing.T) { + h := newAPIV1Harness(t) + // No tagOwners means policy won't authorise a tag, so register via the + // pre-auth key path, which forces tags regardless of policy. + seedNodes(newNodeSeed("bob", "node-b", "tag:initial"))(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/node/1", nil) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodGet, "/api/v1/node/99999", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodGet, "/api/v1/node/abc", nil) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("carol", "node-c"))(t, h.app) + + res := h.callHuma(http.MethodGet, "/api/v1/node/1", nil) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.Node["id"]) + assert.Equal(t, "node-c", got.Node["name"]) + assert.Equal(t, "REGISTER_METHOD_AUTH_KEY", got.Node["registerMethod"]) + // EmitUnpopulated parity: slices present as [], expiry as null. + assert.Equal(t, []any{}, got.Node["ipAddresses"]) + assert.Equal(t, []any{}, got.Node["tags"]) + assert.Nil(t, got.Node["expiry"]) + assert.Contains(t, got.Node, "user") + assert.Contains(t, got.Node, "preAuthKey") + assert.Contains(t, got.Node, "createdAt") + }) +} + +func TestAPIV1NodeList(t *testing.T) { + t.Run("empty returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodGet, "/api/v1/node", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"nodes":[]}`, string(res.body)) + }) + + t.Run("empty parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.assertParity(t, http.MethodGet, "/api/v1/node", nil) + }) + + t.Run("all parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes( + newNodeSeed("alice", "node-a"), + newNodeSeed("bob", "node-b"), + )(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/node", nil) + }) + + t.Run("filter by user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes( + newNodeSeed("alice", "node-a"), + newNodeSeed("bob", "node-b"), + )(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/node?user=alice", nil) + }) + + t.Run("unknown user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + res := h.assertParity(t, http.MethodGet, "/api/v1/node?user=nope", nil) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1NodeDelete(t *testing.T) { + t.Run("happy parity", func(t *testing.T) { + assertParityIsolated(t, seedNodes(newNodeSeed("alice", "node-a")), + http.MethodDelete, "/api/v1/node/1", nil) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/node/99999", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/node/abc", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeExpire(t *testing.T) { + // The embedded pre-auth key's masked prefix is random per app, so isolated + // body comparison is impossible. GetNode/ListNodes prove full serialisation; + // here we just assert the mutation took effect. + t.Run("huma expires node", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + + res := h.callHuma(http.MethodPost, "/api/v1/node/1/expire", nil) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.Node["id"]) + // Expiry was nil before; expiring sets it to a concrete timestamp. + assert.NotNil(t, got.Node["expiry"]) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/expire", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/expire", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeRename(t *testing.T) { + t.Run("huma renames node", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + + res := h.callHuma(http.MethodPost, "/api/v1/node/1/rename/renamed-node", nil) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "renamed-node", got.Node["givenName"]) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/rename/whatever", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/rename/whatever", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeSetTags(t *testing.T) { + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/tags", + []byte(`{"tags":["tag:foo"]}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("empty tags parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/1/tags", []byte(`{"tags":[]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("unauthorized tag parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + // No tagOwners in policy → SetNodeTags rejects with InvalidArgument (400). + res := h.assertParity(t, http.MethodPost, "/api/v1/node/1/tags", + []byte(`{"tags":["tag:server"]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/tags", + []byte(`{"tags":["tag:foo"]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeSetApprovedRoutes(t *testing.T) { + t.Run("huma sets approved routes", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + + res := h.callHuma(http.MethodPost, "/api/v1/node/1/approve_routes", + []byte(`{"routes":[]}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.Node["id"]) + // SubnetRoutes is recomputed from primary routes; empty here but present. + assert.Equal(t, []any{}, got.Node["approvedRoutes"]) + assert.Equal(t, []any{}, got.Node["subnetRoutes"]) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/approve_routes", + []byte(`{"routes":["10.0.0.0/24"]}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/approve_routes", + []byte(`{"routes":[]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeRegister(t *testing.T) { + t.Run("happy parity", func(t *testing.T) { + authID := types.MustAuthID() + mk := key.NewMachine() + nk := key.NewNode() + + seed := func(t *testing.T, app *Headscale) { + t.Helper() + + app.state.CreateUserForTest("alice") + + regData := &types.RegistrationData{ + NodeKey: nk.Public(), + MachineKey: mk.Public(), + Hostname: "registered-node", + } + app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData)) + } + + assertParityIsolated(t, seed, http.MethodPost, + "/api/v1/node/register?user=alice&key="+authID.String(), nil) + }) + + t.Run("invalid key parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + res := h.assertParity(t, http.MethodPost, + "/api/v1/node/register?user=alice&key=invalidkey", nil) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("unknown user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, + "/api/v1/node/register?user=nope&key="+types.MustAuthID().String(), nil) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1NodeBackfillIPs(t *testing.T) { + t.Run("confirmed empty parity", func(t *testing.T) { + assertParityIsolated(t, nil, http.MethodPost, + "/api/v1/node/backfillips?confirmed=true", nil) + }) + + t.Run("confirmed returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodPost, "/api/v1/node/backfillips?confirmed=true", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"changes":[]}`, string(res.body)) + }) + + t.Run("unconfirmed parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/backfillips", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeDebugCreate(t *testing.T) { + t.Run("unknown user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + body := []byte(`{"user":"nope","key":"` + types.MustAuthID().String() + + `","name":"dbg","routes":[]}`) + res := h.assertParity(t, http.MethodPost, "/api/v1/debug/node", body) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid key parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + + body := []byte(`{"user":"alice","key":"badkey","name":"dbg","routes":[]}`) + res := h.assertParity(t, http.MethodPost, "/api/v1/debug/node", body) + assertStatus(t, res, http.StatusBadRequest) + }) + + // The handler mints fresh key material per call, so isolated apps can't + // produce byte-identical bodies; assert the shape directly instead. + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + h.app.state.CreateUserForTest("alice") + + body := []byte(`{"user":"alice","key":"` + types.MustAuthID().String() + + `","name":"dbgnode","routes":["10.0.0.0/24"]}`) + + res := h.callHuma(http.MethodPost, "/api/v1/debug/node", body) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "dbgnode", got.Node["name"]) + assert.Equal(t, "REGISTER_METHOD_UNSPECIFIED", got.Node["registerMethod"]) + assert.Equal(t, []any{"10.0.0.0/24"}, got.Node["availableRoutes"]) + // The synthetic echo node has no pre-auth key: emitted as null. + assert.Nil(t, got.Node["preAuthKey"]) + // Zero-time expiry/lastSeen are emitted as the zero instant, not null. + assert.Equal(t, "0001-01-01T00:00:00Z", got.Node["expiry"]) + assert.Equal(t, "0001-01-01T00:00:00Z", got.Node["lastSeen"]) + }) +} diff --git a/hscontrol/apiv1_policy_test.go b/hscontrol/apiv1_policy_test.go new file mode 100644 index 000000000..0c4cfefa0 --- /dev/null +++ b/hscontrol/apiv1_policy_test.go @@ -0,0 +1,186 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/mapper" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// validPolicy is a minimal policy that passes validation without nodes or users. +const validPolicy = `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + +// invalidPolicy is not valid HuJSON, so policy manager construction fails. +const invalidPolicy = `{"acls": [` + +// seedPolicy stores a policy in the database so reads return it. +func seedPolicy(policy string) func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + _, err := app.state.SetPolicyInDB(policy) + require.NoError(t, err) + } +} + +func TestAPIV1PolicyGet(t *testing.T) { + t.Run("empty parity", func(t *testing.T) { + // With no policy stored, the DB load fails; this is treated as a server + // fault (500), matching the legacy contract. + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodGet, "/api/v1/policy", nil) + assertStatus(t, res, http.StatusInternalServerError) + }) + + t.Run("set parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedPolicy(validPolicy)(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/policy", nil) + }) + + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + seedPolicy(validPolicy)(t, h.app) + + res := h.callHuma(http.MethodGet, "/api/v1/policy", nil) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Policy string `json:"policy"` + UpdatedAt string `json:"updatedAt"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.JSONEq(t, validPolicy, got.Policy) + // updatedAt is emitted even though no omitempty is set. + assert.NotEmpty(t, got.UpdatedAt) + }) +} + +func TestAPIV1PolicySet(t *testing.T) { + t.Run("valid parity", func(t *testing.T) { + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + assertParityIsolated(t, nil, http.MethodPut, "/api/v1/policy", body) + }) + + t.Run("invalid parity", func(t *testing.T) { + body, err := json.Marshal(map[string]string{"policy": invalidPolicy}) + require.NoError(t, err) + + res := assertParityIsolated(t, nil, http.MethodPut, "/api/v1/policy", body) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + res := h.callHuma(http.MethodPut, "/api/v1/policy", body) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Policy string `json:"policy"` + UpdatedAt string `json:"updatedAt"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.JSONEq(t, validPolicy, got.Policy) + assert.NotEmpty(t, got.UpdatedAt) + }) + + t.Run("not-db mode rejected", func(t *testing.T) { + // Policy updates are only valid in DB mode; file mode rejects with 400. + // createTestApp is DB mode, so build a file-mode app here. + humaApp := createFilePolicyApp(t) + + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + hum := callHandler(newHumaTestHandler(humaApp), http.MethodPut, "/api/v1/policy", body) + + assert.Equal(t, http.StatusBadRequest, hum.status, + "huma body: %s", hum.body) + }) +} + +func TestAPIV1PolicyCheck(t *testing.T) { + t.Run("valid parity", func(t *testing.T) { + h := newAPIV1Harness(t) + + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + h.assertParity(t, http.MethodPost, "/api/v1/policy/check", body) + }) + + t.Run("invalid parity", func(t *testing.T) { + h := newAPIV1Harness(t) + + body, err := json.Marshal(map[string]string{"policy": invalidPolicy}) + require.NoError(t, err) + + res := h.assertParity(t, http.MethodPost, "/api/v1/policy/check", body) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("valid returns empty object", func(t *testing.T) { + h := newAPIV1Harness(t) + + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + res := h.callHuma(http.MethodPost, "/api/v1/policy/check", body) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{}`, string(res.body)) + }) +} + +// createFilePolicyApp mirrors createTestApp but with file-based policy mode so +// the policy-update-disabled path can be exercised. +func createFilePolicyApp(t *testing.T) *Headscale { + t.Helper() + + tmpDir := t.TempDir() + + cfg := types.Config{ + ServerURL: "http://localhost:8080", + NoisePrivateKeyPath: tmpDir + "/noise_private.key", + Database: types.DatabaseConfig{ + Type: "sqlite3", + Sqlite: types.SqliteConfig{ + Path: tmpDir + "/headscale_test.db", + }, + }, + OIDC: types.OIDCConfig{}, + Policy: types.PolicyConfig{ + Mode: types.PolicyModeFile, + }, + Tuning: types.Tuning{ + BatchChangeDelay: 100 * time.Millisecond, + BatcherWorkers: 1, + }, + } + + app, err := NewHeadscale(&cfg) + require.NoError(t, err) + + app.mapBatcher = mapper.NewBatcherAndMapper(&cfg, app.state) + app.mapBatcher.Start() + + t.Cleanup(func() { + if app.mapBatcher != nil { + app.mapBatcher.Close() + } + }) + + return app +} diff --git a/hscontrol/apiv1_preauthkeys_test.go b/hscontrol/apiv1_preauthkeys_test.go new file mode 100644 index 000000000..2df5f8e6d --- /dev/null +++ b/hscontrol/apiv1_preauthkeys_test.go @@ -0,0 +1,163 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedPreAuthKeys creates a user-owned key and a tagged (system-created) key; +// the tagged key exercises the user:null path. +func seedPreAuthKeys() func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + user := app.state.CreateUserForTest("alice") + uid := types.UserID(user.ID) + exp := time.Time{} + + _, err := app.state.CreatePreAuthKey(&uid, true, false, &exp, nil) + require.NoError(t, err) + + // Tagged, system-created: no user. + _, err = app.state.CreatePreAuthKey(nil, false, true, &exp, []string{"tag:test"}) + require.NoError(t, err) + } +} + +func TestAPIV1CreatePreAuthKey(t *testing.T) { + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + h.app.state.CreateUserForTest("alice") + + res := h.callHuma(http.MethodPost, "/api/v1/preauthkey", + []byte(`{"user":"1","reusable":true}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + PreAuthKey map[string]any `json:"preAuthKey"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.PreAuthKey["id"]) + assert.Equal(t, true, got.PreAuthKey["reusable"]) + // Zero-value fields are emitted (EmitUnpopulated parity). + assert.Equal(t, false, got.PreAuthKey["ephemeral"]) + assert.Equal(t, false, got.PreAuthKey["used"]) + assert.Contains(t, got.PreAuthKey, "expiration") + assert.Contains(t, got.PreAuthKey, "createdAt") + // Empty tags serialize as [], not null. + assert.Equal(t, []any{}, got.PreAuthKey["aclTags"]) + // The freshly created key returns its full secret. + assert.NotEmpty(t, got.PreAuthKey["key"]) + user, ok := got.PreAuthKey["user"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "1", user["id"]) + }) + + t.Run("tagged key has null user", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodPost, "/api/v1/preauthkey", + []byte(`{"aclTags":["tag:test"]}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + PreAuthKey map[string]any `json:"preAuthKey"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Nil(t, got.PreAuthKey["user"]) + assert.Equal(t, []any{"tag:test"}, got.PreAuthKey["aclTags"]) + }) + + t.Run("invalid tag parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.app.state.CreateUserForTest("alice") + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", + []byte(`{"user":"1","aclTags":["badtag"]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("nonexistent user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", []byte(`{"user":"999"}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid user id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", []byte(`{"user":"abc"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("neither tagged nor owned parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", []byte(`{"reusable":true}`)) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1ExpirePreAuthKey(t *testing.T) { + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, seedPreAuthKeys(), http.MethodPost, + "/api/v1/preauthkey/expire", []byte(`{"id":"1"}`)) + }) + + t.Run("nonexistent parity", func(t *testing.T) { + res := assertParityIsolated(t, seedPreAuthKeys(), http.MethodPost, + "/api/v1/preauthkey/expire", []byte(`{"id":"99999"}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey/expire", []byte(`{"id":"abc"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1DeletePreAuthKey(t *testing.T) { + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, seedPreAuthKeys(), http.MethodDelete, + "/api/v1/preauthkey?id=1", nil) + }) + + t.Run("nonexistent parity", func(t *testing.T) { + res := assertParityIsolated(t, seedPreAuthKeys(), http.MethodDelete, + "/api/v1/preauthkey?id=99999", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/preauthkey?id=abc", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1ListPreAuthKeys(t *testing.T) { + t.Run("empty returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodGet, "/api/v1/preauthkey", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"preAuthKeys":[]}`, string(res.body)) + }) + + t.Run("empty parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.assertParity(t, http.MethodGet, "/api/v1/preauthkey", nil) + }) + + t.Run("all parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedPreAuthKeys()(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/preauthkey", nil) + }) +} diff --git a/hscontrol/apiv1_socket_test.go b/hscontrol/apiv1_socket_test.go new file mode 100644 index 000000000..0b6f53ec3 --- /dev/null +++ b/hscontrol/apiv1_socket_test.go @@ -0,0 +1,61 @@ +package hscontrol + +import ( + "context" + "net" + "net/http" + "path/filepath" + "testing" + + clientv1 "github.com/juanfont/headscale/gen/client/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAPIV1SocketClient proves the CLI's local transport: the Huma handler over +// a unix socket reached by an http.Client that dials it, the same wiring as the +// server's socket listener and the CLI's newSocketClient, on local trust. +func TestAPIV1SocketClient(t *testing.T) { + app := createTestApp(t) + + socketPath := filepath.Join(t.TempDir(), "headscale.sock") + + lis, err := new(net.ListenConfig).Listen(context.Background(), "unix", socketPath) + require.NoError(t, err) + + srv := &http.Server{Handler: newHumaTestHandler(app)} //nolint:gosec + go func() { _ = srv.Serve(lis) }() + + t.Cleanup(func() { _ = srv.Close() }) + + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + + return d.DialContext(ctx, "unix", socketPath) + }, + }, + } + + client, err := clientv1.NewClientWithResponses( + "http://local", + clientv1.WithHTTPClient(httpClient), + ) + require.NoError(t, err) + + ctx := context.Background() + + health, err := client.HealthWithResponse(ctx) + require.NoError(t, err) + require.Equal(t, http.StatusOK, health.StatusCode()) + require.NotNil(t, health.JSON200) + assert.True(t, health.JSON200.DatabaseConnectivity) + + name := "socket-user" + created, err := client.CreateUserWithResponse(ctx, clientv1.CreateUserJSONRequestBody{Name: &name}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, created.StatusCode()) + require.NotNil(t, created.JSON200) + assert.Equal(t, "socket-user", created.JSON200.User.Name) +} diff --git a/hscontrol/apiv1_users_test.go b/hscontrol/apiv1_users_test.go new file mode 100644 index 000000000..428ba21c6 --- /dev/null +++ b/hscontrol/apiv1_users_test.go @@ -0,0 +1,123 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func seedUsers(names ...string) func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + for _, n := range names { + app.state.CreateUserForTest(n) + } + } +} + +func TestAPIV1CreateUser(t *testing.T) { + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodPost, "/api/v1/user", []byte(`{"name":"test"}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + User map[string]any `json:"user"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.User["id"]) + assert.Equal(t, "test", got.User["name"]) + assert.Contains(t, got.User, "createdAt") + // Zero-value fields are emitted as empty strings (EmitUnpopulated parity). + assert.Empty(t, got.User["email"]) + assert.Empty(t, got.User["displayName"]) + }) + + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, nil, http.MethodPost, "/api/v1/user", + []byte(`{"name":"test","displayName":"Test","email":"t@example.com"}`)) + }) + + t.Run("duplicate name parity", func(t *testing.T) { + res := assertParityIsolated(t, seedUsers("dup"), http.MethodPost, "/api/v1/user", + []byte(`{"name":"dup"}`)) + assertStatus(t, res, http.StatusConflict) + }) +} + +func TestAPIV1RenameUser(t *testing.T) { + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, seedUsers("alice"), http.MethodPost, + "/api/v1/user/1/rename/bob", nil) + }) + + t.Run("nonexistent parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/user/999/rename/bob", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/user/abc/rename/bob", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1DeleteUser(t *testing.T) { + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, seedUsers("alice"), http.MethodDelete, "/api/v1/user/1", nil) + }) + + t.Run("nonexistent parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/user/999", nil) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1ListUsers(t *testing.T) { + t.Run("empty returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodGet, "/api/v1/user", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"users":[]}`, string(res.body)) + }) + + t.Run("empty parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.assertParity(t, http.MethodGet, "/api/v1/user", nil) + }) + + t.Run("all parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice", "bob", "carol")(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/user", nil) + }) + + t.Run("filter by name parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice", "bob")(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/user?name=alice", nil) + }) + + t.Run("filter by id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice", "bob")(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/user?id=2", nil) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice")(t, h.app) + res := h.assertParity(t, http.MethodGet, "/api/v1/user?id=abc", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} diff --git a/hscontrol/apiv2_acl_test.go b/hscontrol/apiv2_acl_test.go new file mode 100644 index 000000000..def9af824 --- /dev/null +++ b/hscontrol/apiv2_acl_test.go @@ -0,0 +1,239 @@ +package hscontrol + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/danielgtaylor/huma/v2/humatest" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const allowAllPolicy = `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + +// storedPolicy returns the HuJSON actually persisted in the DB (server-side +// ground truth), or "" when none is set. +func storedPolicy(t *testing.T, app *Headscale) string { + t.Helper() + + p, err := app.state.GetPolicy() + if errors.Is(err, types.ErrPolicyNotFound) { + return "" + } + + require.NoError(t, err) + + return p.Data +} + +// policyETagOf recomputes the expected ETag independently of the handler, so a +// handler that hashed the wrong buffer is caught. +func policyETagOf(data string) string { + sum := sha256.Sum256([]byte(data)) + + return `"` + hex.EncodeToString(sum[:]) + `"` +} + +// setACL POSTs a raw policy body with the given content type and optional +// headers (e.g. "If-Match: ..."). +func setACL(t *testing.T, api humatest.TestAPI, body, contentType string, headers ...string) *httptest.ResponseRecorder { + t.Helper() + + args := make([]any, 0, 2+len(headers)) + args = append(args, bytes.NewReader([]byte(body)), "Content-Type: "+contentType) + + for _, h := range headers { + args = append(args, h) + } + + return api.Post("/api/v2/tailnet/-/acl", args...) +} + +func TestAPIv2ACLDefaultWhenUnset(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + resp := api.Get("/api/v2/tailnet/-/acl") + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + assert.NotEmpty(t, resp.Header().Get("Etag")) + assert.Contains(t, resp.Body.String(), `"acls"`) + + // GET did not materialize a stored policy. + assert.Empty(t, storedPolicy(t, app)) + + // Content-addressed: a second GET returns the same ETag. + assert.Equal(t, resp.Header().Get("Etag"), api.Get("/api/v2/tailnet/-/acl").Header().Get("Etag")) +} + +func TestAPIv2ACLContentNegotiation(t *testing.T) { + api := registerAPIV2(t, createTestApp(t)) + + jsonResp := api.Get("/api/v2/tailnet/-/acl") + huResp := api.Get("/api/v2/tailnet/-/acl", "Accept: application/hujson") + + assert.Equal(t, "application/json", jsonResp.Header().Get("Content-Type")) + assert.Equal(t, "application/hujson", huResp.Header().Get("Content-Type")) + // Same bytes, same ETag — the ETag is over content, not type. + assert.Equal(t, jsonResp.Body.Bytes(), huResp.Body.Bytes()) + assert.Equal(t, jsonResp.Header().Get("Etag"), huResp.Header().Get("Etag")) +} + +func TestAPIv2ACLSetCanonicalJSON(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + set := setACL(t, api, allowAllPolicy, "application/json") + require.Equalf(t, http.StatusOK, set.Code, "body: %s", set.Body) + setETag := set.Header().Get("Etag") + assert.NotEmpty(t, setETag) + + // (a) tool's own get reflects it, same ETag. + get := api.Get("/api/v2/tailnet/-/acl") + assert.Contains(t, get.Body.String(), `"action":"accept"`) + assert.Equal(t, setETag, get.Header().Get("Etag")) + + // (b) server-side: exact stored bytes. + assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app)) + + // (c) ETag is the sha256 of those bytes. + assert.Equal(t, policyETagOf(allowAllPolicy), setETag) +} + +func TestAPIv2ACLSetHuJSONWithComments(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + // Line comment + trailing comma: valid HuJSON, invalid strict JSON. The + // server accepts it (the SDK sends the policy file this way) and standardizes + // it on store; the ACL content survives even though comments are blanked. + policy := "{\n // allow all\n \"acls\": [{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}],\n}" + + set := setACL(t, api, policy, "application/hujson") + require.Equalf(t, http.StatusOK, set.Code, "body: %s", set.Body) + + // Server-side: stored, parseable, ACL intact. + stored := storedPolicy(t, app) + assert.Contains(t, stored, `"action":"accept"`) + + // GET round-trips the stored form and its content-addressed ETag. + raw := api.Get("/api/v2/tailnet/-/acl", "Accept: application/hujson") + assert.Equal(t, stored, raw.Body.String()) + assert.Equal(t, policyETagOf(stored), raw.Header().Get("Etag")) +} + +func TestAPIv2ACLETagChangesOnChangeStableOnNoop(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + etag1 := setACL(t, api, allowAllPolicy, "application/json").Header().Get("Etag") + stored1 := storedPolicy(t, app) + + p2 := `{"hosts":{"h":"100.64.0.1"},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + etag2 := setACL(t, api, p2, "application/json").Header().Get("Etag") + assert.NotEqual(t, etag1, etag2, "etag changes when policy changes") + assert.NotEqual(t, stored1, storedPolicy(t, app)) + + // No-op re-set keeps the ETag stable. + etag3 := setACL(t, api, p2, "application/json").Header().Get("Etag") + assert.Equal(t, etag2, etag3) + assert.Equal(t, p2, storedPolicy(t, app)) +} + +func TestAPIv2ACLIfMatchPreconditions(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + etag := setACL(t, api, allowAllPolicy, "application/json").Header().Get("Etag") + + p2 := `{"hosts":{"h":"100.64.0.1"},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + + // Match -> 200, applies. + require.Equal(t, http.StatusOK, setACL(t, api, p2, "application/json", "If-Match: "+etag).Code) + assert.Equal(t, p2, storedPolicy(t, app)) + + // Mismatch -> 412, server unchanged. + before := storedPolicy(t, app) + assert.Equal(t, http.StatusPreconditionFailed, + setACL(t, api, allowAllPolicy, "application/json", `If-Match: "deadbeef"`).Code) + assert.Equal(t, before, storedPolicy(t, app), "rejected write left the policy untouched") + + // Absent -> unconditional 200. + require.Equal(t, http.StatusOK, setACL(t, api, allowAllPolicy, "application/json").Code) + assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app)) +} + +func TestAPIv2ACLIfMatchTsDefault(t *testing.T) { + // No policy set: ts-default matches the allow-all default -> 200. + app := createTestApp(t) + api := registerAPIV2(t, app) + require.Equal(t, http.StatusOK, + setACL(t, api, allowAllPolicy, "application/json", `If-Match: "ts-default"`).Code) + assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app)) + + // A non-default policy is set: ts-default no longer matches -> 412. + assert.Equal(t, http.StatusPreconditionFailed, + setACL(t, api, `{"hosts":{"h":"100.64.0.1"},"acls":[]}`, "application/json", `If-Match: "ts-default"`).Code) +} + +func TestAPIv2ACLInvalidPolicyAtomicity(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + good := setACL(t, api, allowAllPolicy, "application/json") + require.Equal(t, http.StatusOK, good.Code) + goodETag := good.Header().Get("Etag") + + // Malformed body -> 400, and the stored policy is unchanged (atomicity). + bad := setACL(t, api, `{ this is not valid`, "application/json") + assert.Equal(t, http.StatusBadRequest, bad.Code) + assert.Contains(t, bad.Body.String(), `"message"`) + assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app)) + + // Wire-level: GET still serves the good policy + its ETag (no drift). + get := api.Get("/api/v2/tailnet/-/acl") + assert.Equal(t, goodETag, get.Header().Get("Etag")) + assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app)) +} + +func TestAPIv2ACLNonDefaultTailnet404(t *testing.T) { + api := registerAPIV2(t, createTestApp(t)) + + assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/example.com/acl").Code) + assert.Equal(t, http.StatusNotFound, + api.Post("/api/v2/tailnet/example.com/acl", bytes.NewReader([]byte(allowAllPolicy))).Code) +} + +func TestAPIv2ACLFileModeReadOnly(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "acl.hujson") + fileBytes := "{\n // file-managed\n \"acls\": [{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}],\n}" + require.NoError(t, os.WriteFile(path, []byte(fileBytes), 0o600)) + + cfg := &types.Config{Policy: types.PolicyConfig{Mode: types.PolicyModeFile, Path: path}} + _, api := humatest.New(t, apiv2.Config()) + apiv2.Register(api, apiv2.Backend{Cfg: cfg}) + + // GET serves the file bytes + their ETag. + get := api.Get("/api/v2/tailnet/-/acl") + require.Equalf(t, http.StatusOK, get.Code, "body: %s", get.Body) + assert.Equal(t, fileBytes, get.Body.String()) + assert.Equal(t, policyETagOf(fileBytes), get.Header().Get("Etag")) + + // POST is rejected in file mode; the file is unchanged. + set := setACL(t, api, allowAllPolicy, "application/json") + assert.Equal(t, http.StatusBadRequest, set.Code) + assert.Contains(t, set.Body.String(), "database") + + after, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, fileBytes, string(after)) +} diff --git a/hscontrol/apiv2_devices_test.go b/hscontrol/apiv2_devices_test.go new file mode 100644 index 000000000..24c0d77a4 --- /dev/null +++ b/hscontrol/apiv2_devices_test.go @@ -0,0 +1,343 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + "github.com/danielgtaylor/huma/v2/humatest" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// registerAPIV2 builds a humatest API with the v2 operations registered over the +// app's state, with no auth middleware (the contract tests exercise wire shapes; +// auth is covered by TestAPIv2). The full Backend (Change + Cfg) is wired so the +// device/acl write handlers work. +func registerAPIV2(t *testing.T, app *Headscale) humatest.TestAPI { + t.Helper() + + _, api := humatest.New(t, apiv2.Config()) + apiv2.Register(api, apiv2.Backend{State: app.state, Change: app.Change, Cfg: app.cfg}) + + return api +} + +// deviceTestEnv is one seeded, registered, user-owned node plus the v2 API. +type deviceTestEnv struct { + app *Headscale + api humatest.TestAPI + user *types.User + nodeID types.NodeID + deviceID string +} + +// newDeviceTestEnv seeds a registered user-owned node into the NodeStore, with +// tag:ci and tag:prod declared in policy so tagging is permitted. +func newDeviceTestEnv(t *testing.T) deviceTestEnv { + t.Helper() + + app := createTestApp(t) + user := app.state.CreateUserForTest("dut-user") + + _, err := app.state.SetPolicy([]byte( + `{"tagOwners":{"tag:ci":["` + user.Name + `@"],"tag:prod":["` + user.Name + `@"]},` + + `"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`, + )) + require.NoError(t, err) + + node := app.state.CreateRegisteredNodeForTest(user, "contract-dut") + node.User = user + view := app.state.PutNodeInStoreForTest(*node) + + return deviceTestEnv{ + app: app, + api: registerAPIV2(t, app), + user: user, + nodeID: view.ID(), + deviceID: strconv.FormatUint(uint64(view.ID()), 10), + } +} + +// srvNode reloads the node from the NodeStore — the server-side source of truth. +func (e deviceTestEnv) srvNode(t *testing.T) types.NodeView { + t.Helper() + + v, ok := e.app.state.GetNodeByID(e.nodeID) + require.True(t, ok, "node must exist in the NodeStore") + require.True(t, v.Valid()) + + return v +} + +// seedExpiry stamps a future expiry so set-key(disable) is a real transition. +func (e deviceTestEnv) seedExpiry(t *testing.T, at time.Time) { + t.Helper() + + _, _, err := e.app.state.SetNodeExpiry(e.nodeID, &at) + require.NoError(t, err) +} + +func getDevice(t *testing.T, api humatest.TestAPI, deviceID string) apiv2.Device { + t.Helper() + + resp := api.Get("/api/v2/device/" + deviceID) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var dev apiv2.Device + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dev)) + + return dev +} + +func getDeviceRoutes(t *testing.T, api humatest.TestAPI, deviceID string) apiv2.DeviceRoutes { + t.Helper() + + resp := api.Get("/api/v2/device/" + deviceID + "/routes") + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var routes apiv2.DeviceRoutes + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &routes)) + + return routes +} + +func approvedRouteStrings(nv types.NodeView) []string { + return util.PrefixesToString(nv.ApprovedRoutes().AsSlice()) +} + +func TestAPIv2Device_Get(t *testing.T) { + e := newDeviceTestEnv(t) + + resp := e.api.Get("/api/v2/device/" + e.deviceID + "?fields=all") + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var dev apiv2.Device + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dev)) + assert.Equal(t, e.deviceID, dev.ID) + assert.Equal(t, e.deviceID, dev.NodeID) + assert.Equal(t, "contract-dut", dev.Hostname) + assert.Equal(t, "contract-dut", dev.Name) + assert.True(t, dev.Authorized) + assert.Equal(t, e.user.Username(), dev.User) + assert.NotNil(t, dev.Tags) + assert.True(t, dev.KeyExpiryDisabled, "seeded node has no expiry") + assert.NotEmpty(t, dev.Addresses) + + // Server-side cross-check. + n := e.srvNode(t) + assert.Equal(t, n.GivenName(), dev.Name) + assert.False(t, n.IsTagged()) + assert.True(t, n.User().Valid()) + assert.False(t, n.Expiry().Valid()) + assert.Equal(t, n.IPsAsString(), dev.Addresses) +} + +func TestAPIv2Device_Get_UnknownID_404(t *testing.T) { + e := newDeviceTestEnv(t) + + assert.Equal(t, http.StatusNotFound, e.api.Get("/api/v2/device/999999").Code) + assert.Equal(t, http.StatusNotFound, e.api.Get("/api/v2/device/not-a-number").Code) +} + +func TestAPIv2Device_List(t *testing.T) { + e := newDeviceTestEnv(t) + + list := func() []apiv2.Device { + t.Helper() + + resp := e.api.Get("/api/v2/tailnet/-/devices") + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var out struct { + Devices []apiv2.Device `json:"devices"` + } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out)) + + return out.Devices + } + + assert.True(t, containsDeviceID(list(), e.deviceID)) + assert.Len(t, list(), e.app.state.ListNodes().Len(), "list count == ListNodes") + + // A second node appears; deleting it removes it from both the list and state. + node2 := e.app.state.CreateRegisteredNodeForTest(e.user, "contract-dut-2") + node2.User = e.user + view2 := e.app.state.PutNodeInStoreForTest(*node2) + id2 := strconv.FormatUint(uint64(view2.ID()), 10) + assert.True(t, containsDeviceID(list(), id2)) + + require.Equal(t, http.StatusOK, e.api.Delete("/api/v2/device/"+id2).Code) + assert.False(t, containsDeviceID(list(), id2)) + + _, ok := e.app.state.GetNodeByID(view2.ID()) + assert.False(t, ok) + + assert.Equal(t, http.StatusNotFound, e.api.Get("/api/v2/tailnet/example.com/devices").Code) +} + +func TestAPIv2Device_SetName(t *testing.T) { + e := newDeviceTestEnv(t) + + require.Equal(t, http.StatusOK, + e.api.Post("/api/v2/device/"+e.deviceID+"/name", map[string]any{"name": "renamed-dut"}).Code) + assert.Equal(t, "renamed-dut", getDevice(t, e.api, e.deviceID).Name) + assert.Equal(t, "renamed-dut", e.srvNode(t).GivenName()) + + // Rename again. + require.Equal(t, http.StatusOK, + e.api.Post("/api/v2/device/"+e.deviceID+"/name", map[string]any{"name": "renamed-again"}).Code) + assert.Equal(t, "renamed-again", getDevice(t, e.api, e.deviceID).Name) + assert.Equal(t, "renamed-again", e.srvNode(t).GivenName()) + + // Invalid DNS label is rejected; the name is unchanged. + bad := e.api.Post("/api/v2/device/"+e.deviceID+"/name", map[string]any{"name": "Invalid_Name!"}) + assert.Equal(t, http.StatusBadRequest, bad.Code) + assert.Contains(t, bad.Body.String(), `"message"`) + assert.Equal(t, "renamed-again", e.srvNode(t).GivenName()) +} + +func TestAPIv2Device_SetTags(t *testing.T) { + e := newDeviceTestEnv(t) + + setTags := func(tags []string) int { + return e.api.Post("/api/v2/device/"+e.deviceID+"/tags", map[string]any{"tags": tags}).Code + } + + // One tag flips the node to tag-owned. + require.Equal(t, http.StatusOK, setTags([]string{"tag:ci"})) + assert.Equal(t, []string{"tag:ci"}, getDevice(t, e.api, e.deviceID).Tags) + n := e.srvNode(t) + assert.True(t, n.IsTagged()) + assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice()) + assert.False(t, n.User().Valid()) + assert.Equal(t, types.TaggedDevices.Username(), getDevice(t, e.api, e.deviceID).User) + + // Two tags (sorted). + require.Equal(t, http.StatusOK, setTags([]string{"tag:ci", "tag:prod"})) + assert.Equal(t, []string{"tag:ci", "tag:prod"}, getDevice(t, e.api, e.deviceID).Tags) + assert.Equal(t, []string{"tag:ci", "tag:prod"}, e.srvNode(t).Tags().AsSlice()) + + // A different single tag replaces the set. + require.Equal(t, http.StatusOK, setTags([]string{"tag:prod"})) + assert.Equal(t, []string{"tag:prod"}, e.srvNode(t).Tags().AsSlice()) + + // Remove-all is a no-op: tags survive. + require.Equal(t, http.StatusOK, setTags([]string{})) + assert.Equal(t, []string{"tag:prod"}, e.srvNode(t).Tags().AsSlice()) + assert.True(t, e.srvNode(t).IsTagged()) + + // An undeclared tag is rejected with 400 (mapError now maps the sentinel), + // and the server-side tags are unchanged. + assert.Equal(t, http.StatusBadRequest, setTags([]string{"tag:nope"})) + assert.Equal(t, []string{"tag:prod"}, e.srvNode(t).Tags().AsSlice()) +} + +func TestAPIv2Device_SetKey(t *testing.T) { + e := newDeviceTestEnv(t) + e.seedExpiry(t, time.Now().Add(24*time.Hour)) + + require.True(t, e.srvNode(t).Expiry().Valid(), "precondition: node has an expiry") + assert.False(t, getDevice(t, e.api, e.deviceID).KeyExpiryDisabled) + + require.Equal(t, http.StatusOK, + e.api.Post("/api/v2/device/"+e.deviceID+"/key", map[string]any{"keyExpiryDisabled": true}).Code) + assert.True(t, getDevice(t, e.api, e.deviceID).KeyExpiryDisabled) + assert.False(t, e.srvNode(t).Expiry().Valid(), "expiry cleared") + + // Re-enable is accepted as a no-op; expiry stays cleared. + require.Equal(t, http.StatusOK, + e.api.Post("/api/v2/device/"+e.deviceID+"/key", map[string]any{"keyExpiryDisabled": false}).Code) + assert.True(t, getDevice(t, e.api, e.deviceID).KeyExpiryDisabled) + assert.False(t, e.srvNode(t).Expiry().Valid()) +} + +func TestAPIv2Device_SetRoutes(t *testing.T) { + e := newDeviceTestEnv(t) + + setRoutes := func(routes []string) apiv2.DeviceRoutes { + t.Helper() + + resp := e.api.Post("/api/v2/device/"+e.deviceID+"/routes", map[string]any{"routes": routes}) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var dr apiv2.DeviceRoutes + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dr)) + + return dr + } + + // One route: enabled reflects it; advertised stays empty (nothing announced). + dr := setRoutes([]string{"10.0.0.0/24"}) + assert.Contains(t, dr.Enabled, "10.0.0.0/24") + assert.Empty(t, dr.Advertised) + assert.Contains(t, getDeviceRoutes(t, e.api, e.deviceID).Enabled, "10.0.0.0/24") + assert.Contains(t, approvedRouteStrings(e.srvNode(t)), "10.0.0.0/24") + assert.Empty(t, e.srvNode(t).AnnouncedRoutes()) + + // Two routes. + setRoutes([]string{"10.0.0.0/24", "192.168.0.0/24"}) + + approved := approvedRouteStrings(e.srvNode(t)) + assert.Contains(t, approved, "10.0.0.0/24") + assert.Contains(t, approved, "192.168.0.0/24") + + // Exit route expands to both families. + setRoutes([]string{"0.0.0.0/0"}) + + approved = approvedRouteStrings(e.srvNode(t)) + assert.Contains(t, approved, "0.0.0.0/0") + assert.Contains(t, approved, "::/0") + + // Clear. + dr = setRoutes([]string{}) + assert.Empty(t, dr.Enabled) + assert.Empty(t, approvedRouteStrings(e.srvNode(t))) + + // Malformed prefix is rejected; routes unchanged. + bad := e.api.Post("/api/v2/device/"+e.deviceID+"/routes", map[string]any{"routes": []string{"not-a-cidr"}}) + assert.Equal(t, http.StatusBadRequest, bad.Code) + assert.Empty(t, approvedRouteStrings(e.srvNode(t))) +} + +func TestAPIv2Device_SetAuthorized(t *testing.T) { + e := newDeviceTestEnv(t) + + require.Equal(t, http.StatusOK, + e.api.Post("/api/v2/device/"+e.deviceID+"/authorized", map[string]any{"authorized": true}).Code) + assert.True(t, getDevice(t, e.api, e.deviceID).Authorized) + + // De-authorize is rejected; the node stays present and authorized. + bad := e.api.Post("/api/v2/device/"+e.deviceID+"/authorized", map[string]any{"authorized": false}) + assert.Equal(t, http.StatusBadRequest, bad.Code) + assert.Contains(t, bad.Body.String(), `"message"`) + assert.True(t, getDevice(t, e.api, e.deviceID).Authorized) +} + +func TestAPIv2Device_Delete(t *testing.T) { + e := newDeviceTestEnv(t) + + require.Equal(t, http.StatusOK, e.api.Delete("/api/v2/device/"+e.deviceID).Code) + assert.Equal(t, http.StatusNotFound, e.api.Get("/api/v2/device/"+e.deviceID).Code) + + _, ok := e.app.state.GetNodeByID(e.nodeID) + assert.False(t, ok, "node gone from the NodeStore") + + // Delete again is 404. + assert.Equal(t, http.StatusNotFound, e.api.Delete("/api/v2/device/"+e.deviceID).Code) +} + +func containsDeviceID(devs []apiv2.Device, id string) bool { + for _, d := range devs { + if d.ID == id || d.NodeID == id { + return true + } + } + + return false +} diff --git a/hscontrol/apiv2_keys_test.go b/hscontrol/apiv2_keys_test.go new file mode 100644 index 000000000..5a296b2df --- /dev/null +++ b/hscontrol/apiv2_keys_test.go @@ -0,0 +1,322 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + "github.com/danielgtaylor/huma/v2/humatest" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// taggedCaps builds capabilities for a tagged auth key. +func taggedCaps(tags ...string) *apiv2.KeyCapabilities { + return &apiv2.KeyCapabilities{ + Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{ + Reusable: true, + Preauthorized: true, + Tags: tags, + }}, + } +} + +// newKeyTestAPI builds an app + v2 API with NO owner user in context. Without the +// auth middleware ownerUser(ctx) is empty, so only the tagged-key path creates; +// the user-owned path (which needs an owning API key) is covered by TestAPIv2. +func newKeyTestAPI(t *testing.T) (*Headscale, humatest.TestAPI) { + t.Helper() + + app := createTestApp(t) + + _, api := humatest.New(t, apiv2.Config()) + apiv2.Register(api, apiv2.Backend{State: app.state}) + + return app, api +} + +// createKey POSTs a key and decodes the create response. +func createKey(t *testing.T, api humatest.TestAPI, req apiv2.CreateKeyRequest) apiv2.Key { + t.Helper() + + resp := api.Post("/api/v2/tailnet/-/keys", req) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var key apiv2.Key + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &key)) + + return key +} + +// getKey GETs a key by id and decodes it. +func getKey(t *testing.T, api humatest.TestAPI, id string) apiv2.Key { + t.Helper() + + resp := api.Get("/api/v2/tailnet/-/keys/" + id) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var key apiv2.Key + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &key)) + + return key +} + +// listKeys GETs the key list and returns the enveloped slice. +func listKeys(t *testing.T, api humatest.TestAPI) []apiv2.Key { + t.Helper() + + resp := api.Get("/api/v2/tailnet/-/keys") + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var list struct { + Keys []apiv2.Key `json:"keys"` + } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &list)) + + return list.Keys +} + +func containsKeyID(keys []apiv2.Key, id string) bool { + for _, k := range keys { + if k.ID == id { + return true + } + } + + return false +} + +// srvKey is the server-side ground truth: the stored PreAuthKey, found by its +// stringified id through ListPreAuthKeys (User is preloaded). +func srvKey(t *testing.T, app *Headscale, id string) types.PreAuthKey { + t.Helper() + + want, err := strconv.ParseUint(id, 10, 64) + require.NoError(t, err) + + keys, err := app.state.ListPreAuthKeys() + require.NoError(t, err) + + for _, k := range keys { + if k.ID == want { + return k + } + } + + t.Fatalf("pre-auth key %s not found server-side", id) + + return types.PreAuthKey{} +} + +func keyCount(t *testing.T, app *Headscale) int { + t.Helper() + + keys, err := app.state.ListPreAuthKeys() + require.NoError(t, err) + + return len(keys) +} + +func TestAPIv2Key_Create_Tagged(t *testing.T) { + app, api := newKeyTestAPI(t) + + created := createKey(t, api, apiv2.CreateKeyRequest{ + Description: "dev access", + ExpirySeconds: 86400, + Capabilities: taggedCaps("tag:test"), + }) + + // (a) create response — Tailscale Key shape, exact seconds (int64 wire). + // ID/Key/Created/Expires are server-assigned; assert their presence apart. + want := apiv2.Key{ + KeyType: "auth", + Description: "dev access", + ExpirySeconds: 86400, + Capabilities: *taggedCaps("tag:test"), + Tags: []string{"tag:test"}, + } + if diff := cmp.Diff(want, created, cmpopts.IgnoreFields(apiv2.Key{}, "ID", "Key", "Created", "Expires")); diff != "" { + t.Errorf("created key mismatch (-want +got):\n%s", diff) + } + + assert.NotEmpty(t, created.ID) + assert.NotEmpty(t, created.Key, "secret returned on create") + assert.NotNil(t, created.Expires) + + // (c) server-side — the stored key. + pak := srvKey(t, app, created.ID) + assert.True(t, pak.Reusable) + assert.False(t, pak.Ephemeral) + assert.Equal(t, []string{"tag:test"}, pak.Tags) + assert.Equal(t, "dev access", pak.Description) + assert.Nil(t, pak.User, "tagged key has no owning user") + require.NotNil(t, pak.CreatedAt) + require.NotNil(t, pak.Expiration) + assert.InDelta(t, 86400, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 2) +} + +func TestAPIv2Key_Create_Permutations(t *testing.T) { + tests := []struct { + name string + req apiv2.CreateKeyRequest + wantReusable bool + wantEphemeral bool + wantDesc string + wantSeconds float64 + }{ + { + name: "single-use", + req: apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{ + Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{Tags: []string{"tag:test"}}}, + }}, + wantSeconds: 7776000, + }, + { + name: "reusable", + req: apiv2.CreateKeyRequest{Capabilities: taggedCaps("tag:test")}, + wantReusable: true, + wantSeconds: 7776000, + }, + { + name: "ephemeral", + req: apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{ + Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{ + Ephemeral: true, + Tags: []string{"tag:test"}, + }}, + }}, + wantEphemeral: true, + wantSeconds: 7776000, + }, + { + name: "with description", + req: apiv2.CreateKeyRequest{Description: "ci", Capabilities: taggedCaps("tag:test")}, + wantReusable: true, + wantDesc: "ci", + wantSeconds: 7776000, + }, + { + name: "explicit expiry", + req: apiv2.CreateKeyRequest{ExpirySeconds: 3600, Capabilities: taggedCaps("tag:test")}, + wantReusable: true, + wantSeconds: 3600, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app, api := newKeyTestAPI(t) + + created := createKey(t, api, tt.req) + assert.Equal(t, tt.wantReusable, created.Capabilities.Devices.Create.Reusable) + assert.Equal(t, tt.wantEphemeral, created.Capabilities.Devices.Create.Ephemeral) + assert.Equal(t, tt.wantDesc, created.Description) + assert.InDelta(t, tt.wantSeconds, float64(created.ExpirySeconds), 2) + + pak := srvKey(t, app, created.ID) + assert.Equal(t, tt.wantReusable, pak.Reusable) + assert.Equal(t, tt.wantEphemeral, pak.Ephemeral) + assert.Equal(t, tt.wantDesc, pak.Description) + require.NotNil(t, pak.CreatedAt) + require.NotNil(t, pak.Expiration) + assert.InDelta(t, tt.wantSeconds, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 2) + }) + } +} + +func TestAPIv2Key_Get(t *testing.T) { + _, api := newKeyTestAPI(t) + + created := createKey(t, api, apiv2.CreateKeyRequest{ + Description: "dev access", + ExpirySeconds: 86400, + Capabilities: taggedCaps("tag:test"), + }) + + got := getKey(t, api, created.ID) + // Get omits the secret (empty Key) and is stable across the round-trip. + want := apiv2.Key{ + ID: created.ID, + KeyType: "auth", + Description: "dev access", + ExpirySeconds: 86400, + Capabilities: *taggedCaps("tag:test"), + Tags: []string{"tag:test"}, + } + if diff := cmp.Diff(want, got, cmpopts.IgnoreFields(apiv2.Key{}, "Created", "Expires")); diff != "" { + t.Errorf("got key mismatch (-want +got):\n%s", diff) + } + + // Unknown id and bad tailnet both 404 with the Tailscale error body. + assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/-/keys/999999").Code) + bad := api.Get("/api/v2/tailnet/example.com/keys/" + created.ID) + assert.Equal(t, http.StatusNotFound, bad.Code) + assert.Contains(t, bad.Body.String(), `"message"`) +} + +func TestAPIv2Key_List(t *testing.T) { + app, api := newKeyTestAPI(t) + + k1 := createKey(t, api, apiv2.CreateKeyRequest{Capabilities: taggedCaps("tag:test")}) + k2 := createKey(t, api, apiv2.CreateKeyRequest{Capabilities: taggedCaps("tag:test")}) + + keys := listKeys(t, api) + assert.True(t, containsKeyID(keys, k1.ID)) + assert.True(t, containsKeyID(keys, k2.ID)) + + // Server-side has both too. + assert.GreaterOrEqual(t, keyCount(t, app), 2) + + bad := api.Get("/api/v2/tailnet/example.com/keys") + assert.Equal(t, http.StatusNotFound, bad.Code) + assert.Contains(t, bad.Body.String(), `"message"`) +} + +func TestAPIv2Key_Delete(t *testing.T) { + app, api := newKeyTestAPI(t) + + created := createKey(t, api, apiv2.CreateKeyRequest{Capabilities: taggedCaps("tag:test")}) + + require.Equal(t, http.StatusOK, api.Delete("/api/v2/tailnet/-/keys/"+created.ID).Code) + + // DELETE soft-revokes (Tailscale-faithful): the key stays retrievable with + // invalid set and a revoked timestamp, and is still listed. + got := getKey(t, api, created.ID) + assert.True(t, got.Invalid, "revoked key reports invalid") + assert.NotNil(t, got.Revoked, "revoked timestamp is populated") + assert.True(t, containsKeyID(listKeys(t, api), created.ID), "revoked key still listed") + + // Server-side: row kept, revoked stamped. + pak := srvKey(t, app, created.ID) + require.NotNil(t, pak.Revoked) + require.Error(t, pak.Validate(), "revoked key is invalid") + + // Delete again -> 404 (already revoked). + assert.Equal(t, http.StatusNotFound, api.Delete("/api/v2/tailnet/-/keys/"+created.ID).Code) + + // The collector hard-deletes keys revoked before the cutoff. + reaped, err := app.state.DestroyRevokedPreAuthKeysBefore(time.Now().Add(time.Minute)) + require.NoError(t, err) + assert.GreaterOrEqual(t, reaped, 1) + assert.False(t, containsKeyID(listKeys(t, api), created.ID), "collector removed the revoked key") + assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/-/keys/"+created.ID).Code) +} + +func TestAPIv2Key_Create_NoTags_NoOwner_400(t *testing.T) { + app, api := newKeyTestAPI(t) + + before := keyCount(t, app) + + resp := api.Post("/api/v2/tailnet/-/keys", apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{}}) + assert.Equal(t, http.StatusBadRequest, resp.Code) + assert.Contains(t, resp.Body.String(), `"message"`) + + // No orphan key was created. + assert.Equal(t, before, keyCount(t, app)) +} diff --git a/hscontrol/apiv2_oauth_matrix_test.go b/hscontrol/apiv2_oauth_matrix_test.go new file mode 100644 index 000000000..3e71d730f --- /dev/null +++ b/hscontrol/apiv2_oauth_matrix_test.go @@ -0,0 +1,280 @@ +package hscontrol + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/scope" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// apiReq issues an arbitrary-method request with an optional bearer token and JSON +// body, returning the status and raw body. It owns the response body. +func apiReq(t *testing.T, method, target, bearer string, body any) (int, []byte) { + t.Helper() + + var r io.Reader + + if body != nil { + b, err := json.Marshal(body) + require.NoError(t, err) + + r = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(t.Context(), method, target, r) + require.NoError(t, err) + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, data +} + +// scopeDenied reports whether a response is a scope-enforcement denial. The scope +// gate runs in the middleware before the handler, so a denial is exactly a 403 +// carrying this message; any other status (200, or a 400/404 from the handler on +// minimal input) means the request passed the scope gate. +func scopeDenied(status int, body []byte) bool { + return status == http.StatusForbidden && + strings.Contains(string(body), "missing the required scope") +} + +// mintScopedToken creates an OAuth client holding exactly scope s (tagged tag:ci so +// tag-requiring scopes are valid) and returns an access token for it. +func mintScopedToken(t *testing.T, baseURL, admin string, s scope.Scope) string { + t.Helper() + + _, secret := createClient(t, baseURL, admin, []string{string(s)}, []string{"tag:ci"}) + + return tokenFor(t, baseURL, secret) +} + +// createAdminAuthKey creates a tagged auth key with the admin key and returns its id. +func createAdminAuthKey(t *testing.T, baseURL, admin string) string { + t.Helper() + + status, body := apiReq(t, http.MethodPost, baseURL+"/api/v2/tailnet/-/keys", admin, apiv2.CreateKeyRequest{ + Capabilities: &apiv2.KeyCapabilities{Devices: apiv2.KeyDeviceCapabilities{ + Create: apiv2.KeyDeviceCreateCapabilities{Reusable: true, Tags: []string{"tag:ci"}}, + }}, + }) + require.Equalf(t, http.StatusOK, status, "create admin auth key: %s", body) + + var key apiv2.Key + require.NoError(t, json.Unmarshal(body, &key)) + + return key.ID +} + +type matrixOp struct { + name string + method string + path string + need scope.Scope + body any + // multiplexed marks the keyType-multiplexed keys ops. They self-enforce in + // the handler and, to avoid an existence oracle, deny an unauthorized token + // with a uniform not-found rather than the middleware's scope-403. So denial + // is "resource not returned" (status != 200), not a specific 403. + multiplexed bool +} + +// TestAPIv2OAuthMatrix_Enforcement is the exhaustive operation×scope cross-product: +// for every scope-gated operation and every scope in the vocabulary, a token +// holding exactly that scope is allowed iff scope.Grants permits it (P3) and denied +// otherwise (P2). +func TestAPIv2OAuthMatrix_Enforcement(t *testing.T) { + app, baseURL, admin := newOAuthTestServer(t) + + // Stable ids for the keyType-multiplexed get-by-id operations. + clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, nil) + authKeyID := createAdminAuthKey(t, baseURL, admin) + _ = app + + ops := []matrixOp{ + {"getDevice", http.MethodGet, "/api/v2/device/1", scope.DevicesCoreRead, nil, false}, + {"listDevices", http.MethodGet, "/api/v2/tailnet/-/devices", scope.DevicesCoreRead, nil, false}, + {"deleteDevice", http.MethodDelete, "/api/v2/device/1", scope.DevicesCore, nil, false}, + {"authorizeDevice", http.MethodPost, "/api/v2/device/1/authorized", scope.DevicesCore, map[string]any{}, false}, + {"setDeviceName", http.MethodPost, "/api/v2/device/1/name", scope.DevicesCore, map[string]any{}, false}, + {"setDeviceTags", http.MethodPost, "/api/v2/device/1/tags", scope.DevicesCore, map[string]any{}, false}, + {"setDeviceKey", http.MethodPost, "/api/v2/device/1/key", scope.DevicesCore, map[string]any{}, false}, + {"setDeviceRoutes", http.MethodPost, "/api/v2/device/1/routes", scope.DevicesRoutes, map[string]any{}, false}, + {"getDeviceRoutes", http.MethodGet, "/api/v2/device/1/routes", scope.DevicesRoutesRead, nil, false}, + {"getACL", http.MethodGet, "/api/v2/tailnet/-/acl", scope.PolicyFileRead, nil, false}, + {"setACL", http.MethodPost, "/api/v2/tailnet/-/acl", scope.PolicyFile, map[string]any{}, false}, + {"getSettings", http.MethodGet, "/api/v2/tailnet/-/settings", scope.FeatureSettingsRead, nil, false}, + {"updateSettings", http.MethodPatch, "/api/v2/tailnet/-/settings", scope.FeatureSettings, map[string]any{}, false}, + {"getKeyClient", http.MethodGet, "/api/v2/tailnet/-/keys/" + clientID, scope.OAuthKeysRead, nil, true}, + {"getKeyAuth", http.MethodGet, "/api/v2/tailnet/-/keys/" + authKeyID, scope.AuthKeysRead, nil, true}, + } + + // One token per scope, reused across every operation. + tokens := make(map[scope.Scope]string, len(scope.Known())) + for _, s := range scope.Known() { + tokens[s] = mintScopedToken(t, baseURL, admin, s) + } + + for _, op := range ops { + for _, s := range scope.Known() { + status, body := apiReq(t, op.method, baseURL+op.path, tokens[s], op.body) + + wantDenied := !scope.Grants([]scope.Scope{s}, op.need) + + // A multiplexed keys op denies via uniform not-found (no existence + // oracle), so denial is "resource not returned"; every other op + // denies with the middleware's scope-403. + denied := scopeDenied(status, body) + if op.multiplexed { + denied = status != http.StatusOK + } + + assert.Equalf(t, wantDenied, denied, + "op %s with scope %q (needs %q): status=%d body=%s", + op.name, s, op.need, status, body) + } + } +} + +// TestAPIv2OAuthMatrix_ScopeNarrowing proves P1 for token minting: a client may +// only mint a token for scopes within its own grant. For every held scope X and +// requested scope Y, the mint succeeds iff scope.Grants([X], Y). +func TestAPIv2OAuthMatrix_ScopeNarrowing(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + for _, held := range scope.Known() { + _, secret := createClient(t, baseURL, admin, []string{string(held)}, []string{"tag:ci"}) + + for _, want := range scope.Known() { + status, m := mintToken(t, baseURL, "", secret, string(want), false) + + wantOK := scope.Grants([]scope.Scope{held}, want) + if wantOK { + assert.Equalf(t, http.StatusOK, status, "held %q narrow to %q: %v", held, want, m) + } else { + assert.Equalf(t, http.StatusBadRequest, status, + "held %q must not mint %q: %v", held, want, m) + assert.Equal(t, "invalid_scope", m["error"]) + } + } + } +} + +// TestAPIv2OAuthMatrix_TagNarrowing proves P1 for tags at mint time: a client may +// only mint a token for tags within its grant (closing the /oauth/token tags-param +// path). A client with the "all" scope may request any tag. +func TestAPIv2OAuthMatrix_TagNarrowing(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + _, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:a", "tag:b"}) + + // In-grant tags mint; an out-of-grant tag is rejected. + status, m := mintToken(t, baseURL, "", secret, "", false) + require.Equalf(t, http.StatusOK, status, "%v", m) + + for _, tag := range []string{"tag:a", "tag:b"} { + st, mm := mintTokenWithTags(t, baseURL, secret, tag) + assert.Equalf(t, http.StatusOK, st, "in-grant tag %q: %v", tag, mm) + } + + st, mm := mintTokenWithTags(t, baseURL, secret, "tag:c") + assert.Equal(t, http.StatusBadRequest, st, mm) + assert.Equal(t, "invalid_target", mm["error"]) + + // An all-scope client may request any tag. + _, allSecret := createClient(t, baseURL, admin, []string{"all"}, []string{"tag:a"}) + stAll, mAll := mintTokenWithTags(t, baseURL, allSecret, "tag:anything") + assert.Equalf(t, http.StatusOK, stAll, "all-scope client may assign any tag: %v", mAll) +} + +// TestAPIv2OAuthMatrix_Lifecycle proves expired and revoked credentials are denied +// at the HTTP layer, and that an admin API key bypasses scope checks. +func TestAPIv2OAuthMatrix_Lifecycle(t *testing.T) { + app, baseURL, admin := newOAuthTestServer(t) + + devicesPath := baseURL + "/api/v2/tailnet/-/devices" + + // Expired token → 401. + _, client, err := app.state.CreateOAuthClient([]string{"devices:core:read"}, []string{"tag:ci"}, "expired", nil) + require.NoError(t, err) + + past := time.Now().Add(-time.Hour) + expiredTok, _, err := app.state.MintAccessToken(client.ClientID, []string{"devices:core:read"}, nil, &past) + require.NoError(t, err) + + status, _ := apiReq(t, http.MethodGet, devicesPath, expiredTok, nil) + assert.Equal(t, http.StatusUnauthorized, status, "expired token must be rejected") + + // Revoked client → its token is denied. + _, revClient, err := app.state.CreateOAuthClient([]string{"devices:core:read"}, []string{"tag:ci"}, "revoked", nil) + require.NoError(t, err) + + future := time.Now().Add(time.Hour) + revTok, _, err := app.state.MintAccessToken(revClient.ClientID, []string{"devices:core:read"}, nil, &future) + require.NoError(t, err) + + // Works before revocation. + okStatus, okBody := apiReq(t, http.MethodGet, devicesPath, revTok, nil) + assert.Falsef(t, scopeDenied(okStatus, okBody), "token should pass the scope gate before revoke: %d", okStatus) + + require.NoError(t, app.state.RevokeOAuthClient(revClient.ClientID)) + + revStatus, _ := apiReq(t, http.MethodGet, devicesPath, revTok, nil) + assert.Equal(t, http.StatusUnauthorized, revStatus, "token of a revoked client must be rejected") + + // Admin API key bypasses scope checks: it reaches a devices op that a minimal + // (feature_settings:read) token cannot. + minimalTok := mintScopedToken(t, baseURL, admin, scope.FeatureSettingsRead) + minStatus, minBody := apiReq(t, http.MethodGet, devicesPath, minimalTok, nil) + assert.True(t, scopeDenied(minStatus, minBody), "minimal token must be scope-denied on devices") + + adminStatus, adminBody := apiReq(t, http.MethodGet, devicesPath, admin, nil) + assert.Falsef(t, scopeDenied(adminStatus, adminBody), + "admin key is all-access and must not be scope-denied: %d %s", adminStatus, adminBody) +} + +// mintTokenWithTags mints with a tags narrowing parameter. +func mintTokenWithTags(t *testing.T, baseURL, secret, tags string) (int, map[string]any) { + t.Helper() + + form := fmt.Sprintf("grant_type=client_credentials&client_secret=%s&tags=%s", secret, tags) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, + baseURL+"/api/v2/oauth/token", strings.NewReader(form)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + var m map[string]any + + _ = json.Unmarshal(data, &m) + + return resp.StatusCode, m +} diff --git a/hscontrol/apiv2_oauth_test.go b/hscontrol/apiv2_oauth_test.go new file mode 100644 index 000000000..c3f61043c --- /dev/null +++ b/hscontrol/apiv2_oauth_test.go @@ -0,0 +1,511 @@ +package hscontrol + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newOAuthTestServer builds the full v2 API (the auth+scope middleware and the +// OAuth token endpoint, which the humatest harness in apiv2_keys_test.go does +// not mount) over a real httptest server, returning the app, its base URL, and +// an all-access admin API key. +func newOAuthTestServer(t *testing.T) (*Headscale, string, string) { + t.Helper() + + app := createTestApp(t) + + // Tag creation now requires the tag to exist in policy (matching + // SetNodeTags), so define the tags these tests assign. Tests needing + // specific tag ownership (e.g. delegation) override this policy. + const policy = `{"tagOwners":{"tag:a":[],"tag:b":[],"tag:c":[],"tag:ci":[],"tag:k8s":[],"tag:k8s-operator":[],"tag:other":[],"tag:anything":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + + _, err := app.state.SetPolicy([]byte(policy)) + require.NoError(t, err) + _, err = app.state.SetPolicyInDB(policy) + require.NoError(t, err) + _, err = app.state.ReloadPolicy() + require.NoError(t, err) + + mux, _ := apiv2.Handler(apiv2.Backend{State: app.state, Change: app.Change, Cfg: app.cfg}) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + adminKey, _, err := app.state.CreateAPIKey(nil) + require.NoError(t, err) + + return app, srv.URL, adminKey +} + +// apiPost POSTs a JSON body with an optional bearer token, returning the status +// and raw body. It owns the response body so callers never leak it. +func apiPost(t *testing.T, target, bearer string, body any) (int, []byte) { + t.Helper() + + b, err := json.Marshal(body) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, target, bytes.NewReader(b)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, data +} + +// apiGet GETs a target with an optional bearer token, returning the status. It +// drains and closes the response body so callers never leak it. +func apiGet(t *testing.T, target, bearer string) int { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, target, nil) + require.NoError(t, err) + + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + _, _ = io.Copy(io.Discard, resp.Body) + + return resp.StatusCode +} + +// createClient creates an OAuth client through the v2 keys API as the bearer +// credential, returning the client id and the once-shown secret. +func createClient(t *testing.T, baseURL, bearer string, scopes, tags []string) (string, string) { + t.Helper() + + status, body := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", bearer, apiv2.CreateKeyRequest{ + KeyType: "client", + Scopes: scopes, + Tags: tags, + }) + require.Equalf(t, http.StatusOK, status, "create client: %s", body) + + var key apiv2.Key + require.NoError(t, json.Unmarshal(body, &key)) + assert.Equal(t, "client", key.KeyType) + require.NotEmpty(t, key.ID, "client id returned") + require.NotEmpty(t, key.Key, "secret returned once on create") + + return key.ID, key.Key +} + +// mintToken runs the client-credentials grant. scope is an optional space-delimited +// narrowing of the client's scopes. credsInBasic sends the secret via HTTP Basic +// instead of the body. Returns the status and decoded JSON body. +func mintToken(t *testing.T, baseURL, clientID, secret, scope string, credsInBasic bool) (int, map[string]any) { + t.Helper() + + form := url.Values{"grant_type": {"client_credentials"}} + if scope != "" { + form.Set("scope", scope) + } + + if !credsInBasic { + form.Set("client_secret", secret) + } + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, + baseURL+"/api/v2/oauth/token", strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + if credsInBasic { + req.SetBasicAuth(clientID, secret) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + var m map[string]any + + _ = json.Unmarshal(data, &m) + + return resp.StatusCode, m +} + +// tokenFor mints and returns a bearer access token for the client. +func tokenFor(t *testing.T, baseURL, secret string) string { + t.Helper() + + status, m := mintToken(t, baseURL, "", secret, "", false) + require.Equalf(t, http.StatusOK, status, "mint token: %v", m) + + tok, _ := m["access_token"].(string) + require.NotEmpty(t, tok) + + return tok +} + +// createTaggedKey attempts to create a tagged auth key and returns the HTTP +// status, so allow/deny is asserted by the caller. +func createTaggedKey(t *testing.T, baseURL, bearer string, tags []string) int { + t.Helper() + + status, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", bearer, apiv2.CreateKeyRequest{ + Capabilities: &apiv2.KeyCapabilities{Devices: apiv2.KeyDeviceCapabilities{ + Create: apiv2.KeyDeviceCreateCapabilities{Reusable: true, Tags: tags}, + }}, + }) + + return status +} + +func TestAPIv2OAuth_TokenEndpoint(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + clientID, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"}) + + // The secret embeds the client id (Tailscale's derive-from-secret trick). + assert.Contains(t, secret, clientID) + + // Mint via the request body. + status, m := mintToken(t, baseURL, clientID, secret, "", false) + require.Equalf(t, http.StatusOK, status, "%v", m) + assert.Equal(t, "Bearer", m["token_type"]) + assert.InDelta(t, 3600, m["expires_in"], 0, "fixed 1h, in seconds") + tok, _ := m["access_token"].(string) + assert.Contains(t, tok, "hskey-oauthtok-", "distinct prefix from admin keys") + + // Mint via HTTP Basic (x/oauth2 auto-detect probes Basic first). + statusBasic, mBasic := mintToken(t, baseURL, clientID, secret, "", true) + require.Equalf(t, http.StatusOK, statusBasic, "%v", mBasic) + assert.NotEmpty(t, mBasic["access_token"]) + + // Bad credentials → RFC 6749 invalid_client. + statusBad, mBad := mintToken(t, baseURL, clientID, "hskey-client-deadbeefdead-"+stringOf("0", 64), "", false) + assert.Equal(t, http.StatusUnauthorized, statusBad) + assert.Equal(t, "invalid_client", mBad["error"]) + + // The token response carries a bearer credential and must not be cached + // (RFC 6749 §5.1). mintToken consumes the body, so probe the header raw. + form := url.Values{"grant_type": {"client_credentials"}, "client_secret": {secret}} + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, + baseURL+"/api/v2/oauth/token", strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) +} + +func TestAPIv2OAuth_ScopeEnforcement(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + // A write-scoped token may create an auth key within its tag grant... + _, writeSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"}) + writeTok := tokenFor(t, baseURL, writeSecret) + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, writeTok, []string{"tag:ci"}), + "write scope + in-grant tag allowed") + + // ...but not with a tag outside its grant. + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, writeTok, []string{"tag:other"}), + "tag outside grant denied") + + // A read-only token is denied the write. + _, readSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"}) + readTok := tokenFor(t, baseURL, readSecret) + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, readTok, []string{"tag:ci"}), + "read scope denied write") + + // The wrong write scope (devices:core) cannot create auth keys. + _, devSecret := createClient(t, baseURL, admin, []string{"devices:core"}, []string{"tag:ci"}) + devTok := tokenFor(t, baseURL, devSecret) + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, devTok, []string{"tag:ci"}), + "wrong scope denied") + + // The "all" super-scope grants auth_keys. + _, allSecret := createClient(t, baseURL, admin, []string{"all"}, []string{"tag:ci"}) + allTok := tokenFor(t, baseURL, allSecret) + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, allTok, []string{"tag:ci"}), + "all grants auth_keys") + + // A token minted from the all-powerful client but narrowed to read-only is + // itself denied writes: narrowing is enforced on the minted token. + statusNarrow, mNarrow := mintToken(t, baseURL, "", allSecret, "auth_keys:read", false) + require.Equalf(t, http.StatusOK, statusNarrow, "narrow mint: %v", mNarrow) + narrowed, _ := mNarrow["access_token"].(string) + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, narrowed, []string{"tag:ci"}), + "token narrowed to :read denied write") +} + +func TestAPIv2OAuth_ClientManagementScopes(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + // An oauth_keys token may create a client within its own grant... + _, okSecret := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"}) + okTok := tokenFor(t, baseURL, okSecret) + status, body := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", okTok, apiv2.CreateKeyRequest{ + KeyType: "client", Scopes: []string{"oauth_keys:read"}, + }) + assert.Equalf(t, http.StatusOK, status, "oauth_keys creates an in-grant client: %s", body) + + // ...but may NOT escalate by granting the new client a scope the token lacks. + statusEsc, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", okTok, apiv2.CreateKeyRequest{ + KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"}, + }) + assert.Equal(t, http.StatusForbidden, statusEsc, "oauth_keys token cannot mint a broader client") + + // A token without oauth_keys cannot manage clients at all. + _, akSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"}) + akTok := tokenFor(t, baseURL, akSecret) + statusDeny, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", akTok, apiv2.CreateKeyRequest{ + KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"}, + }) + assert.Equal(t, http.StatusForbidden, statusDeny, "auth_keys (no oauth_keys) cannot create a client") +} + +func TestAPIv2OAuth_TagOwnedBy(t *testing.T) { + app, baseURL, admin := newOAuthTestServer(t) + + // tag:k8s is owned by tag:k8s-operator: the operator's tag delegation. + // tag:other exists but is owned by no one, so it tests grant denial (403) + // rather than tag-not-in-policy (400). + const policy = `{"tagOwners":{"tag:k8s-operator":[],"tag:k8s":["tag:k8s-operator"],"tag:other":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + + _, err := app.state.SetPolicy([]byte(policy)) + require.NoError(t, err) + _, err = app.state.SetPolicyInDB(policy) + require.NoError(t, err) + _, err = app.state.ReloadPolicy() + require.NoError(t, err) + + _, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:k8s-operator"}) + tok := tokenFor(t, baseURL, secret) + + // Exact-match tag: allowed. + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, tok, []string{"tag:k8s-operator"}), + "a token may use its own tag") + + // Owned-by tag: allowed (tag:k8s is owned by tag:k8s-operator). + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, tok, []string{"tag:k8s"}), + "a token may use a tag owned by its tag") + + // Unrelated tag: denied. + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, tok, []string{"tag:other"}), + "a token may not use an unowned tag") +} + +// TestAPIv2OAuth_NoClientExistenceOracle asserts a token that cannot read OAuth +// clients gets the same response for a real client id as for a missing key, so +// it cannot enumerate which ids are OAuth clients. +func TestAPIv2OAuth_NoClientExistenceOracle(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"}) + + // A token holding only auth_keys:read (no oauth_keys:read). + _, akSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"}) + akTok := tokenFor(t, baseURL, akSecret) + + statusReal := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/"+clientID, akTok) + statusMissing := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/deadbeefdead", akTok) + + assert.Equal(t, statusMissing, statusReal, + "a token without oauth_keys:read must not distinguish a real client id from a missing key") + assert.NotEqual(t, http.StatusOK, statusReal, "the client must not be readable") + + // A token that does hold oauth_keys:read can read the client. + _, okSecret := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, []string{"tag:ci"}) + okTok := tokenFor(t, baseURL, okSecret) + + statusOK := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/"+clientID, okTok) + assert.Equal(t, http.StatusOK, statusOK, "oauth_keys:read may read the client") +} + +// TestAPIv2OAuth_SetDeviceTagsGrant asserts a devices:core token may only set +// tags within its grant on a device. Without the grant check, the scope alone +// would let a token stamp any existing policy tag (e.g. tag:other) onto any node. +func TestAPIv2OAuth_SetDeviceTagsGrant(t *testing.T) { + app, baseURL, admin := newOAuthTestServer(t) + + // A registered, user-owned node to retag. + user := app.state.CreateUserForTest("dut") + node := app.state.CreateRegisteredNodeForTest(user, "dut") + node.User = user + view := app.state.PutNodeInStoreForTest(*node) + devURL := baseURL + "/api/v2/device/" + view.StringID() + "/tags" + + // A devices:core token granted only tag:ci. + _, secret := createClient(t, baseURL, admin, []string{"devices:core"}, []string{"tag:ci"}) + tok := tokenFor(t, baseURL, secret) + + // In-grant tag is allowed. + st, body := apiPost(t, devURL, tok, map[string]any{"tags": []string{"tag:ci"}}) + assert.Equalf(t, http.StatusOK, st, "in-grant tag: %s", body) + + // An existing policy tag outside the token's grant is denied, not silently set. + st, _ = apiPost(t, devURL, tok, map[string]any{"tags": []string{"tag:other"}}) + assert.Equal(t, http.StatusForbidden, st, "out-of-grant tag must be denied") + + // An admin API key is unrestricted. + st, body = apiPost(t, devURL, admin, map[string]any{"tags": []string{"tag:other"}}) + assert.Equalf(t, http.StatusOK, st, "admin may set any policy tag: %s", body) +} + +// TestAPIv2OAuth_UndefinedTagRejected asserts an OAuth token cannot create a +// client or auth key carrying a tag absent from policy (matching SetNodeTags), +// while an admin key retains the historical syntax-only validation. +func TestAPIv2OAuth_UndefinedTagRejected(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + // A token that may create clients and auth keys, holding tag:ci (in policy). + _, secret := createClient(t, baseURL, admin, []string{"oauth_keys", "auth_keys"}, []string{"tag:ci"}) + tok := tokenFor(t, baseURL, secret) + + // A token-created auth key with a tag not in policy is rejected. + assert.Equal(t, http.StatusBadRequest, createTaggedKey(t, baseURL, tok, []string{"tag:undefined"}), + "token may not create an auth key with an undefined tag") + + // A token-created client with a tag not in policy is rejected. + st, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", tok, apiv2.CreateKeyRequest{ + KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:undefined"}, + }) + assert.Equal(t, http.StatusBadRequest, st, "token may not create a client with an undefined tag") + + // An admin key keeps the historical behaviour: a syntactically valid but + // undefined tag is accepted (consistent with the v1/CLI pre-auth-key path). + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, admin, []string{"tag:adminhistorical"}), + "admin retains syntax-only tag validation") +} + +// TestAPIv2OAuth_DenialBranches covers the token-endpoint and bearer-dispatch +// failure paths: each must fail closed with the right status and no session. +func TestAPIv2OAuth_DenialBranches(t *testing.T) { + _, baseURL, _ := newOAuthTestServer(t) + tokenURL := baseURL + "/api/v2/oauth/token" + keysURL := baseURL + "/api/v2/tailnet/-/keys" + + post := func(form string) (int, map[string]any) { + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, tokenURL, strings.NewReader(form)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + var m map[string]any + + _ = json.Unmarshal(data, &m) + + return resp.StatusCode, m + } + + // No credentials → invalid_client. + st, m := post("grant_type=client_credentials") + assert.Equal(t, http.StatusUnauthorized, st) + assert.Equal(t, "invalid_client", m["error"]) + + // Wrong grant_type → unsupported_grant_type. + st, m = post("grant_type=authorization_code&client_secret=x") + assert.Equal(t, http.StatusBadRequest, st) + assert.Equal(t, "unsupported_grant_type", m["error"]) + + // Unknown secret → invalid_client. + st, m = post("grant_type=client_credentials&client_secret=not-a-real-secret") + assert.Equal(t, http.StatusUnauthorized, st) + assert.Equal(t, "invalid_client", m["error"]) + + // Bearer dispatch: every malformed/unknown bearer is unauthorized. + for _, bearer := range []string{ + "hskey-oauthtok-deadbeefdead-" + stringOf("0", 64), // well-formed prefix, unknown token + "hskey-oauthtok-garbage", // malformed OAuth token + "hskey-client-deadbeefdead-" + stringOf("0", 64), // client secret presented as API bearer + "totally-bogus", + } { + assert.Equalf(t, http.StatusUnauthorized, apiGet(t, keysURL, bearer), + "bearer %q must be unauthorized", bearer) + } +} + +// TestAPIv2OAuth_KeysMultiplexIsolation asserts the multiplexed keys endpoint +// keeps the two kinds isolated by scope: a token cannot list or delete a kind it +// lacks the scope for, and an admin key sees both. +func TestAPIv2OAuth_KeysMultiplexIsolation(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + keysURL := baseURL + "/api/v2/tailnet/-/keys" + + clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"}) + require.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, admin, []string{"tag:ci"})) + + listKinds := func(bearer string) map[string]int { + st, body := apiReq(t, http.MethodGet, keysURL, bearer, nil) + require.Equalf(t, http.StatusOK, st, "%s", body) + + var out struct { + Keys []apiv2.Key `json:"keys"` + } + + require.NoError(t, json.Unmarshal(body, &out)) + + kinds := map[string]int{} + for _, k := range out.Keys { + kinds[k.KeyType]++ + } + + return kinds + } + + _, akReadSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"}) + akKinds := listKinds(tokenFor(t, baseURL, akReadSecret)) + assert.Zero(t, akKinds["client"], "auth_keys:read must not see OAuth clients") + assert.Positive(t, akKinds["auth"], "auth_keys:read sees auth keys") + + _, okReadSecret := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, []string{"tag:ci"}) + okKinds := listKinds(tokenFor(t, baseURL, okReadSecret)) + assert.Zero(t, okKinds["auth"], "oauth_keys:read must not see auth keys") + assert.Positive(t, okKinds["client"], "oauth_keys:read sees OAuth clients") + + adminKinds := listKinds(admin) + assert.Positive(t, adminKinds["auth"]) + assert.Positive(t, adminKinds["client"]) + + // An auth_keys (write) token cannot delete an OAuth client; it survives. + _, akWriteSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"}) + stDel, _ := apiReq(t, http.MethodDelete, keysURL+"/"+clientID, tokenFor(t, baseURL, akWriteSecret), nil) + assert.NotEqual(t, http.StatusOK, stDel, "auth_keys token must not delete an OAuth client") + assert.Positive(t, listKinds(admin)["client"], "client survives the denied delete") + + // An oauth_keys (write) token deletes the client. + _, okWriteSecret := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"}) + stDel2, body2 := apiReq(t, http.MethodDelete, keysURL+"/"+clientID, tokenFor(t, baseURL, okWriteSecret), nil) + assert.Equalf(t, http.StatusOK, stDel2, "oauth_keys deletes the client: %s", body2) +} + +func stringOf(s string, n int) string { + return strings.Repeat(s, n) +} diff --git a/hscontrol/apiv2_settings_test.go b/hscontrol/apiv2_settings_test.go new file mode 100644 index 000000000..62b88bd75 --- /dev/null +++ b/hscontrol/apiv2_settings_test.go @@ -0,0 +1,150 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/danielgtaylor/huma/v2/humatest" + "github.com/google/go-cmp/cmp" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// settingsAPIWithConfig registers the v2 API over a Backend carrying cfg and a +// zero State — the settings GET reads only b.Cfg, so the other handlers (which +// would need State) are simply never hit. +func settingsAPIWithConfig(t *testing.T, cfg *types.Config) humatest.TestAPI { + t.Helper() + + _, api := humatest.New(t, apiv2.Config()) + apiv2.Register(api, apiv2.Backend{Cfg: cfg}) + + return api +} + +func getSettings(t *testing.T, api humatest.TestAPI) apiv2.TailnetSettings { + t.Helper() + + resp := api.Get("/api/v2/tailnet/-/settings") + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var s apiv2.TailnetSettings + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &s)) + + return s +} + +// TestAPIv2SettingsComputedFields validates the GET mapping for each field that +// is computed from config — the branches the default-config roundtrip never +// exercises. Expectations live in struct fields, not name branches. +func TestAPIv2SettingsComputedFields(t *testing.T) { + tests := []struct { + name string + cfg types.Config + wantHTTPS bool + wantKeyDurationDays int + wantACLsExtManaged bool + }{ + { + name: "defaults", + cfg: types.Config{}, + }, + { + name: "https via cert path", + cfg: types.Config{TLS: types.TLSConfig{CertPath: "/x/cert.pem"}}, + wantHTTPS: true, + }, + { + name: "https via letsencrypt", + cfg: types.Config{ + TLS: types.TLSConfig{LetsEncrypt: types.LetsEncryptConfig{Hostname: "hs.example.com"}}, + }, + wantHTTPS: true, + }, + { + name: "key duration 7d", + cfg: types.Config{Node: types.NodeConfig{Expiry: 7 * 24 * time.Hour}}, + wantKeyDurationDays: 7, + }, + { + name: "key duration 90d", + cfg: types.Config{Node: types.NodeConfig{Expiry: 90 * 24 * time.Hour}}, + wantKeyDurationDays: 90, + }, + { + name: "key duration truncates", + cfg: types.Config{Node: types.NodeConfig{Expiry: 36 * time.Hour}}, + wantKeyDurationDays: 1, + }, + { + name: "acls externally managed in file mode", + cfg: types.Config{Policy: types.PolicyConfig{Mode: types.PolicyModeFile}}, + wantACLsExtManaged: true, + }, + { + name: "acls db mode", + cfg: types.Config{Policy: types.PolicyConfig{Mode: types.PolicyModeDB}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := getSettings(t, settingsAPIWithConfig(t, &tt.cfg)) + + assert.Equal(t, tt.wantHTTPS, s.HTTPSEnabled) + assert.Equal(t, tt.wantKeyDurationDays, s.DevicesKeyDurationDays) + assert.Equal(t, tt.wantACLsExtManaged, s.ACLsExternallyManagedOn) + assert.Equal(t, "none", s.UsersRoleAllowedToJoinExternalTailnets) + }) + } +} + +// TestAPIv2SettingsConstantOffFields pins the hardcoded-off fields: +// even with every config knob set, they must not pick up signal. +func TestAPIv2SettingsConstantOffFields(t *testing.T) { + cfg := types.Config{ + TLS: types.TLSConfig{CertPath: "/x/cert.pem"}, + Node: types.NodeConfig{Expiry: 90 * 24 * time.Hour}, + Policy: types.PolicyConfig{Mode: types.PolicyModeFile}, + } + + s := getSettings(t, settingsAPIWithConfig(t, &cfg)) + + // Only the four computed fields reflect cfg; everything else stays off. + want := apiv2.TailnetSettings{ + ACLsExternallyManagedOn: true, + DevicesKeyDurationDays: 90, + HTTPSEnabled: true, + UsersRoleAllowedToJoinExternalTailnets: "none", + } + if diff := cmp.Diff(want, s); diff != "" { + t.Errorf("settings mismatch (-want +got):\n%s", diff) + } +} + +// TestAPIv2SettingsPatchUnsupported confirms writes are rejected and inert. +func TestAPIv2SettingsPatchUnsupported(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + patch := api.Patch("/api/v2/tailnet/-/settings", map[string]any{"devicesApprovalOn": true}) + assert.Equal(t, http.StatusNotImplemented, patch.Code) + assert.Contains(t, patch.Body.String(), `"message"`) + + // The rejected PATCH did not mutate anything. + assert.False(t, getSettings(t, api).DevicesApprovalOn) +} + +// TestAPIv2SettingsNonDefaultTailnet404 — the tailnet check runs before the +// 501, so a bad tailnet is 404 on both verbs. +func TestAPIv2SettingsNonDefaultTailnet404(t *testing.T) { + api := settingsAPIWithConfig(t, &types.Config{}) + + assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/example.com/settings").Code) + assert.Equal(t, http.StatusNotFound, + api.Patch("/api/v2/tailnet/example.com/settings", map[string]any{}).Code) +} diff --git a/hscontrol/apiv2_users_test.go b/hscontrol/apiv2_users_test.go new file mode 100644 index 000000000..38efd9a68 --- /dev/null +++ b/hscontrol/apiv2_users_test.go @@ -0,0 +1,188 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + + "github.com/danielgtaylor/huma/v2/humatest" + "github.com/google/go-cmp/cmp" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// userID stringifies a test user's id the way the API emits it. +func userID(u *types.User) string { + return strconv.FormatUint(uint64(u.ID), 10) +} + +// getUserByID GETs a user by id and decodes it. +func getUserByID(t *testing.T, api humatest.TestAPI, id string) apiv2.User { + t.Helper() + + resp := api.Get("/api/v2/users/" + id) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var user apiv2.User + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &user)) + + return user +} + +// listUsers GETs the user list (with an optional raw query string) and returns +// the decoded slice plus the raw body, so tests can pin the envelope shape. +func listUsers(t *testing.T, api humatest.TestAPI, query string) ([]apiv2.User, string) { + t.Helper() + + resp := api.Get("/api/v2/tailnet/-/users" + query) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var env struct { + Users []apiv2.User `json:"users"` + } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &env)) + + return env.Users, resp.Body.String() +} + +func containsUserID(users []apiv2.User, id string) bool { + for _, u := range users { + if u.ID == id { + return true + } + } + + return false +} + +func TestAPIv2User_Get(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + user := app.state.CreateUserForTest("golden") + + got := getUserByID(t, api, userID(user)) + + // Shape for a user with no devices: identity mapped, unmodelled fields fixed. + want := apiv2.User{ + ID: userID(user), + DisplayName: "golden", + LoginName: "golden", + TailnetID: "1", + Created: user.CreatedAt, + Type: "member", + Role: "member", + Status: "active", + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("user mismatch (-want +got):\n%s", diff) + } +} + +func TestAPIv2User_Get_DeviceCount(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + user := app.state.CreateUserForTest("owner") + + const wantDevices = 3 + for i := range wantDevices { + node := app.state.CreateRegisteredNodeForTest(user, "dut-"+strconv.Itoa(i)) + app.state.PutNodeInStoreForTest(*node) + } + + got := getUserByID(t, api, userID(user)) + assert.Equal(t, wantDevices, got.DeviceCount, "deviceCount aggregates the user's nodes") +} + +func TestAPIv2User_Get_NotFound(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + tests := []struct { + name string + id string + }{ + {name: "unknown numeric id", id: "999999"}, + {name: "non-numeric id", id: "not-a-number"}, + // The tagged-devices pseudo-user is never a real DB row. + {name: "tagged-devices pseudo-user", id: strconv.Itoa(types.TaggedDevicesUserID)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := api.Get("/api/v2/users/" + tt.id) + assert.Equal(t, http.StatusNotFound, resp.Code) + assert.Contains(t, resp.Body.String(), `"message"`, "Tailscale error shape") + }) + } +} + +func TestAPIv2User_List(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + u1 := app.state.CreateUserForTest("alice") + u2 := app.state.CreateUserForTest("bob") + + users, body := listUsers(t, api, "") + assert.Contains(t, body, `"users"`, "list is enveloped under users") + assert.True(t, containsUserID(users, userID(u1))) + assert.True(t, containsUserID(users, userID(u2))) + assert.False(t, containsUserID(users, strconv.Itoa(types.TaggedDevicesUserID)), + "tagged-devices pseudo-user is not listed") + + for _, u := range users { + assert.NotEmpty(t, u.ID, "every user has an id") + assert.NotEmpty(t, u.LoginName, "every user has a login name") + assert.Equal(t, "member", u.Type) + assert.Equal(t, "active", u.Status) + } +} + +func TestAPIv2User_List_Filters(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + app.state.CreateUserForTest("alice") + app.state.CreateUserForTest("bob") + + tests := []struct { + name string + query string + wantEmpty bool + }{ + {name: "no filter", query: "", wantEmpty: false}, + {name: "type member", query: "?type=member", wantEmpty: false}, + {name: "role member", query: "?role=member", wantEmpty: false}, + {name: "member and member", query: "?type=member&role=member", wantEmpty: false}, + {name: "type shared", query: "?type=shared", wantEmpty: true}, + {name: "role admin", query: "?role=admin", wantEmpty: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + users, body := listUsers(t, api, tt.query) + assert.Contains(t, body, `"users"`) + assert.NotContains(t, body, "null", "empty list marshals as [] not null") + + if tt.wantEmpty { + assert.Empty(t, users) + } else { + assert.NotEmpty(t, users) + } + }) + } +} + +func TestAPIv2User_List_BadTailnet(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + resp := api.Get("/api/v2/tailnet/example.com/users") + assert.Equal(t, http.StatusNotFound, resp.Code) + assert.Contains(t, resp.Body.String(), `"message"`) +} diff --git a/hscontrol/app.go b/hscontrol/app.go index c183c3bbe..fbbd6683e 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -24,9 +24,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/metrics" - grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" - "github.com/juanfont/headscale" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + apiv1 "github.com/juanfont/headscale/hscontrol/api/v1" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" "github.com/juanfont/headscale/hscontrol/capver" "github.com/juanfont/headscale/hscontrol/db" "github.com/juanfont/headscale/hscontrol/derp" @@ -37,22 +36,12 @@ import ( "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types/change" "github.com/juanfont/headscale/hscontrol/util" - zerolog "github.com/philip-bui/grpc-zerolog" "github.com/pkg/profile" - zl "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/sasha-s/go-deadlock" "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" "golang.org/x/sync/errgroup" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/peer" - "google.golang.org/grpc/reflection" - "google.golang.org/grpc/status" "tailscale.com/envknob" "tailscale.com/tailcfg" "tailscale.com/types/dnstype" @@ -84,7 +73,6 @@ func init() { } const ( - AuthPrefix = "Bearer " updateInterval = 5 * time.Second privateKeyFileMode = 0o600 headscaleDirPerm = 0o700 @@ -204,13 +192,15 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) { if cfg.PrefixV4 != nil { magicDNSDomains = append( magicDNSDomains, - util.GenerateIPv4DNSRootDomain(*cfg.PrefixV4)...) + util.GenerateIPv4DNSRootDomain(*cfg.PrefixV4)..., + ) } if cfg.PrefixV6 != nil { magicDNSDomains = append( magicDNSDomains, - util.GenerateIPv6DNSRootDomain(*cfg.PrefixV6)...) + util.GenerateIPv6DNSRootDomain(*cfg.PrefixV6)..., + ) } // we might have routes already from Split DNS @@ -283,7 +273,7 @@ func (h *Headscale) scheduledTasks(ctx context.Context) { lastExpiryCheck := time.Unix(0, 0) - derpTickerChan := make(<-chan time.Time) + var derpTickerChan <-chan time.Time if h.cfg.DERP.AutoUpdate && h.cfg.DERP.UpdateFrequency != 0 { derpTicker := time.NewTicker(h.cfg.DERP.UpdateFrequency) @@ -295,8 +285,6 @@ func (h *Headscale) scheduledTasks(ctx context.Context) { var extraRecordsUpdate <-chan []tailcfg.DNSRecord if h.extraRecordMan != nil { extraRecordsUpdate = h.extraRecordMan.UpdateCh() - } else { - extraRecordsUpdate = make(chan []tailcfg.DNSRecord) } var ( @@ -320,16 +308,46 @@ func (h *Headscale) scheduledTasks(ctx context.Context) { Dur("interval", h.cfg.Node.Routes.HA.ProbeInterval). Dur("timeout", h.cfg.Node.Routes.HA.ProbeTimeout). Msg("HA subnet router health probing enabled") - } else { - haHealthChan = make(<-chan time.Time) } + var revokedKeyGCChan <-chan time.Time + + if h.cfg.PreAuthKeys.RevokedRetention > 0 { + revokedKeyTicker := time.NewTicker(time.Hour) + defer revokedKeyTicker.Stop() + + revokedKeyGCChan = revokedKeyTicker.C + } + + // OAuth access tokens are short-lived (1h) and re-minted on demand; reap + // expired rows hourly so the table stays bounded. + accessTokenTicker := time.NewTicker(time.Hour) + defer accessTokenTicker.Stop() + for { select { case <-ctx.Done(): log.Info().Caller().Msg("scheduled task worker is shutting down.") return + case <-revokedKeyGCChan: + cutoff := time.Now().Add(-h.cfg.PreAuthKeys.RevokedRetention) + + reaped, err := h.state.DestroyRevokedPreAuthKeysBefore(cutoff) + if err != nil { + log.Error().Err(err).Msg("reaping revoked pre-auth keys") + } else if reaped > 0 { + log.Info().Int("count", reaped).Msg("reaped revoked pre-auth keys") + } + + case <-accessTokenTicker.C: + reaped, err := h.state.DeleteExpiredAccessTokens(time.Now()) + if err != nil { + log.Error().Err(err).Msg("reaping expired oauth access tokens") + } else if reaped > 0 { + log.Debug().Int64("count", reaped).Msg("reaped expired oauth access tokens") + } + case <-expireTicker.C: var ( expiredNodeChanges []change.Change @@ -387,119 +405,6 @@ func (h *Headscale) scheduledTasks(ctx context.Context) { } } -func (h *Headscale) grpcAuthenticationInterceptor(ctx context.Context, - req any, - info *grpc.UnaryServerInfo, - handler grpc.UnaryHandler, -) (any, error) { - // Check if the request is coming from the on-server client. - // This is not secure, but it is to maintain maintainability - // with the "legacy" database-based client - // It is also needed for grpc-gateway to be able to connect to - // the server - client, _ := peer.FromContext(ctx) - - log.Trace(). - Caller(). - Str("client_address", client.Addr.String()). - Msg("Client is trying to authenticate") - - meta, ok := metadata.FromIncomingContext(ctx) - if !ok { - return ctx, status.Errorf( - codes.InvalidArgument, - "retrieving metadata", - ) - } - - authHeader, ok := meta["authorization"] - if !ok { - return ctx, status.Errorf( - codes.Unauthenticated, - "authorization token not supplied", - ) - } - - token := authHeader[0] - - if !strings.HasPrefix(token, AuthPrefix) { - return ctx, status.Error( - codes.Unauthenticated, - `missing "Bearer " prefix in "Authorization" header`, - ) - } - - valid, err := h.state.ValidateAPIKey(strings.TrimPrefix(token, AuthPrefix)) - if err != nil { - return ctx, status.Error(codes.Internal, "validating token") - } - - if !valid { - log.Info(). - Str("client_address", client.Addr.String()). - Msg("invalid token") - - return ctx, status.Error(codes.Unauthenticated, "invalid token") - } - - return handler(ctx, req) -} - -func (h *Headscale) httpAuthenticationMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func( - writer http.ResponseWriter, - req *http.Request, - ) { - log.Trace(). - Caller(). - Str("client_address", req.RemoteAddr). - Msg("HTTP authentication invoked") - - authHeader := req.Header.Get("Authorization") - - writeUnauthorized := func(statusCode int) { - writer.WriteHeader(statusCode) - - if _, err := writer.Write([]byte("Unauthorized")); err != nil { //nolint:noinlineerr - log.Error().Err(err).Msg("writing HTTP response failed") - } - } - - if !strings.HasPrefix(authHeader, AuthPrefix) { - log.Error(). - Caller(). - Str("client_address", req.RemoteAddr). - Msg(`missing "Bearer " prefix in "Authorization" header`) - writeUnauthorized(http.StatusUnauthorized) - - return - } - - valid, err := h.state.ValidateAPIKey(strings.TrimPrefix(authHeader, AuthPrefix)) - if err != nil { - log.Info(). - Caller(). - Err(err). - Str("client_address", req.RemoteAddr). - Msg("failed to validate token") - writeUnauthorized(http.StatusUnauthorized) - - return - } - - if !valid { - log.Info(). - Str("client_address", req.RemoteAddr). - Msg("invalid token") - writeUnauthorized(http.StatusUnauthorized) - - return - } - - next.ServeHTTP(writer, req) - }) -} - // ensureUnixSocketIsAbsent will check if the given path for headscales unix socket is clear // and will remove it if it is not. func (h *Headscale) ensureUnixSocketIsAbsent() error { @@ -525,7 +430,18 @@ func securityHeaders(next http.Handler) http.Handler { }) } -func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux { +// serveHumaMux dispatches to a Huma mux mounted under the outer chi router. +// Huma registers operations at absolute paths (/api/v1/...), so chi's route +// context must be cleared for the inner mux to re-match against the original URL. +func serveHumaMux(mux http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + mux.ServeHTTP(w, req.WithContext( + context.WithValue(req.Context(), chi.RouteCtxKey, nil), + )) + } +} + +func (h *Headscale) createRouter(apiV1Mux, apiV2Mux http.Handler) *chi.Mux { r := chi.NewRouter() r.Use(metrics.Collector(metrics.CollectorOpts{ Host: false, @@ -544,6 +460,11 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux { r.Use(middleware.Recoverer) r.Use(securityHeaders) + // TS2021 accepts both the native client's HTTP POST upgrade and the + // browser/WASM client's WebSocket GET upgrade; NoiseUpgradeHandler + // dispatches on the Upgrade header, not the method. Registering GET as + // well keeps the router from rejecting the WebSocket handshake with 405. + r.Get(ts2021UpgradePath, h.NoiseUpgradeHandler) r.Post(ts2021UpgradePath, h.NoiseUpgradeHandler) r.Get("/robots.txt", h.RobotsHandler) @@ -562,10 +483,6 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux { r.Get("/apple/{platform}", h.ApplePlatformConfig) r.Get("/windows", h.WindowsConfigMessage) - // TODO(kristoffer): move swagger into a package - r.Get("/swagger", headscale.SwaggerUI) - r.Get("/swagger/v1/openapiv2.json", headscale.SwaggerAPIv1) - r.Post("/verify", h.VerifyHandler) if h.cfg.DERP.ServerEnabled { @@ -575,9 +492,13 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux { r.HandleFunc("/bootstrap-dns", derpServer.DERPBootstrapDNSHandler(h.state.DERPMap())) } + // Auth is enforced inside each Huma mux per-operation, so the whole API + // mounts as one handler per version: operations need an API key while the + // OpenAPI document and docs UI stay public. v1 is the headscale-native admin + // API; v2 is Headscale's v2 API, which ports some endpoints from Tailscale. r.Route("/api", func(r chi.Router) { - r.Use(h.httpAuthenticationMiddleware) - r.HandleFunc("/v1/*", grpcMux.ServeHTTP) + r.Handle("/v1/*", serveHumaMux(apiV1Mux)) + r.Handle("/v2/*", serveHumaMux(apiV2Mux)) }) // Ping response endpoint: receives HEAD from clients responding // to a [tailcfg.PingRequest]. The unguessable ping ID serves as authentication. @@ -589,7 +510,7 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux { return r } -// Serve launches the HTTP and gRPC server service Headscale and the API. +// Serve launches the HTTP servers that run Headscale and its API. // //nolint:gocyclo // complex server startup function func (h *Headscale) Serve() error { @@ -680,12 +601,6 @@ func (h *Headscale) Serve() error { go h.scheduledTasks(scheduleCtx) - if zl.GlobalLevel() == zl.TraceLevel { - zerolog.RespLog = true - } else { - zerolog.RespLog = false - } - // Prepare group for running listeners errorGroup := new(errgroup.Group) @@ -713,45 +628,47 @@ func (h *Headscale) Serve() error { socketListener, err := new(net.ListenConfig).Listen(context.Background(), "unix", h.cfg.UnixSocket) if err != nil { - return fmt.Errorf("setting up gRPC socket: %w", err) + return fmt.Errorf("setting up socket: %w", err) } // Change socket permissions if err := os.Chmod(h.cfg.UnixSocket, h.cfg.UnixSocketPermission); err != nil { //nolint:noinlineerr - return fmt.Errorf("changing gRPC socket permission: %w", err) + return fmt.Errorf("changing socket permission: %w", err) } - grpcGatewayMux := grpcRuntime.NewServeMux() + // The Huma v1 API mux matches full /api/v1/... paths and is shared by + // the local unix socket (served without authentication, local trust) + // and the remote TCP router (served behind the API-key middleware). + humaMux, _ := apiv1.Handler(apiv1.Backend{ + State: h.state, + Change: h.Change, + Cfg: h.cfg, + }) - // Make the grpc-gateway connect to grpc over socket - grpcGatewayConn, err := grpc.Dial( //nolint:staticcheck // SA1019: deprecated but supported in 1.x - h.cfg.UnixSocket, - []grpc.DialOption{ - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithContextDialer(util.GrpcSocketDialer), - }..., - ) - if err != nil { - return fmt.Errorf("setting up gRPC gateway via socket: %w", err) + // The Headscale v2 API. Served behind Basic/Bearer auth on the remote + // listener, and over the local unix socket (local trust) so the CLI can + // manage OAuth clients through the same v2 keys handler the Tailscale + // ecosystem uses. + humaV2Mux, _ := apiv2.Handler(apiv2.Backend{ + State: h.state, + Change: h.Change, + Cfg: h.cfg, + }) + + // Serve both Huma APIs over the unix socket without TLS or auth: socket + // access implies trust. WithLocalTrust marks these requests so each API's + // security middleware skips the credential check. v2 paths route to the v2 + // mux; everything else (the v1 paths) to v1. + socketHandler := http.NewServeMux() + socketHandler.Handle("/api/v2/", apiv2.WithLocalTrust(humaV2Mux)) + socketHandler.Handle("/", apiv1.WithLocalTrust(humaMux)) + + socketServer := &http.Server{ + Handler: socketHandler, + ReadTimeout: types.HTTPTimeout, } - // Connect to the gRPC server over localhost to skip - // the authentication. - err = v1.RegisterHeadscaleServiceHandler(ctx, grpcGatewayMux, grpcGatewayConn) - if err != nil { - return fmt.Errorf("registering Headscale API service to gRPC: %w", err) - } - - // Start the local gRPC server without TLS and without authentication - grpcSocket := grpc.NewServer( - // Uncomment to debug grpc communication. - // zerolog.UnaryInterceptor(), - ) - - v1.RegisterHeadscaleServiceServer(grpcSocket, newHeadscaleV1APIServer(h)) - reflection.Register(grpcSocket) - - errorGroup.Go(func() error { return grpcSocket.Serve(socketListener) }) + errorGroup.Go(func() error { return socketServer.Serve(socketListener) }) // // @@ -763,64 +680,13 @@ func (h *Headscale) Serve() error { return fmt.Errorf("configuring TLS settings: %w", err) } - // - // - // gRPC setup - // - - // We are sadly not able to run gRPC and HTTPS (2.0) on the same - // port because the connection mux does not support matching them - // since they are so similar. There is multiple issues open and we - // can revisit this if changes: - // https://github.com/soheilhy/cmux/issues/68 - // https://github.com/soheilhy/cmux/issues/91 - - var ( - grpcServer *grpc.Server - grpcListener net.Listener - ) - - if tlsConfig != nil || h.cfg.GRPCAllowInsecure { - log.Info().Msgf("enabling remote gRPC at %s", h.cfg.GRPCAddr) - - grpcOptions := []grpc.ServerOption{ - grpc.ChainUnaryInterceptor( - h.grpcAuthenticationInterceptor, - // Uncomment to debug grpc communication. - // zerolog.NewUnaryServerInterceptor(), - ), - } - - if tlsConfig != nil { - grpcOptions = append(grpcOptions, - grpc.Creds(credentials.NewTLS(tlsConfig)), - ) - } else { - log.Warn().Msg("gRPC is running without security") - } - - grpcServer = grpc.NewServer(grpcOptions...) - - v1.RegisterHeadscaleServiceServer(grpcServer, newHeadscaleV1APIServer(h)) - - grpcListener, err = new(net.ListenConfig).Listen(context.Background(), "tcp", h.cfg.GRPCAddr) - if err != nil { - return fmt.Errorf("binding to TCP address: %w", err) - } - - errorGroup.Go(func() error { return grpcServer.Serve(grpcListener) }) - - log.Info(). - Msgf("listening and serving gRPC on: %s", h.cfg.GRPCAddr) - } - // // // HTTP setup // // This is the regular router that we expose // over our main Addr - router := h.createRouter(grpcGatewayMux) + router := h.createRouter(humaMux, humaV2Mux) httpServer := &http.Server{ Addr: h.cfg.Addr, @@ -961,13 +827,10 @@ func (h *Headscale) Serve() error { info("waiting for netmap stream to close") h.clientStreamsOpen.Wait() - info("shutting down grpc server (socket)") - grpcSocket.GracefulStop() + info("shutting down api server (socket)") - if grpcServer != nil { - info("shutting down grpc server (external)") - grpcServer.GracefulStop() - grpcListener.Close() + if err := socketServer.Shutdown(shutdownCtx); err != nil { //nolint:noinlineerr + log.Error().Err(err).Msg("failed to shutdown socket server") } if tailsqlContext != nil { @@ -983,7 +846,6 @@ func (h *Headscale) Serve() error { } httpListener.Close() - grpcGatewayConn.Close() // Stop listening (and unlink the socket if unix type): info("closing socket listener") @@ -1015,14 +877,14 @@ func (h *Headscale) Serve() error { } func (h *Headscale) getTLSSettings() (*tls.Config, error) { - var err error + tlsEnabled := h.cfg.TLS.LetsEncrypt.Hostname != "" || h.cfg.TLS.CertPath != "" + if tlsEnabled && !strings.HasPrefix(h.cfg.ServerURL, "https://") { + log.Warn().Msg("listening with TLS but ServerURL does not start with https://") + } else if !tlsEnabled && !strings.HasPrefix(h.cfg.ServerURL, "http://") { + log.Warn().Msg("listening without TLS but ServerURL does not start with http://") + } if h.cfg.TLS.LetsEncrypt.Hostname != "" { - if !strings.HasPrefix(h.cfg.ServerURL, "https://") { - log.Warn(). - Msg("Listening with TLS but ServerURL does not start with https://") - } - certManager := autocert.Manager{ Prompt: autocert.AcceptTOS, HostPolicy: autocert.HostWhitelist(h.cfg.TLS.LetsEncrypt.Hostname), @@ -1068,27 +930,26 @@ func (h *Headscale) getTLSSettings() (*tls.Config, error) { default: return nil, errUnsupportedLetsEncryptChallengeType } - } else if h.cfg.TLS.CertPath == "" { - if !strings.HasPrefix(h.cfg.ServerURL, "http://") { - log.Warn().Msg("listening without TLS but ServerURL does not start with http://") - } - - return nil, err - } else { - if !strings.HasPrefix(h.cfg.ServerURL, "https://") { - log.Warn().Msg("listening with TLS but ServerURL does not start with https://") - } - - tlsConfig := &tls.Config{ - NextProtos: []string{"http/1.1"}, - Certificates: make([]tls.Certificate, 1), - MinVersion: tls.VersionTLS12, - } - - tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(h.cfg.TLS.CertPath, h.cfg.TLS.KeyPath) - - return tlsConfig, err } + + if h.cfg.TLS.CertPath == "" { + return nil, nil //nolint:nilnil // intentional: no TLS config when neither LetsEncrypt nor a cert path is set + } + + tlsConfig := &tls.Config{ + NextProtos: []string{"http/1.1"}, + Certificates: make([]tls.Certificate, 1), + MinVersion: tls.VersionTLS12, + } + + cert, err := tls.LoadX509KeyPair(h.cfg.TLS.CertPath, h.cfg.TLS.KeyPath) + if err != nil { + return nil, err + } + + tlsConfig.Certificates[0] = cert + + return tlsConfig, nil } func readOrCreatePrivateKey(path string) (*key.MachinePrivate, error) { @@ -1148,7 +1009,19 @@ func (h *Headscale) Change(cs ...change.Change) { // The handler serves the Tailscale control protocol including the /key // endpoint and /ts2021 Noise upgrade path. func (h *Headscale) HTTPHandler() http.Handler { - return h.createRouter(grpcRuntime.NewServeMux()) + humaMux, _ := apiv1.Handler(apiv1.Backend{ + State: h.state, + Change: h.Change, + Cfg: h.cfg, + }) + + humaV2Mux, _ := apiv2.Handler(apiv2.Backend{ + State: h.state, + Change: h.Change, + Cfg: h.cfg, + }) + + return h.createRouter(humaMux, humaV2Mux) } // NoisePublicKey returns the server's Noise protocol public key. diff --git a/hscontrol/auth.go b/hscontrol/auth.go index a6f396666..81e620bfd 100644 --- a/hscontrol/auth.go +++ b/hscontrol/auth.go @@ -23,6 +23,18 @@ type AuthProvider interface { AuthURL(authID types.AuthID) string } +// machineKeyMismatch fails closed when a node looked up by NodeKey was started +// in a Noise session with a different machine key. Without this anyone holding a +// target's NodeKey could open a session with a throwaway machine key and act on +// the owner's node. Returns a 401 [HTTPError] on mismatch, nil otherwise. +func machineKeyMismatch(node types.NodeView, machineKey key.MachinePublic) error { + if node.MachineKey() != machineKey { + return NewHTTPError(http.StatusUnauthorized, "node exists with a different machine key", nil) + } + + return nil +} + func (h *Headscale) handleRegister( ctx context.Context, req tailcfg.RegisterRequest, @@ -75,12 +87,9 @@ func (h *Headscale) handleRegister( // open a Noise session with a throwaway machine key and read // the owner's User/Login back through [nodeToRegisterResponse]. // [Headscale.handleLogout] enforces the same check on its own path. - if node.MachineKey() != machineKey { - return nil, NewHTTPError( - http.StatusUnauthorized, - "node exists with a different machine key", - nil, - ) + err := machineKeyMismatch(node, machineKey) + if err != nil { + return nil, err } // When tailscaled restarts, it sends [tailcfg.RegisterRequest] with Auth=nil and Expiry=zero. @@ -153,8 +162,9 @@ func (h *Headscale) handleLogout( // Fail closed if it looks like this is an attempt to modify a node where // the node key and the machine key the noise session was started with does // not align. - if node.MachineKey() != machineKey { - return nil, NewHTTPError(http.StatusUnauthorized, "node exist with different machine key", nil) + err := machineKeyMismatch(node, machineKey) + if err != nil { + return nil, err } // Note: We do NOT return early if req.Auth is set, because Tailscale clients diff --git a/hscontrol/auth_tags_test.go b/hscontrol/auth_tags_test.go index 3c55c6671..3cea0cb0c 100644 --- a/hscontrol/auth_tags_test.go +++ b/hscontrol/auth_tags_test.go @@ -179,9 +179,8 @@ func TestReAuthDoesNotReapplyTags(t *testing.T) { assert.Equal(t, 1, allNodes.Len(), "Should have exactly one node") } -// NOTE: TestSetTagsOnUserOwnedNode functionality is covered by gRPC tests in grpcv1_test.go -// which properly handle ACL policy setup. The test verifies that [headscaleV1APIServer.SetTags] can convert -// user-owned nodes to tagged nodes while preserving UserID. +// NOTE: Converting user-owned nodes to tagged nodes while preserving UserID +// is covered by the SetTags API tests, which properly handle ACL policy setup. // TestCannotRemoveAllTags tests that attempting to remove all tags from a // tagged node fails with ErrCannotRemoveAllTags. Once a node is tagged, diff --git a/hscontrol/capver/capver.go b/hscontrol/capver/capver.go index 86a653ddb..a4c535e88 100644 --- a/hscontrol/capver/capver.go +++ b/hscontrol/capver/capver.go @@ -3,12 +3,12 @@ package capver //go:generate go run ../../tools/capver/main.go import ( + "maps" "slices" - "sort" "strings" - xmaps "golang.org/x/exp/maps" "tailscale.com/tailcfg" + "tailscale.com/util/cmpver" "tailscale.com/util/set" ) @@ -26,17 +26,7 @@ func CanOldCodeBeCleanedUp() { } func tailscaleVersSorted() []string { - vers := xmaps.Keys(tailscaleToCapVer) - sort.Strings(vers) - - return vers -} - -func capVersSorted() []tailcfg.CapabilityVersion { - capVers := xmaps.Keys(capVerToTailscaleVer) - slices.Sort(capVers) - - return capVers + return slices.Sorted(maps.Keys(tailscaleToCapVer)) } // TailscaleVersion returns the Tailscale version for the given [tailcfg.CapabilityVersion]. @@ -66,21 +56,6 @@ func CapabilityVersion(ver string) tailcfg.CapabilityVersion { return 0 } -// TailscaleLatest returns the n latest Tailscale versions. -func TailscaleLatest(n int) []string { - if n <= 0 { - return nil - } - - tsSorted := tailscaleVersSorted() - - if n > len(tsSorted) { - return tsSorted - } - - return tsSorted[len(tsSorted)-n:] -} - // TailscaleLatestMajorMinor returns the n latest Tailscale versions (e.g. 1.80). func TailscaleLatestMajorMinor(n int, stripV bool) []string { if n <= 0 { @@ -99,7 +74,9 @@ func TailscaleLatestMajorMinor(n int, stripV bool) []string { } majorSl := majors.Slice() - sort.Strings(majorSl) + // cmpver orders versions numerically, so v1.100 sorts after v1.98 rather than + // lexically before it. + slices.SortFunc(majorSl, cmpver.Compare) if n > len(majorSl) { return majorSl @@ -107,18 +84,3 @@ func TailscaleLatestMajorMinor(n int, stripV bool) []string { return majorSl[len(majorSl)-n:] } - -// CapVerLatest returns the n latest [tailcfg.CapabilityVersion] values. -func CapVerLatest(n int) []tailcfg.CapabilityVersion { - if n <= 0 { - return nil - } - - s := capVersSorted() - - if n > len(s) { - return s - } - - return s[len(s)-n:] -} diff --git a/hscontrol/db/api_key.go b/hscontrol/db/api_key.go index e64648c98..b56f1c188 100644 --- a/hscontrol/db/api_key.go +++ b/hscontrol/db/api_key.go @@ -7,9 +7,9 @@ import ( "time" "github.com/juanfont/headscale/hscontrol/types" - "github.com/juanfont/headscale/hscontrol/util" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "tailscale.com/util/rands" ) const ( @@ -23,9 +23,9 @@ const ( ) var ( - ErrAPIKeyFailedToParse = errors.New("failed to parse ApiKey") - ErrAPIKeyGenerationFailed = errors.New("failed to generate API key") - ErrAPIKeyInvalidGeneration = errors.New("generated API key failed validation") + ErrAPIKeyFailedToParse = errors.New("failed to parse ApiKey") + ErrAPIKeyGenerationFailed = errors.New("failed to generate API key") + ErrAPIKeyExpired = errors.New("API key expired") ) // CreateAPIKey creates a new [types.APIKey] in a user, and returns it. @@ -33,34 +33,10 @@ func (hsdb *HSDatabase) CreateAPIKey( expiration *time.Time, ) (string, *types.APIKey, error) { // Generate public prefix (12 chars) - prefix, err := util.GenerateRandomStringURLSafe(apiKeyPrefixLength) - if err != nil { - return "", nil, err - } - - // Validate prefix - if len(prefix) != apiKeyPrefixLength { - return "", nil, fmt.Errorf("%w: generated prefix has invalid length: expected %d, got %d", ErrAPIKeyInvalidGeneration, apiKeyPrefixLength, len(prefix)) - } - - if !isValidBase64URLSafe(prefix) { - return "", nil, fmt.Errorf("%w: generated prefix contains invalid characters", ErrAPIKeyInvalidGeneration) - } + prefix := rands.HexString(apiKeyPrefixLength) // Generate secret (64 chars) - secret, err := util.GenerateRandomStringURLSafe(apiKeyHashLength) - if err != nil { - return "", nil, err - } - - // Validate secret - if len(secret) != apiKeyHashLength { - return "", nil, fmt.Errorf("%w: generated secret has invalid length: expected %d, got %d", ErrAPIKeyInvalidGeneration, apiKeyHashLength, len(secret)) - } - - if !isValidBase64URLSafe(secret) { - return "", nil, fmt.Errorf("%w: generated secret contains invalid characters", ErrAPIKeyInvalidGeneration) - } + secret := rands.HexString(apiKeyHashLength) // Full key string (shown ONCE to user) keyStr := apiKeyPrefix + prefix + "-" + secret @@ -152,6 +128,31 @@ func (hsdb *HSDatabase) ValidateAPIKey(keyStr string) (bool, error) { return true, nil } +// AuthenticateAPIKey validates keyStr and returns the matching, unexpired +// [types.APIKey] (with its owning UserID populated). Unlike ValidateAPIKey it +// returns the key itself, so the v2 API can act as the key's owning user. A +// non-nil error means the key is missing, malformed, or expired. +func (hsdb *HSDatabase) AuthenticateAPIKey(keyStr string) (*types.APIKey, error) { + key, err := validateAPIKey(hsdb.DB, keyStr) + if err != nil { + return nil, err + } + + if key.Expiration != nil && key.Expiration.Before(time.Now()) { + return nil, ErrAPIKeyExpired + } + + return key, nil +} + +// SetAPIKeyUser sets the owning user of an API key. Used when an admin mints a +// key on behalf of a user (headscale apikeys create --user). +func (hsdb *HSDatabase) SetAPIKeyUser(keyID uint64, userID types.UserID) error { + return hsdb.DB.Model(&types.APIKey{}). + Where("id = ?", keyID). + Update("user_id", uint(userID)).Error +} + // ParseAPIKeyPrefix extracts the database prefix from a display prefix. // Handles formats: "hskey-api-{12chars}-***", "hskey-api-{12chars}", or just "{12chars}". // Returns the 12-character prefix suitable for database lookup. @@ -205,61 +206,20 @@ func validateAPIKey(db *gorm.DB, keyStr string) (*types.APIKey, error) { } // New format: parse and verify - const expectedMinLength = apiKeyPrefixLength + 1 + apiKeyHashLength - if len(prefixAndSecret) < expectedMinLength { - return nil, fmt.Errorf( - "%w: key too short, expected at least %d chars after prefix, got %d", - ErrAPIKeyFailedToParse, - expectedMinLength, - len(prefixAndSecret), - ) - } - - // Use fixed-length parsing - prefix := prefixAndSecret[:apiKeyPrefixLength] - - // Validate separator at expected position - if prefixAndSecret[apiKeyPrefixLength] != '-' { - return nil, fmt.Errorf( - "%w: expected separator '-' at position %d, got '%c'", - ErrAPIKeyFailedToParse, - apiKeyPrefixLength, - prefixAndSecret[apiKeyPrefixLength], - ) - } - - secret := prefixAndSecret[apiKeyPrefixLength+1:] - - // Validate secret length - if len(secret) != apiKeyHashLength { - return nil, fmt.Errorf( - "%w: secret length mismatch, expected %d chars, got %d", - ErrAPIKeyFailedToParse, - apiKeyHashLength, - len(secret), - ) - } - - // Validate prefix contains only base64 URL-safe characters - if !isValidBase64URLSafe(prefix) { - return nil, fmt.Errorf( - "%w: prefix contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)", - ErrAPIKeyFailedToParse, - ) - } - - // Validate secret contains only base64 URL-safe characters - if !isValidBase64URLSafe(secret) { - return nil, fmt.Errorf( - "%w: secret contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)", - ErrAPIKeyFailedToParse, - ) + prefix, secret, err := parsePrefixedKey( + prefixAndSecret, + apiKeyPrefixLength, + apiKeyHashLength, + ErrAPIKeyFailedToParse, + ) + if err != nil { + return nil, err } // Look up by prefix (indexed) var key types.APIKey - err := db.First(&key, "prefix = ?", prefix).Error + err = db.First(&key, "prefix = ?", prefix).Error if err != nil { return nil, fmt.Errorf("API key not found: %w", err) } diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index 8c088c492..c9b94ef6c 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -38,9 +38,9 @@ var errDatabaseNotSupported = errors.New("database type not supported") var errForeignKeyConstraintsViolated = errors.New("foreign key constraints violated") const ( - maxIdleConns = 100 - maxOpenConns = 100 - contextTimeoutSecs = 10 + maxIdleConns = 100 + maxOpenConns = 100 + contextTimeout = 10 * time.Second ) type HSDatabase struct { @@ -220,7 +220,7 @@ AND auth_key_id NOT IN ( { ID: "202505141324", Migrate: func(tx *gorm.DB) error { - users, err := ListUsers(tx) + users, err := ListUsers(tx, nil) if err != nil { return fmt.Errorf("listing users: %w", err) } @@ -617,7 +617,7 @@ AND auth_key_id NOT IN ( } // 2. Load users and nodes to create PolicyManager - users, err := ListUsers(tx) + users, err := ListUsers(tx, nil) if err != nil { return fmt.Errorf("loading users for RequestTags migration: %w", err) } @@ -706,13 +706,20 @@ AND auth_key_id NOT IN ( // but this prevents deleting users whose nodes have been // tagged, and the ON DELETE CASCADE FK would destroy the // tagged nodes if the user were deleted. + // + // A nil tags slice marshals to the JSON literal 'null', so + // untagged nodes can carry tags='null'. That spelling must be + // excluded alongside '[]' and '' or untagged nodes lose their + // user. Nodes already detached by the earlier version of this + // migration are repaired by the recovery migration below. // Fixes: https://github.com/juanfont/headscale/issues/3077 + // Fixes: https://github.com/juanfont/headscale/issues/3323 ID: "202602201200-clear-tagged-node-user-id", Migrate: func(tx *gorm.DB) error { err := tx.Exec(` UPDATE nodes SET user_id = NULL -WHERE tags IS NOT NULL AND tags != '[]' AND tags != ''; +WHERE tags IS NOT NULL AND tags != '[]' AND tags != '' AND tags != 'null'; `).Error if err != nil { return fmt.Errorf("clearing user_id on tagged nodes: %w", err) @@ -744,6 +751,155 @@ WHERE expiry IS NOT NULL AND expiry < '1900-01-01'; }, Rollback: func(db *gorm.DB) error { return nil }, }, + { + // Recover user_id on untagged nodes detached by the earlier + // version of 202602201200-clear-tagged-node-user-id, which + // treated tags='null' as tagged and cleared the user. This + // repairs databases that already upgraded to 0.29.0; fresh + // upgrades are protected by the fixed migration above and find + // nothing to repair. Recovery is best-effort: the owner is + // re-derived from the node's pre-auth key, so nodes registered + // via CLI/OIDC (no pre-auth key) cannot be recovered and must + // be reassigned manually. + // Fixes: https://github.com/juanfont/headscale/issues/3323 + ID: "202606181200-recover-null-tags-node-user-id", + Migrate: func(tx *gorm.DB) error { + err := tx.Exec(` +UPDATE nodes +SET user_id = ( + SELECT pak.user_id FROM pre_auth_keys pak WHERE pak.id = nodes.auth_key_id +) +WHERE user_id IS NULL + AND auth_key_id IS NOT NULL + AND (tags IS NULL OR tags = '' OR tags = '[]' OR tags = 'null'); + `).Error + if err != nil { + return fmt.Errorf("recovering user_id on untagged nodes: %w", err) + } + + return nil + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, + { + // Add an optional owning user to API keys so the v2 API can + // create user-owned (untagged) auth keys, mirroring Tailscale's + // "key owned by the creating identity". + ID: "202606191500-api-key-user-id", + Migrate: func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn(&types.APIKey{}, "user_id") { + err := tx.Migrator().AddColumn(&types.APIKey{}, "user_id") + if err != nil { + return fmt.Errorf("adding user_id to api_keys: %w", err) + } + } + + return nil + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, + { + // Add a free-text description to pre-auth keys, set via the + // v2 keys API. + ID: "202606191501-pre-auth-key-description", + Migrate: func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn(&types.PreAuthKey{}, "description") { + err := tx.Migrator().AddColumn(&types.PreAuthKey{}, "description") + if err != nil { + return fmt.Errorf("adding description to pre_auth_keys: %w", err) + } + } + + return nil + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, + { + // Add a revoked timestamp to pre-auth keys. The v2 API's DELETE + // soft-revokes a key (set revoked = now) rather than destroying + // it; the row is reaped later by the background collector. + ID: "202606201200-pre-auth-key-revoked", + Migrate: func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn(&types.PreAuthKey{}, "revoked") { + err := tx.Migrator().AddColumn(&types.PreAuthKey{}, "revoked") + if err != nil { + return fmt.Errorf("adding revoked to pre_auth_keys: %w", err) + } + } + + return nil + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, + { + // Add the OAuth client + access token tables backing the v2 API's + // OAuth client-credentials flow. They mirror the api_keys / + // pre_auth_keys security model: a public id/prefix plus an Argon2id + // hash of the secret. + // + // SQLite uses explicit DDL that matches schema.sql byte-for-byte + // (the squibble digest is the SQLite source of truth). Postgres, + // which has no digest and rejects SQLite-isms like AUTOINCREMENT, + // uses dialect-aware AutoMigrate, mirroring InitSchema's fresh-DB + // table creation so an existing Postgres deployment can upgrade. + ID: "202606211200-oauth-clients-and-tokens", + Migrate: func(tx *gorm.DB) error { + if tx.Migrator().HasTable(&types.OAuthClient{}) && + tx.Migrator().HasTable(&types.OAuthAccessToken{}) { + return nil + } + + if tx.Name() != "sqlite" { + return tx.AutoMigrate(&types.OAuthClient{}, &types.OAuthAccessToken{}) + } + + if !tx.Migrator().HasTable(&types.OAuthClient{}) { + err := tx.Exec(`CREATE TABLE oauth_clients( + id integer PRIMARY KEY AUTOINCREMENT, + client_id text, + secret_hash blob, + scopes text, + tags text, + description text, + user_id integer, + created_at datetime, + revoked datetime +)`).Error + if err != nil { + return fmt.Errorf("creating oauth_clients table: %w", err) + } + + err = tx.Exec(`CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`).Error + if err != nil { + return fmt.Errorf("creating oauth_clients index: %w", err) + } + } + + if !tx.Migrator().HasTable(&types.OAuthAccessToken{}) { + err := tx.Exec(`CREATE TABLE oauth_access_tokens( + id integer PRIMARY KEY AUTOINCREMENT, + prefix text, + hash blob, + client_id text, + scopes text, + tags text, + expiration datetime, + created_at datetime +)`).Error + if err != nil { + return fmt.Errorf("creating oauth_access_tokens table: %w", err) + } + + err = tx.Exec(`CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`).Error + if err != nil { + return fmt.Errorf("creating oauth_access_tokens index: %w", err) + } + } + + return nil + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, }, ) @@ -755,6 +911,8 @@ WHERE expiry IS NOT NULL AND expiry < '1900-01-01'; &types.APIKey{}, &types.Node{}, &types.Policy{}, + &types.OAuthClient{}, + &types.OAuthAccessToken{}, ) if err != nil { return err @@ -770,6 +928,8 @@ WHERE expiry IS NOT NULL AND expiry < '1900-01-01'; `DROP INDEX IF EXISTS "idx_name_provider_identifier"`, `DROP INDEX IF EXISTS "idx_name_no_provider_identifier"`, `DROP INDEX IF EXISTS "idx_pre_auth_keys_prefix"`, + `DROP INDEX IF EXISTS "idx_oauth_clients_client_id"`, + `DROP INDEX IF EXISTS "idx_oauth_access_tokens_prefix"`, } for _, dropSQL := range dropIndexes { @@ -788,6 +948,8 @@ WHERE expiry IS NOT NULL AND expiry < '1900-01-01'; `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 idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''`, + `CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`, + `CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`, } for _, indexSQL := range indexes { @@ -836,7 +998,7 @@ WHERE expiry IS NOT NULL AND expiry < '1900-01-01'; defer sqlConn.SetMaxIdleConns(1) defer sqlConn.SetMaxOpenConns(1) - ctx, cancel := context.WithTimeout(context.Background(), contextTimeoutSecs*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), contextTimeout) defer cancel() opts := squibble.DigestOptions{ @@ -970,65 +1132,20 @@ func openDB(cfg types.DatabaseConfig) (*gorm.DB, error) { func runMigrations(cfg types.DatabaseConfig, dbConn *gorm.DB, migrations *gormigrate.Gormigrate) error { if cfg.Type == types.DatabaseSqlite { - // SQLite: Run migrations step-by-step, only disabling foreign keys when necessary - - // List of migration IDs that require foreign keys to be disabled - // These are migrations that perform complex schema changes that GORM cannot handle safely with FK enabled - // NO NEW MIGRATIONS SHOULD BE ADDED HERE. ALL NEW MIGRATIONS MUST RUN WITH FOREIGN KEYS ENABLED. - migrationsRequiringFKDisabled := map[string]bool{ - "202501221827": true, // Route table automigration with FK constraint issues - "202501311657": true, // PreAuthKey table automigration with FK constraint issues - // Add other migration IDs here as they are identified to need FK disabled + // SQLite: Run the early migrations that GORM cannot handle safely with + // foreign keys enabled (route and pre-auth-key automigrations) with FK + // disabled, then run everything else with FK enabled. + // + // NO NEW MIGRATIONS SHOULD RUN WITH FK DISABLED. As of 2025-07-02, all + // new migrations must run with foreign keys enabled via the + // migrations.Migrate() call below. + if err := dbConn.Exec("PRAGMA foreign_keys = OFF").Error; err != nil { //nolint:noinlineerr + return fmt.Errorf("disabling foreign keys: %w", err) } - // Get the current foreign key status - var fkOriginallyEnabled int - if err := dbConn.Raw("PRAGMA foreign_keys").Scan(&fkOriginallyEnabled).Error; err != nil { //nolint:noinlineerr - return fmt.Errorf("checking foreign key status: %w", err) - } - - // Get all migration IDs in order from the actual migration definitions - // Only IDs that are in the migrationsRequiringFKDisabled map will be processed with FK disabled - // any other new migrations are ran after. - migrationIDs := []string{ - // v0.25.0 - "202501221827", - "202501311657", - "202502070949", - - // v0.26.0 - "202502131714", - "202502171819", - "202505091439", - "202505141324", - - // As of 2025-07-02, no new IDs should be added here. - // They will be ran by the migrations.Migrate() call below. - } - - for _, migrationID := range migrationIDs { - log.Trace().Caller().Str("migration_id", migrationID).Msg("running migration") - needsFKDisabled := migrationsRequiringFKDisabled[migrationID] - - if needsFKDisabled { - // Disable foreign keys for this migration - err := dbConn.Exec("PRAGMA foreign_keys = OFF").Error - if err != nil { - return fmt.Errorf("disabling foreign keys for migration %s: %w", migrationID, err) - } - } else { - // Ensure foreign keys are enabled for this migration - err := dbConn.Exec("PRAGMA foreign_keys = ON").Error - if err != nil { - return fmt.Errorf("enabling foreign keys for migration %s: %w", migrationID, err) - } - } - - // Run up to this specific migration (will only run the next pending migration) - err := migrations.MigrateTo(migrationID) - if err != nil { - return fmt.Errorf("running migration %s: %w", migrationID, err) - } + // Run up to and including the last migration that requires FK disabled. + if err := migrations.MigrateTo("202501311657"); err != nil { //nolint:noinlineerr + return fmt.Errorf("running migration 202501311657: %w", err) } if err := dbConn.Exec("PRAGMA foreign_keys = ON").Error; err != nil { //nolint:noinlineerr diff --git a/hscontrol/db/db_test.go b/hscontrol/db/db_test.go index 60abcfa07..88d282c46 100644 --- a/hscontrol/db/db_test.go +++ b/hscontrol/db/db_test.go @@ -36,7 +36,7 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) { // Verify users data preservation users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) { - return ListUsers(rx) + return ListUsers(rx, nil) }) require.NoError(t, err) assert.Len(t, users, 1, "should preserve all 1 user from original schema") @@ -198,6 +198,103 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) { assert.False(t, node5.IsExpired(), "node5 should not be reported as expired") }, }, + // Test for the clear-tagged-node-user-id migration + // (202602201200-clear-tagged-node-user-id). A nil tags slice + // marshals to the JSON literal 'null', so untagged nodes can carry + // tags='null' in the database. The migration must only clear + // user_id on genuinely tagged nodes, not on these untagged ones. + // Fixes: https://github.com/juanfont/headscale/issues/3323 + { + dbPath: "testdata/sqlite/null_tags_user_id_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, 4, "should have all 4 nodes") + + byHostname := make(map[string]*types.Node, len(nodes)) + for _, n := range nodes { + byHostname[n.Hostname] = n + } + + // Node 1 had tags='null' (untagged) and belonged to user2. + // The migration must NOT clear its user_id. + node1 := byHostname["node1"] + require.NotNil(t, node1, "node1 should exist") + assert.False(t, node1.IsTagged(), "node1 with tags='null' should be untagged") + require.NotNil(t, node1.UserID, "node1 should keep its user assigned") + assert.Equal(t, uint(2), *node1.UserID, "node1 should still belong to user2") + + // Node 2 is genuinely tagged; user_id must be cleared. + node2 := byHostname["node2"] + require.NotNil(t, node2, "node2 should exist") + assert.True(t, node2.IsTagged(), "node2 should be tagged") + assert.Nil(t, node2.UserID, "node2 (tagged) should have user_id cleared") + + // Node 3 had tags='[]' (untagged); user_id preserved. + node3 := byHostname["node3"] + require.NotNil(t, node3, "node3 should exist") + assert.False(t, node3.IsTagged(), "node3 with tags='[]' should be untagged") + require.NotNil(t, node3.UserID, "node3 should keep its user assigned") + assert.Equal(t, uint(1), *node3.UserID, "node3 should still belong to user1") + + // Node 4 had tags='' (untagged); user_id preserved. + node4 := byHostname["node4"] + require.NotNil(t, node4, "node4 should exist") + assert.False(t, node4.IsTagged(), "node4 with tags='' should be untagged") + require.NotNil(t, node4.UserID, "node4 should keep its user assigned") + assert.Equal(t, uint(1), *node4.UserID, "node4 should still belong to user1") + }, + }, + // Test for the null-tags user_id recovery migration. Databases that + // already upgraded to 0.29.0 had user_id wrongly cleared on untagged + // nodes with tags='null'. The recovery migration re-derives user_id + // from the node's pre-auth key where one exists. + // Fixes: https://github.com/juanfont/headscale/issues/3323 + { + dbPath: "testdata/sqlite/recover_null_tags_user_id_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, 4, "should have all 4 nodes") + + byHostname := make(map[string]*types.Node, len(nodes)) + for _, n := range nodes { + byHostname[n.Hostname] = n + } + + // Node 1: authkey-registered, orphaned by the bug. The recovery + // migration restores user_id from its pre-auth key (user2). + node1 := byHostname["node1"] + require.NotNil(t, node1, "node1 should exist") + require.NotNil(t, node1.UserID, "node1 user_id should be recovered") + assert.Equal(t, uint(2), *node1.UserID, "node1 should be recovered to user2") + + // Node 2: genuinely tagged, correctly cleared. Must stay cleared. + node2 := byHostname["node2"] + require.NotNil(t, node2, "node2 should exist") + assert.True(t, node2.IsTagged(), "node2 should be tagged") + assert.Nil(t, node2.UserID, "node2 (tagged) must remain cleared") + + // Node 3: CLI-registered, no pre-auth key. Unrecoverable. + node3 := byHostname["node3"] + require.NotNil(t, node3, "node3 should exist") + assert.Nil(t, node3.UserID, "node3 has no pre-auth key to recover from") + + // Node 4: never orphaned; user_id must be untouched. + node4 := byHostname["node4"] + require.NotNil(t, node4, "node4 should exist") + require.NotNil(t, node4.UserID, "node4 user_id should be untouched") + assert.Equal(t, uint(1), *node4.UserID, "node4 should still belong to user1") + }, + }, } for _, tt := range tests { diff --git a/hscontrol/db/ip.go b/hscontrol/db/ip.go index 014172edb..fede11b7f 100644 --- a/hscontrol/db/ip.go +++ b/hscontrol/db/ip.go @@ -138,9 +138,6 @@ func NewIPAllocator( } func (i *IPAllocator) Next() (*netip.Addr, *netip.Addr, error) { - i.mu.Lock() - defer i.mu.Unlock() - var ( err error ret4 *netip.Addr @@ -148,21 +145,17 @@ func (i *IPAllocator) Next() (*netip.Addr, *netip.Addr, error) { ) if i.prefix4 != nil { - ret4, err = i.next(i.prev4, i.prefix4) + ret4, err = i.allocateNext(&i.prev4, i.prefix4) if err != nil { return nil, nil, fmt.Errorf("allocating IPv4 address: %w", err) } - - i.prev4 = *ret4 } if i.prefix6 != nil { - ret6, err = i.next(i.prev6, i.prefix6) + ret6, err = i.allocateNext(&i.prev6, i.prefix6) if err != nil { return nil, nil, fmt.Errorf("allocating IPv6 address: %w", err) } - - i.prev6 = *ret6 } return ret4, ret6, nil @@ -170,34 +163,20 @@ func (i *IPAllocator) Next() (*netip.Addr, *netip.Addr, error) { var ErrCouldNotAllocateIP = errors.New("failed to allocate IP") -// allocateNext4 allocates the next IPv4 under i.mu, advancing prev4 so a run of -// allocations (e.g. BackfillNodeIPs) does not rescan already-issued addresses, -// and so prev4 is read under the lock rather than in the caller's frame. -func (i *IPAllocator) allocateNext4() (*netip.Addr, error) { +// allocateNext allocates the next address from prefix under i.mu, advancing +// prev so a run of allocations (e.g. BackfillNodeIPs) does not rescan +// already-issued addresses, and so prev is read under the lock rather than in +// the caller's frame. +func (i *IPAllocator) allocateNext(prev *netip.Addr, prefix *netip.Prefix) (*netip.Addr, error) { i.mu.Lock() defer i.mu.Unlock() - ret, err := i.next(i.prev4, i.prefix4) + ret, err := i.next(*prev, prefix) if err != nil { return nil, err } - i.prev4 = *ret - - return ret, nil -} - -// allocateNext6 mirrors allocateNext4 for the IPv6 prefix. -func (i *IPAllocator) allocateNext6() (*netip.Addr, error) { - i.mu.Lock() - defer i.mu.Unlock() - - ret, err := i.next(i.prev6, i.prefix6) - if err != nil { - return nil, err - } - - i.prev6 = *ret + *prev = *ret return ret, nil } @@ -348,7 +327,7 @@ func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) { changed := false // IPv4 prefix is set, but node ip is missing, alloc if i.prefix4 != nil && node.IPv4 == nil { - ret4, err := i.allocateNext4() + ret4, err := i.allocateNext(&i.prev4, i.prefix4) if err != nil { return fmt.Errorf("allocating IPv4 for node(%d): %w", node.ID, err) } @@ -361,7 +340,7 @@ func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) { // IPv6 prefix is set, but node ip is missing, alloc if i.prefix6 != nil && node.IPv6 == nil { - ret6, err := i.allocateNext6() + ret6, err := i.allocateNext(&i.prev6, i.prefix6) if err != nil { return fmt.Errorf("allocating IPv6 for node(%d): %w", node.ID, err) } diff --git a/hscontrol/db/ip_backfill_race_test.go b/hscontrol/db/ip_backfill_race_test.go index 2c3e4b997..25c7ad197 100644 --- a/hscontrol/db/ip_backfill_race_test.go +++ b/hscontrol/db/ip_backfill_race_test.go @@ -10,7 +10,7 @@ import ( ) // TestAllocatorConcurrentNextAndBackfillNoRace exercises the registration path -// (Next) concurrently with the backfill allocation path (allocateNext4/6) on +// (Next) concurrently with the backfill allocation path (allocateNext) on // the same allocator. Backfill used to read prev4/prev6 in the caller's frame // without the lock, racing Next's writes; both must now take i.mu. Run with // -race. @@ -36,12 +36,12 @@ func TestAllocatorConcurrentNextAndBackfillNoRace(t *testing.T) { wg.Go(func() { for range iterations { - _, err := alloc.allocateNext4() + _, err := alloc.allocateNext(&alloc.prev4, alloc.prefix4) if err != nil { return } - _, err = alloc.allocateNext6() + _, err = alloc.allocateNext(&alloc.prev6, alloc.prefix6) if err != nil { return } diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go index 192f05ade..002bde590 100644 --- a/hscontrol/db/node.go +++ b/hscontrol/db/node.go @@ -1,10 +1,11 @@ package db import ( + "cmp" "errors" "fmt" "net/netip" - "sort" + "slices" "strconv" "sync" "testing" @@ -30,6 +31,15 @@ const ( // ErrNodeNameNotUnique is returned when a node name is not unique. var ErrNodeNameNotUnique = errors.New("node name is not unique") +// preloadNode returns a session that eager-loads a node's AuthKey, the +// AuthKey's User, and the node's User. +func preloadNode(tx *gorm.DB) *gorm.DB { + return tx. + Preload("AuthKey"). + Preload("AuthKey.User"). + Preload("User") +} + var ( ErrNodeNotFound = errors.New("node not found") ErrNodeRouteIsNotAvailable = errors.New("route is not available on node") @@ -52,17 +62,14 @@ func (hsdb *HSDatabase) ListPeers(nodeID types.NodeID, peerIDs ...types.NodeID) func ListPeers(tx *gorm.DB, nodeID types.NodeID, peerIDs ...types.NodeID) (types.Nodes, error) { nodes := types.Nodes{} - err := tx. - Preload("AuthKey"). - Preload("AuthKey.User"). - Preload("User"). + err := preloadNode(tx). Where("id <> ?", nodeID). Where(peerIDs).Find(&nodes).Error if err != nil { return types.Nodes{}, err } - sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID }) + slices.SortFunc(nodes, func(a, b *types.Node) int { return cmp.Compare(a.ID, b.ID) }) return nodes, nil } @@ -78,10 +85,7 @@ func (hsdb *HSDatabase) ListNodes(nodeIDs ...types.NodeID) (types.Nodes, error) func ListNodes(tx *gorm.DB, nodeIDs ...types.NodeID) (types.Nodes, error) { nodes := types.Nodes{} - err := tx. - Preload("AuthKey"). - Preload("AuthKey.User"). - Preload("User"). + err := preloadNode(tx). Where(nodeIDs).Find(&nodes).Error if err != nil { return nil, err @@ -111,18 +115,22 @@ func (hsdb *HSDatabase) getNode(uid types.UserID, name string) (*types.Node, err // getNode finds a [types.Node] by name and user and returns the [types.Node] struct. func getNode(tx *gorm.DB, uid types.UserID, name string) (*types.Node, error) { - nodes, err := ListNodesByUser(tx, uid) + uidPtr := uint(uid) + + node := types.Node{} + + err := preloadNode(tx). + Where(&types.Node{UserID: &uidPtr, Hostname: name}). + First(&node).Error if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNodeNotFound + } + return nil, err } - for _, m := range nodes { - if m.Hostname == name { - return m, nil - } - } - - return nil, ErrNodeNotFound + return &node, nil } func (hsdb *HSDatabase) GetNodeByID(id types.NodeID) (*types.Node, error) { @@ -132,11 +140,8 @@ func (hsdb *HSDatabase) GetNodeByID(id types.NodeID) (*types.Node, error) { // GetNodeByID finds a [types.Node] by ID and returns the [types.Node] struct. func GetNodeByID(tx *gorm.DB, id types.NodeID) (*types.Node, error) { mach := types.Node{} - if result := tx. - Preload("AuthKey"). - Preload("AuthKey.User"). - Preload("User"). - Find(&types.Node{ID: id}).First(&mach); result.Error != nil { + if result := preloadNode(tx). + First(&mach, "id = ?", id); result.Error != nil { return nil, result.Error } @@ -153,10 +158,7 @@ func GetNodeByNodeKey( nodeKey key.NodePublic, ) (*types.Node, error) { mach := types.Node{} - if result := tx. - Preload("AuthKey"). - Preload("AuthKey.User"). - Preload("User"). + if result := preloadNode(tx). First(&mach, "node_key = ?", nodeKey.String()); result.Error != nil { return nil, result.Error } @@ -522,6 +524,15 @@ func (e *EphemeralGarbageCollector) Start() { } } +// firstOr returns the first non-empty option, or def if none is provided. +func firstOr(def string, opt []string) string { + if len(opt) > 0 && opt[0] != "" { + return opt[0] + } + + return def +} + func (hsdb *HSDatabase) CreateNodeForTest(user *types.User, hostname ...string) *types.Node { if !testing.Testing() { panic("CreateNodeForTest can only be called during tests") @@ -531,10 +542,7 @@ func (hsdb *HSDatabase) CreateNodeForTest(user *types.User, hostname ...string) panic("CreateNodeForTest requires a valid user") } - nodeName := defaultTestNodePrefix - if len(hostname) > 0 && hostname[0] != "" { - nodeName = hostname[0] - } + nodeName := firstOr(defaultTestNodePrefix, hostname) // Create a preauth key for the node pak, err := hsdb.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) @@ -604,10 +612,7 @@ func (hsdb *HSDatabase) CreateNodesForTest(user *types.User, count int, hostname panic("CreateNodesForTest requires a valid user") } - prefix := defaultTestNodePrefix - if len(hostnamePrefix) > 0 && hostnamePrefix[0] != "" { - prefix = hostnamePrefix[0] - } + prefix := firstOr(defaultTestNodePrefix, hostnamePrefix) nodes := make([]*types.Node, count) for i := range count { @@ -627,10 +632,7 @@ func (hsdb *HSDatabase) CreateRegisteredNodesForTest(user *types.User, count int panic("CreateRegisteredNodesForTest requires a valid user") } - prefix := defaultTestNodePrefix - if len(hostnamePrefix) > 0 && hostnamePrefix[0] != "" { - prefix = hostnamePrefix[0] - } + prefix := firstOr(defaultTestNodePrefix, hostnamePrefix) nodes := make([]*types.Node, count) for i := range count { diff --git a/hscontrol/db/node_test.go b/hscontrol/db/node_test.go index 7bbcf3d95..b628bf71f 100644 --- a/hscontrol/db/node_test.go +++ b/hscontrol/db/node_test.go @@ -346,7 +346,7 @@ func TestAutoApproveRoutes(t *testing.T) { err = adb.DB.Save(&nodeTagged).Error require.NoError(t, err) - users, err := adb.ListUsers() + users, err := adb.ListUsers(nil) require.NoError(t, err) nodes, err := adb.ListNodes() diff --git a/hscontrol/db/oauth.go b/hscontrol/db/oauth.go new file mode 100644 index 000000000..6ce757bc7 --- /dev/null +++ b/hscontrol/db/oauth.go @@ -0,0 +1,385 @@ +package db + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "runtime" + "slices" + "strings" + "time" + + "github.com/juanfont/headscale/hscontrol/types" + "golang.org/x/crypto/argon2" + "gorm.io/gorm" + "tailscale.com/util/rands" + "tailscale.com/util/set" +) + +const ( + // OAuth client secret: hskey-client--. The clientID + // is the public, indexed lookup key (the analogue of an API key's prefix) and + // is embedded in the secret so the token endpoint can derive it. The prefix + // itself lives in the types package ([types.OAuthClientPrefix]). + oauthClientIDLength = 12 + oauthClientSecretLength = 64 + + // OAuth access token: hskey-oauthtok--. The distinct + // prefix (vs hskey-api- admin keys, [types.AccessTokenPrefix]) lets the auth + // middleware dispatch a scoped token from an all-access admin key alone. + accessTokenPrefixLength = 12 + accessTokenSecretLength = 64 +) + +var ( + ErrOAuthClientNotFound = fmt.Errorf("oauth client not found: %w", gorm.ErrRecordNotFound) + ErrOAuthClientFailedToParse = errors.New("failed to parse oauth client secret") + ErrOAuthClientRevoked = errors.New("oauth client revoked") + + ErrAccessTokenNotFound = fmt.Errorf("oauth access token not found: %w", gorm.ErrRecordNotFound) + ErrAccessTokenFailedToParse = errors.New("failed to parse oauth access token") + ErrAccessTokenExpired = errors.New("oauth access token expired") + ErrAccessTokenClientRevoked = errors.New("oauth access token issuing client revoked or deleted") + + errSecretHashMalformed = errors.New("malformed secret hash") + errSecretMismatch = errors.New("secret does not match hash") +) + +// Argon2id parameters, OWASP's minimum recommendation (19 MiB, 2 iterations, 1 +// lane). They are encoded into every stored hash, so raising them later still +// verifies credentials stored under the old cost. +const ( + argon2Time = 2 + argon2Memory = 19 * 1024 + argon2Threads = 1 + argon2KeyLen = 32 + argon2SaltLen = 16 +) + +// argon2Limiter bounds concurrent Argon2id computations. Each costs ~19 MiB and +// the unauthenticated OAuth token endpoint runs one per attempt, so an unbounded +// flood could exhaust memory. ponytail: a global semaphore sized to GOMAXPROCS; +// revisit only if credential hashing ever becomes a throughput bottleneck. +var argon2Limiter = make(chan struct{}, max(2, runtime.GOMAXPROCS(0))) + +// hashSecret hashes a credential secret with Argon2id, encoded in PHC string +// form so the parameters travel with the hash. Argon2id is the current OWASP +// recommendation, replacing bcrypt for new credential storage. +func hashSecret(secret string) ([]byte, error) { + salt := make([]byte, argon2SaltLen) + + _, err := rand.Read(salt) + if err != nil { + return nil, fmt.Errorf("generating salt: %w", err) + } + + hash := argon2.IDKey([]byte(secret), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen) + + encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argon2Memory, argon2Time, argon2Threads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(hash), + ) + + return []byte(encoded), nil +} + +// verifySecret reports whether secret matches a hashSecret-encoded hash. It +// reads the cost parameters from the stored hash and compares in constant time +// so a mismatch leaks no timing signal. +func verifySecret(encoded []byte, secret string) error { + parts := strings.Split(string(encoded), "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return errSecretHashMalformed + } + + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { //nolint:noinlineerr + return errSecretHashMalformed + } + + var ( + memory, time uint32 + threads uint8 + ) + + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { //nolint:noinlineerr + return errSecretHashMalformed + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return errSecretHashMalformed + } + + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return errSecretHashMalformed + } + + argon2Limiter <- struct{}{} + //nolint:gosec // want is a 32-byte hash read back from storage, no overflow + got := argon2.IDKey([]byte(secret), salt, time, memory, threads, uint32(len(want))) + + <-argon2Limiter + + if subtle.ConstantTimeCompare(got, want) != 1 { + return errSecretMismatch + } + + return nil +} + +// CreateOAuthClient creates a new [types.OAuthClient] and returns the plaintext +// secret (shown ONCE) alongside the stored client. creatorUserID is the user who +// created it (informational), or nil. +func (hsdb *HSDatabase) CreateOAuthClient( + scopes, tags []string, + description string, + creatorUserID *uint, +) (string, *types.OAuthClient, error) { + tags, err := validateACLTags(tags) + if err != nil { + return "", nil, err + } + + scopes = set.SetOf(scopes).Slice() + slices.Sort(scopes) + + clientID := rands.HexString(oauthClientIDLength) + secret := rands.HexString(oauthClientSecretLength) + secretStr := types.OAuthClientPrefix + clientID + "-" + secret + + hash, err := hashSecret(secret) + if err != nil { + return "", nil, err + } + + now := time.Now().UTC() + client := types.OAuthClient{ + ClientID: clientID, + SecretHash: hash, + Scopes: scopes, + Tags: tags, + Description: description, + UserID: creatorUserID, + CreatedAt: &now, + } + + err = hsdb.Write(func(tx *gorm.DB) error { + return tx.Save(&client).Error + }) + if err != nil { + return "", nil, fmt.Errorf("saving oauth client: %w", err) + } + + return secretStr, &client, nil +} + +// AuthenticateOAuthClient validates a presented client secret and returns the +// matching, unrevoked [types.OAuthClient]. The client id is derived from the +// secret (its middle segment), so any separately-supplied client_id is +// redundant, matching Tailscale, where get-authkey passes a dummy id and the +// server derives the real one from the secret. +func (hsdb *HSDatabase) AuthenticateOAuthClient(secretStr string) (*types.OAuthClient, error) { + if secretStr == "" { + return nil, ErrOAuthClientFailedToParse + } + + // Tailscale allows the secret to carry optional ?key=value attributes when + // used directly as an auth key; strip them before parsing. + secretStr, _, _ = strings.Cut(secretStr, "?") + + _, rest, found := strings.Cut(secretStr, types.OAuthClientPrefix) + if !found { + return nil, ErrOAuthClientFailedToParse + } + + clientID, secret, err := parsePrefixedKey( + rest, + oauthClientIDLength, + oauthClientSecretLength, + ErrOAuthClientFailedToParse, + ) + if err != nil { + return nil, err + } + + var client types.OAuthClient + if err := hsdb.DB.First(&client, "client_id = ?", clientID).Error; err != nil { //nolint:noinlineerr + return nil, ErrOAuthClientNotFound + } + + if err := verifySecret(client.SecretHash, secret); err != nil { //nolint:noinlineerr + return nil, fmt.Errorf("invalid oauth client secret: %w", err) + } + + if client.Revoked != nil { + return nil, ErrOAuthClientRevoked + } + + return &client, nil +} + +// GetOAuthClientByClientID returns a [types.OAuthClient] by its public client id. +func (hsdb *HSDatabase) GetOAuthClientByClientID(clientID string) (*types.OAuthClient, error) { + var client types.OAuthClient + if result := hsdb.DB.First(&client, "client_id = ?", clientID); result.Error != nil { + return nil, result.Error + } + + return &client, nil +} + +// ListOAuthClients returns every [types.OAuthClient]. +func (hsdb *HSDatabase) ListOAuthClients() ([]types.OAuthClient, error) { + clients := []types.OAuthClient{} + + err := hsdb.DB.Find(&clients).Error + if err != nil { + return nil, err + } + + return clients, nil +} + +// RevokeOAuthClient deletes a client and all access tokens it issued. An unknown +// client id returns [ErrOAuthClientNotFound], so a repeated DELETE is a clean +// 404. Unlike pre-auth keys (which soft-revoke for node-registration history), an +// OAuth client has no such history and is removed outright, matching Tailscale. +func (hsdb *HSDatabase) RevokeOAuthClient(clientID string) error { + return hsdb.Write(func(tx *gorm.DB) error { + err := tx.Where("client_id = ?", clientID). + Delete(&types.OAuthAccessToken{}).Error + if err != nil { + return fmt.Errorf("deleting oauth access tokens: %w", err) + } + + res := tx.Where("client_id = ?", clientID).Delete(&types.OAuthClient{}) + if res.Error != nil { + return res.Error + } + + if res.RowsAffected == 0 { + return ErrOAuthClientNotFound + } + + return nil + }) +} + +// MintAccessToken stores a new [types.OAuthAccessToken] for clientID with the +// given (already narrowed) scopes/tags and expiration, returning the plaintext +// token (shown ONCE). +func (hsdb *HSDatabase) MintAccessToken( + clientID string, + scopes, tags []string, + expiration *time.Time, +) (string, *types.OAuthAccessToken, error) { + prefix := rands.HexString(accessTokenPrefixLength) + secret := rands.HexString(accessTokenSecretLength) + tokenStr := types.AccessTokenPrefix + prefix + "-" + secret + + hash, err := hashSecret(secret) + if err != nil { + return "", nil, err + } + + now := time.Now().UTC() + token := types.OAuthAccessToken{ + Prefix: prefix, + Hash: hash, + ClientID: clientID, + Scopes: scopes, + Tags: tags, + Expiration: expiration, + CreatedAt: &now, + } + + // Mint inside a transaction that re-checks the client still exists and is + // not revoked, so a mint cannot complete against a client being deleted. + err = hsdb.Write(func(tx *gorm.DB) error { + var client types.OAuthClient + + err := tx.First(&client, "client_id = ?", clientID).Error + if err != nil { + return ErrOAuthClientNotFound + } + + if client.Revoked != nil { + return ErrOAuthClientRevoked + } + + return tx.Save(&token).Error + }) + if err != nil { + return "", nil, fmt.Errorf("saving oauth access token: %w", err) + } + + return tokenStr, &token, nil +} + +// AuthenticateAccessToken validates a presented bearer token and returns the +// matching, unexpired [types.OAuthAccessToken] (carrying its granted scopes and +// tags). A non-nil error means the token is missing, malformed, or expired. +func (hsdb *HSDatabase) AuthenticateAccessToken(tokenStr string) (*types.OAuthAccessToken, error) { + if tokenStr == "" { + return nil, ErrAccessTokenFailedToParse + } + + _, rest, found := strings.Cut(tokenStr, types.AccessTokenPrefix) + if !found { + return nil, ErrAccessTokenFailedToParse + } + + prefix, secret, err := parsePrefixedKey( + rest, + accessTokenPrefixLength, + accessTokenSecretLength, + ErrAccessTokenFailedToParse, + ) + if err != nil { + return nil, err + } + + var token types.OAuthAccessToken + if err := hsdb.DB.First(&token, "prefix = ?", prefix).Error; err != nil { //nolint:noinlineerr + return nil, ErrAccessTokenNotFound + } + + if err := verifySecret(token.Hash, secret); err != nil { //nolint:noinlineerr + return nil, fmt.Errorf("invalid oauth access token: %w", err) + } + + if token.Expiration != nil && token.Expiration.Before(time.Now()) { + return nil, ErrAccessTokenExpired + } + + // Bind validity to the issuing client: a token whose client has been + // revoked or deleted is rejected. This closes a mint/revoke race (where a + // token could be inserted after the client's tokens were purged) and any + // orphan left by manual deletion or a future soft-revoke path. + var client types.OAuthClient + if err := hsdb.DB.First(&client, "client_id = ?", token.ClientID).Error; err != nil { //nolint:noinlineerr + return nil, ErrAccessTokenClientRevoked + } + + if client.Revoked != nil { + return nil, ErrAccessTokenClientRevoked + } + + return &token, nil +} + +// DeleteExpiredAccessTokens hard-deletes every access token that expired before +// cutoff, returning how many were removed. Auth-time checks already reject +// expired tokens; the hourly reaper (see app.go) calls this only to keep the +// table from growing unbounded. +func (hsdb *HSDatabase) DeleteExpiredAccessTokens(cutoff time.Time) (int64, error) { + res := hsdb.DB.Where("expiration IS NOT NULL AND expiration < ?", cutoff). + Delete(&types.OAuthAccessToken{}) + + return res.RowsAffected, res.Error +} diff --git a/hscontrol/db/oauth_test.go b/hscontrol/db/oauth_test.go new file mode 100644 index 000000000..1bf8c72d4 --- /dev/null +++ b/hscontrol/db/oauth_test.go @@ -0,0 +1,204 @@ +package db + +import ( + "strings" + "sync" + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestVerifySecretConcurrent runs more concurrent verifications than the Argon2 +// concurrency semaphore admits, asserting the limiter releases correctly (no +// deadlock) and stays correct under contention. Run with -race. +func TestVerifySecretConcurrent(t *testing.T) { + hash, err := hashSecret("s3cr3t") + require.NoError(t, err) + + const n = 64 + + var wg sync.WaitGroup + + errs := make([]error, n) + + for i := range n { + wg.Add(1) + + go func(i int) { + defer wg.Done() + + if i%2 == 0 { + errs[i] = verifySecret(hash, "s3cr3t") + } else { + errs[i] = verifySecret(hash, "wrong") + } + }(i) + } + + wg.Wait() + + for i, e := range errs { + if i%2 == 0 { + assert.NoError(t, e, "correct secret must verify") + } else { + assert.Error(t, e, "wrong secret must fail") + } + } +} + +func TestOAuthClientCreateAndAuthenticate(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + + secret, client, err := db.CreateOAuthClient( + []string{"auth_keys", "devices:core"}, + []string{"tag:ci"}, + "my client", + nil, + ) + require.NoError(t, err) + require.NotNil(t, client) + + // Secret carries the public client id as its middle segment, so it can be + // derived from the secret alone (the Tailscale get-authkey trick). + assert.True(t, strings.HasPrefix(secret, "hskey-client-"+client.ClientID+"-")) + // Scopes/tags are deduplicated and sorted for stable storage. + assert.Equal(t, []string{"auth_keys", "devices:core"}, client.Scopes) + assert.Equal(t, []string{"tag:ci"}, client.Tags) + // Only the Argon2id hash is stored, never the plaintext. + assert.NotEmpty(t, client.SecretHash) + assert.True(t, strings.HasPrefix(string(client.SecretHash), "$argon2id$")) + + // The secret authenticates, deriving the client id from the secret itself. + got, err := db.AuthenticateOAuthClient(secret) + require.NoError(t, err) + assert.Equal(t, client.ClientID, got.ClientID) + + // A truncated/garbage secret does not. + _, err = db.AuthenticateOAuthClient("hskey-client-deadbeef-nope") + require.Error(t, err) + + // Wrong secret for a real client id is rejected by the constant-time compare. + _, err = db.AuthenticateOAuthClient("hskey-client-" + client.ClientID + "-" + strings.Repeat("0", 64)) + require.Error(t, err) +} + +func TestHashSecretRoundTrip(t *testing.T) { + const secret = "a-high-entropy-credential-secret" + + encoded, err := hashSecret(secret) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(encoded), "$argon2id$v=")) + + // The same secret hashes to a different value each time (random salt) yet + // still verifies. + encoded2, err := hashSecret(secret) + require.NoError(t, err) + assert.NotEqual(t, encoded, encoded2) + + require.NoError(t, verifySecret(encoded, secret)) + require.ErrorIs(t, verifySecret(encoded, "wrong-secret"), errSecretMismatch) + require.ErrorIs(t, verifySecret([]byte("not-a-phc-string"), secret), errSecretHashMalformed) +} + +func TestOAuthClientRevoke(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + + secret, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil) + require.NoError(t, err) + + // A token minted by the client survives only until the client is revoked. + _, _, err = db.MintAccessToken(client.ClientID, client.Scopes, client.Tags, nil) + require.NoError(t, err) + + require.NoError(t, db.RevokeOAuthClient(client.ClientID)) + + // The client no longer authenticates and a repeated revoke is a clean 404. + _, err = db.AuthenticateOAuthClient(secret) + require.Error(t, err) + require.ErrorIs(t, db.RevokeOAuthClient(client.ClientID), ErrOAuthClientNotFound) +} + +func TestOAuthAccessTokenMintAuthenticateExpire(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + + _, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil) + require.NoError(t, err) + + future := time.Now().Add(time.Hour) + tokenStr, token, err := db.MintAccessToken( + client.ClientID, + []string{"auth_keys"}, + []string{"tag:ci"}, + &future, + ) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(tokenStr, "hskey-oauthtok-")) + + got, err := db.AuthenticateAccessToken(tokenStr) + require.NoError(t, err) + assert.Equal(t, client.ClientID, got.ClientID) + assert.Equal(t, []string{"auth_keys"}, got.Scopes) + assert.Equal(t, []string{"tag:ci"}, got.Tags) + + // An expired token is rejected even though the row still exists. + past := time.Now().Add(-time.Hour) + expiredStr, _, err := db.MintAccessToken(client.ClientID, nil, nil, &past) + require.NoError(t, err) + _, err = db.AuthenticateAccessToken(expiredStr) + require.ErrorIs(t, err, ErrAccessTokenExpired) + + // The reaper deletes the expired row; the live token is untouched. + n, err := db.DeleteExpiredAccessTokens(time.Now()) + require.NoError(t, err) + assert.Equal(t, int64(1), n) + + _ = token + + _, err = db.AuthenticateAccessToken(tokenStr) + require.NoError(t, err) +} + +// TestAccessTokenRejectedWhenClientGone asserts a token whose issuing client no +// longer exists (orphaned by a delete/revoke race) is rejected, even though the +// token row itself is valid and unexpired. +func TestAccessTokenRejectedWhenClientGone(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + + _, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil) + require.NoError(t, err) + + future := time.Now().Add(time.Hour) + tokenStr, _, err := db.MintAccessToken(client.ClientID, []string{"auth_keys"}, []string{"tag:ci"}, &future) + require.NoError(t, err) + + _, err = db.AuthenticateAccessToken(tokenStr) + require.NoError(t, err) + + // Delete only the client row, leaving the token orphaned (the state a + // mint/revoke race or manual deletion would produce). + require.NoError(t, db.DB.Where("client_id = ?", client.ClientID).Delete(&types.OAuthClient{}).Error) + + _, err = db.AuthenticateAccessToken(tokenStr) + require.ErrorIs(t, err, ErrAccessTokenClientRevoked) + + // A soft-revoked client (row present, Revoked set) is likewise rejected. + _, client2, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil) + require.NoError(t, err) + + tokenStr2, _, err := db.MintAccessToken(client2.ClientID, []string{"auth_keys"}, []string{"tag:ci"}, &future) + require.NoError(t, err) + + now := time.Now() + require.NoError(t, db.DB.Model(&types.OAuthClient{}). + Where("client_id = ?", client2.ClientID).Update("revoked", now).Error) + + _, err = db.AuthenticateAccessToken(tokenStr2) + require.ErrorIs(t, err, ErrAccessTokenClientRevoked) +} diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index c8369f794..e839ff507 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -8,9 +8,9 @@ import ( "time" "github.com/juanfont/headscale/hscontrol/types" - "github.com/juanfont/headscale/hscontrol/util" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "tailscale.com/util/rands" "tailscale.com/util/set" ) @@ -25,6 +25,26 @@ var ( ErrPreAuthKeyACLTagInvalid = errors.New("auth-key tag is invalid") ) +// validateACLTags deduplicates, sorts, and checks that every tag carries the +// "tag:" prefix. Shared by the pre-auth-key and OAuth credential paths so both +// enforce the same tag shape. +func validateACLTags(tags []string) ([]string, error) { + tags = set.SetOf(tags).Slice() + slices.Sort(tags) + + for _, tag := range tags { + if !strings.HasPrefix(tag, "tag:") { + return nil, fmt.Errorf( + "%w: '%s' did not begin with 'tag:'", + ErrPreAuthKeyACLTagInvalid, + tag, + ) + } + } + + return tags, nil +} + func (hsdb *HSDatabase) CreatePreAuthKey( uid *types.UserID, reusable bool, @@ -76,51 +96,16 @@ func CreatePreAuthKey( userID = &user.ID } - // Remove duplicates and sort for consistency - aclTags = set.SetOf(aclTags).Slice() - slices.Sort(aclTags) - - // TODO(kradalby): factor out and create a reusable tag validation, - // check if there is one in Tailscale's lib. - for _, tag := range aclTags { - if !strings.HasPrefix(tag, "tag:") { - return nil, fmt.Errorf( - "%w: '%s' did not begin with 'tag:'", - ErrPreAuthKeyACLTagInvalid, - tag, - ) - } + aclTags, err := validateACLTags(aclTags) + if err != nil { + return nil, err } now := time.Now().UTC() - prefix, err := util.GenerateRandomStringURLSafe(authKeyPrefixLength) - if err != nil { - return nil, err - } + prefix := rands.HexString(authKeyPrefixLength) - // Validate generated prefix (should always be valid, but be defensive) - if len(prefix) != authKeyPrefixLength { - return nil, fmt.Errorf("%w: generated prefix has invalid length: expected %d, got %d", ErrPreAuthKeyFailedToParse, authKeyPrefixLength, len(prefix)) - } - - if !isValidBase64URLSafe(prefix) { - return nil, fmt.Errorf("%w: generated prefix contains invalid characters", ErrPreAuthKeyFailedToParse) - } - - toBeHashed, err := util.GenerateRandomStringURLSafe(authKeyLength) - if err != nil { - return nil, err - } - - // Validate generated hash (should always be valid, but be defensive) - if len(toBeHashed) != authKeyLength { - return nil, fmt.Errorf("%w: generated hash has invalid length: expected %d, got %d", ErrPreAuthKeyFailedToParse, authKeyLength, len(toBeHashed)) - } - - if !isValidBase64URLSafe(toBeHashed) { - return nil, fmt.Errorf("%w: generated hash contains invalid characters", ErrPreAuthKeyFailedToParse) - } + toBeHashed := rands.HexString(authKeyLength) keyStr := authKeyPrefix + prefix + "-" + toBeHashed @@ -157,6 +142,15 @@ func CreatePreAuthKey( }, nil } +// SetPreAuthKeyDescription sets the free-text description on a pre-auth key. +// The v2 keys API sets it after creation rather than threading it through the +// many-armed CreatePreAuthKey signature shared by every other caller. +func (hsdb *HSDatabase) SetPreAuthKeyDescription(id uint64, description string) error { + return hsdb.DB.Model(&types.PreAuthKey{}). + Where("id = ?", id). + Update("description", description).Error +} + func (hsdb *HSDatabase) ListPreAuthKeys() ([]types.PreAuthKey, error) { return Read(hsdb.DB, ListPreAuthKeys) } @@ -211,60 +205,18 @@ func findAuthKey(tx *gorm.DB, keyStr string) (*types.PreAuthKey, error) { } // New format: hskey-auth-{12-char-prefix}-{64-char-hash} - // Expected minimum length: 12 (prefix) + 1 (separator) + 64 (hash) = 77 - const expectedMinLength = authKeyPrefixLength + 1 + authKeyLength - if len(prefixAndHash) < expectedMinLength { - return nil, fmt.Errorf( - "%w: key too short, expected at least %d chars after prefix, got %d", - ErrPreAuthKeyFailedToParse, - expectedMinLength, - len(prefixAndHash), - ) - } - - // Use fixed-length parsing instead of separator-based to handle dashes in base64 URL-safe - prefix := prefixAndHash[:authKeyPrefixLength] - - // Validate separator at expected position - if prefixAndHash[authKeyPrefixLength] != '-' { - return nil, fmt.Errorf( - "%w: expected separator '-' at position %d, got '%c'", - ErrPreAuthKeyFailedToParse, - authKeyPrefixLength, - prefixAndHash[authKeyPrefixLength], - ) - } - - hash := prefixAndHash[authKeyPrefixLength+1:] - - // Validate hash length - if len(hash) != authKeyLength { - return nil, fmt.Errorf( - "%w: hash length mismatch, expected %d chars, got %d", - ErrPreAuthKeyFailedToParse, - authKeyLength, - len(hash), - ) - } - - // Validate prefix contains only base64 URL-safe characters - if !isValidBase64URLSafe(prefix) { - return nil, fmt.Errorf( - "%w: prefix contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)", - ErrPreAuthKeyFailedToParse, - ) - } - - // Validate hash contains only base64 URL-safe characters - if !isValidBase64URLSafe(hash) { - return nil, fmt.Errorf( - "%w: hash contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)", - ErrPreAuthKeyFailedToParse, - ) + prefix, hash, err := parsePrefixedKey( + prefixAndHash, + authKeyPrefixLength, + authKeyLength, + ErrPreAuthKeyFailedToParse, + ) + if err != nil { + return nil, err } // Look up key by prefix - err := tx.Preload("User").First(&pak, "prefix = ?", prefix).Error + err = tx.Preload("User").First(&pak, "prefix = ?", prefix).Error if err != nil { return nil, ErrPreAuthKeyNotFound } @@ -278,15 +230,78 @@ func findAuthKey(tx *gorm.DB, keyStr string) (*types.PreAuthKey, error) { return &pak, nil } -// isValidBase64URLSafe checks if a string contains only base64 URL-safe characters. -func isValidBase64URLSafe(s string) bool { - for _, c := range s { - if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '_' { - return false - } +// parsePrefixedKey splits the prefix-and-secret portion of a new-format key +// (the part after the "hskey-*-" prefix) into its fixed-length prefix and +// secret components, validating the length, separator position, and that both +// components are base64 URL-safe. Fixed-length parsing is used instead of +// separator-based to handle dashes in base64 URL-safe characters. +func parsePrefixedKey( + prefixAndSecret string, + //nolint:unparam // kept explicit though every credential kind uses a 12-char prefix and 64-char secret today + prefixLen, secretLen int, + parseErr error, +) (string, string, error) { + expectedMinLength := prefixLen + 1 + secretLen + if len(prefixAndSecret) < expectedMinLength { + return "", "", fmt.Errorf( + "%w: key too short, expected at least %d chars after prefix, got %d", + parseErr, + expectedMinLength, + len(prefixAndSecret), + ) } - return true + prefix := prefixAndSecret[:prefixLen] + + // Validate separator at expected position + if prefixAndSecret[prefixLen] != '-' { + return "", "", fmt.Errorf( + "%w: expected separator '-' at position %d, got '%c'", + parseErr, + prefixLen, + prefixAndSecret[prefixLen], + ) + } + + secret := prefixAndSecret[prefixLen+1:] + + // Validate secret length + if len(secret) != secretLen { + return "", "", fmt.Errorf( + "%w: secret length mismatch, expected %d chars, got %d", + parseErr, + secretLen, + len(secret), + ) + } + + // Validate prefix contains only base64 URL-safe characters + if !isValidBase64URLSafe(prefix) { + return "", "", fmt.Errorf( + "%w: prefix contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)", + parseErr, + ) + } + + // Validate secret contains only base64 URL-safe characters + if !isValidBase64URLSafe(secret) { + return "", "", fmt.Errorf( + "%w: secret contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)", + parseErr, + ) + } + + return prefix, secret, nil +} + +// isValidBase64URLSafe reports whether s contains only base64 URL-safe +// characters (A-Za-z0-9-_). Key material is now generated as hex, a subset of +// this alphabet, so this accepts both current hex keys and any legacy keys +// still stored in the database. +func isValidBase64URLSafe(s string) bool { + return !strings.ContainsFunc(s, func(c rune) bool { + return (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '_' + }) } func (hsdb *HSDatabase) GetPreAuthKey(key string) (*types.PreAuthKey, error) { @@ -299,6 +314,20 @@ func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) { return findAuthKey(tx, key) } +// GetPreAuthKeyByID returns a [types.PreAuthKey] by its primary key, with the +// owning user preloaded. +func (hsdb *HSDatabase) GetPreAuthKeyByID(id uint64) (*types.PreAuthKey, error) { + pak := types.PreAuthKey{} + // Explicit primary-key clause: a struct condition would drop a zero-valued + // ID, making the lookup unconditional and returning the first row instead + // of not-found. + if result := hsdb.DB.Preload("User").First(&pak, "id = ?", id); result.Error != nil { + return nil, result.Error + } + + return &pak, nil +} + // DestroyPreAuthKey destroys a preauthkey. Returns error if the [types.PreAuthKey] // does not exist. This also clears the auth_key_id on any nodes that reference // this key. @@ -313,9 +342,13 @@ func DestroyPreAuthKey(tx *gorm.DB, id uint64) error { } // Then delete the pre-auth key - err = tx.Unscoped().Delete(&types.PreAuthKey{}, id).Error - if err != nil { - return err + res := tx.Unscoped().Delete(&types.PreAuthKey{}, id) + if res.Error != nil { + return res.Error + } + + if res.RowsAffected == 0 { + return ErrPreAuthKeyNotFound } return nil @@ -334,6 +367,63 @@ func (hsdb *HSDatabase) DeletePreAuthKey(id uint64) error { }) } +func (hsdb *HSDatabase) RevokePreAuthKey(id uint64) error { + return hsdb.Write(func(tx *gorm.DB) error { + return RevokePreAuthKey(tx, id) + }) +} + +// RevokePreAuthKey soft-revokes a key (the v2 API's DELETE): the row is kept and +// stays retrievable with its invalid flag set, but the key can no longer +// authorize nodes. The background collector hard-deletes it after the retention +// window. An already-revoked or unknown id returns [ErrPreAuthKeyNotFound], so a +// repeated DELETE is a clean 404. +func RevokePreAuthKey(tx *gorm.DB, id uint64) error { + res := tx.Model(&types.PreAuthKey{}). + Where("id = ? AND revoked IS NULL", id). + Update("revoked", time.Now()) + if res.Error != nil { + return res.Error + } + + if res.RowsAffected == 0 { + return ErrPreAuthKeyNotFound + } + + return nil +} + +// DestroyRevokedPreAuthKeysBefore hard-deletes every key revoked before cutoff, +// returning how many were removed. The background collector calls this to reap +// soft-revoked keys after the retention window. +func (hsdb *HSDatabase) DestroyRevokedPreAuthKeysBefore(cutoff time.Time) (int, error) { + var count int + + err := hsdb.Write(func(tx *gorm.DB) error { + var ids []uint64 + + err := tx.Model(&types.PreAuthKey{}). + Where("revoked IS NOT NULL AND revoked < ?", cutoff). + Pluck("id", &ids).Error + if err != nil { + return err + } + + for _, id := range ids { + err := DestroyPreAuthKey(tx, id) + if err != nil { + return err + } + } + + count = len(ids) + + return nil + }) + + return count, err +} + // UsePreAuthKey atomically marks a [types.PreAuthKey] as used. The UPDATE is // guarded by `used = false` so two concurrent registrations racing for // the same single-use key cannot both succeed: the first commits and @@ -357,8 +447,19 @@ func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error { return nil } -// ExpirePreAuthKey marks a [types.PreAuthKey] as expired. +// ExpirePreAuthKey marks a [types.PreAuthKey] as expired, returning +// [ErrPreAuthKeyNotFound] rather than succeeding silently when no such key exists. func ExpirePreAuthKey(tx *gorm.DB, id uint64) error { now := time.Now() - return tx.Model(&types.PreAuthKey{}).Where("id = ?", id).Update("expiration", now).Error + + res := tx.Model(&types.PreAuthKey{}).Where("id = ?", id).Update("expiration", now) + if res.Error != nil { + return res.Error + } + + if res.RowsAffected == 0 { + return ErrPreAuthKeyNotFound + } + + return nil } diff --git a/hscontrol/db/preauth_keys_test.go b/hscontrol/db/preauth_keys_test.go index c152959c5..5835758f3 100644 --- a/hscontrol/db/preauth_keys_test.go +++ b/hscontrol/db/preauth_keys_test.go @@ -96,7 +96,7 @@ func TestPreAuthKeyACLTags(t *testing.T) { require.NoError(t, err) require.Len(t, listedPaks, 1) - gotTags := listedPaks[0].Proto().GetAclTags() + gotTags := slices.Clone(listedPaks[0].Tags) slices.Sort(gotTags) assert.Equal(t, expectedTags, gotTags) }, diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql index 781446c00..59b601601 100644 --- a/hscontrol/db/schema.sql +++ b/hscontrol/db/schema.sql @@ -44,11 +44,13 @@ CREATE TABLE pre_auth_keys( prefix text, hash blob, user_id integer, + description text, reusable numeric, ephemeral numeric DEFAULT false, used numeric DEFAULT false, tags text, expiration datetime, + revoked datetime, created_at datetime, @@ -60,6 +62,7 @@ CREATE TABLE api_keys( id integer PRIMARY KEY AUTOINCREMENT, prefix text, hash blob, + user_id integer, expiration datetime, last_seen datetime, @@ -67,6 +70,36 @@ CREATE TABLE api_keys( ); CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix); +-- OAuth 2.0 client-credentials clients for the v2 API. client_id is public and +-- embedded in the secret (hskey-client--); only the bcrypt +-- hash of the secret is stored. Mirrors the api_keys security model. +CREATE TABLE oauth_clients( + id integer PRIMARY KEY AUTOINCREMENT, + client_id text, + secret_hash blob, + scopes text, + tags text, + description text, + user_id integer, + created_at datetime, + revoked datetime +); +CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id); + +-- Short-lived bearer access tokens minted by an oauth_client. Stored as a bcrypt +-- hash of the secret, looked up by prefix. +CREATE TABLE oauth_access_tokens( + id integer PRIMARY KEY AUTOINCREMENT, + prefix text, + hash blob, + client_id text, + scopes text, + tags text, + expiration datetime, + created_at datetime +); +CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix); + CREATE TABLE nodes( id integer PRIMARY KEY AUTOINCREMENT, machine_key text, diff --git a/hscontrol/db/sqliteconfig/config.go b/hscontrol/db/sqliteconfig/config.go index b4c807955..9d818ae1b 100644 --- a/hscontrol/db/sqliteconfig/config.go +++ b/hscontrol/db/sqliteconfig/config.go @@ -5,6 +5,7 @@ package sqliteconfig import ( "errors" "fmt" + "slices" "strings" ) @@ -90,20 +91,15 @@ const ( JournalModeOff JournalMode = "OFF" ) -// IsValid returns true if the JournalMode is valid. -func (j JournalMode) IsValid() bool { - switch j { - case JournalModeWAL, JournalModeDelete, JournalModeTruncate, - JournalModePersist, JournalModeMemory, JournalModeOff: - return true - default: - return false - } +// validJournalModes lists the accepted JournalMode values. +var validJournalModes = []JournalMode{ + JournalModeWAL, JournalModeDelete, JournalModeTruncate, + JournalModePersist, JournalModeMemory, JournalModeOff, } -// String returns the string representation. -func (j JournalMode) String() string { - return string(j) +// IsValid returns true if the JournalMode is valid. +func (j JournalMode) IsValid() bool { + return slices.Contains(validJournalModes, j) } // AutoVacuum represents SQLite auto_vacuum pragma values. @@ -147,19 +143,14 @@ const ( AutoVacuumIncremental AutoVacuum = "INCREMENTAL" ) -// IsValid returns true if the AutoVacuum is valid. -func (a AutoVacuum) IsValid() bool { - switch a { - case AutoVacuumNone, AutoVacuumFull, AutoVacuumIncremental: - return true - default: - return false - } +// validAutoVacuums lists the accepted AutoVacuum values. +var validAutoVacuums = []AutoVacuum{ + AutoVacuumNone, AutoVacuumFull, AutoVacuumIncremental, } -// String returns the string representation. -func (a AutoVacuum) String() string { - return string(a) +// IsValid returns true if the AutoVacuum is valid. +func (a AutoVacuum) IsValid() bool { + return slices.Contains(validAutoVacuums, a) } // Synchronous represents SQLite synchronous pragma values. @@ -211,19 +202,14 @@ const ( SynchronousExtra Synchronous = "EXTRA" ) -// IsValid returns true if the Synchronous is valid. -func (s Synchronous) IsValid() bool { - switch s { - case SynchronousOff, SynchronousNormal, SynchronousFull, SynchronousExtra: - return true - default: - return false - } +// validSynchronous lists the accepted Synchronous values. +var validSynchronous = []Synchronous{ + SynchronousOff, SynchronousNormal, SynchronousFull, SynchronousExtra, } -// String returns the string representation. -func (s Synchronous) String() string { - return string(s) +// IsValid returns true if the Synchronous is valid. +func (s Synchronous) IsValid() bool { + return slices.Contains(validSynchronous, s) } // TxLock represents SQLite transaction lock mode. @@ -267,19 +253,15 @@ const ( TxLockExclusive TxLock = "exclusive" ) -// IsValid returns true if the TxLock is valid. -func (t TxLock) IsValid() bool { - switch t { - case TxLockDeferred, TxLockImmediate, TxLockExclusive, "": - return true - default: - return false - } +// validTxLocks lists the accepted TxLock values; the empty string is valid +// and selects the driver default. +var validTxLocks = []TxLock{ + TxLockDeferred, TxLockImmediate, TxLockExclusive, "", } -// String returns the string representation. -func (t TxLock) String() string { - return string(t) +// IsValid returns true if the TxLock is valid. +func (t TxLock) IsValid() bool { + return slices.Contains(validTxLocks, t) } // Config holds SQLite database configuration with type-safe enums. @@ -367,33 +349,6 @@ func (c *Config) ToURL() (string, error) { return "", fmt.Errorf("invalid config: %w", err) } - var pragmas []string - - // Add pragma parameters only if they're set (non-zero/non-empty) - if c.BusyTimeout > 0 { - pragmas = append(pragmas, fmt.Sprintf("busy_timeout=%d", c.BusyTimeout)) - } - - if c.JournalMode != "" { - pragmas = append(pragmas, fmt.Sprintf("journal_mode=%s", c.JournalMode)) - } - - if c.AutoVacuum != "" { - pragmas = append(pragmas, fmt.Sprintf("auto_vacuum=%s", c.AutoVacuum)) - } - - if c.WALAutocheckpoint >= 0 { - pragmas = append(pragmas, fmt.Sprintf("wal_autocheckpoint=%d", c.WALAutocheckpoint)) - } - - if c.Synchronous != "" { - pragmas = append(pragmas, fmt.Sprintf("synchronous=%s", c.Synchronous)) - } - - if c.ForeignKeys { - pragmas = append(pragmas, "foreign_keys=ON") - } - // Handle different database types var baseURL string if c.Path == ":memory:" { @@ -403,16 +358,36 @@ func (c *Config) ToURL() (string, error) { } // Build query parameters - queryParts := make([]string, 0, 1+len(pragmas)) + var queryParts []string // Add _txlock first (it's a connection parameter, not a pragma) if c.TxLock != "" { queryParts = append(queryParts, "_txlock="+string(c.TxLock)) } - // Add pragma parameters - for _, pragma := range pragmas { - queryParts = append(queryParts, "_pragma="+pragma) + // Add pragma parameters only if they're set (non-zero/non-empty) + if c.BusyTimeout > 0 { + queryParts = append(queryParts, fmt.Sprintf("_pragma=busy_timeout=%d", c.BusyTimeout)) + } + + if c.JournalMode != "" { + queryParts = append(queryParts, fmt.Sprintf("_pragma=journal_mode=%s", c.JournalMode)) + } + + if c.AutoVacuum != "" { + queryParts = append(queryParts, fmt.Sprintf("_pragma=auto_vacuum=%s", c.AutoVacuum)) + } + + if c.WALAutocheckpoint >= 0 { + queryParts = append(queryParts, fmt.Sprintf("_pragma=wal_autocheckpoint=%d", c.WALAutocheckpoint)) + } + + if c.Synchronous != "" { + queryParts = append(queryParts, fmt.Sprintf("_pragma=synchronous=%s", c.Synchronous)) + } + + if c.ForeignKeys { + queryParts = append(queryParts, "_pragma=foreign_keys=ON") } if len(queryParts) > 0 { diff --git a/hscontrol/db/sqliteconfig/config_test.go b/hscontrol/db/sqliteconfig/config_test.go index 7829d9e92..9f233902e 100644 --- a/hscontrol/db/sqliteconfig/config_test.go +++ b/hscontrol/db/sqliteconfig/config_test.go @@ -98,25 +98,6 @@ func TestTxLock(t *testing.T) { } } -func TestTxLockString(t *testing.T) { - tests := []struct { - mode TxLock - want string - }{ - {TxLockDeferred, "deferred"}, - {TxLockImmediate, "immediate"}, - {TxLockExclusive, "exclusive"}, - } - - for _, tt := range tests { - t.Run(tt.want, func(t *testing.T) { - if got := tt.mode.String(); got != tt.want { - t.Errorf("TxLock.String() = %q, want %q", got, tt.want) - } - }) - } -} - func TestConfigValidate(t *testing.T) { tests := []struct { name string diff --git a/hscontrol/db/testdata/sqlite/null_tags_user_id_migration_test.sql b/hscontrol/db/testdata/sqlite/null_tags_user_id_migration_test.sql new file mode 100644 index 000000000..c45d0ff59 --- /dev/null +++ b/hscontrol/db/testdata/sqlite/null_tags_user_id_migration_test.sql @@ -0,0 +1,85 @@ +-- Test SQL dump for the clear-tagged-node-user-id migration +-- (202602201200-clear-tagged-node-user-id) against nodes whose tags +-- column holds the JSON literal 'null'. +-- +-- A nil Strings slice marshals to the JSON literal `null`, so pre-0.29 +-- databases contain untagged nodes with tags='null'. The migration's +-- WHERE clause (tags IS NOT NULL AND tags != '[]' AND tags != '') treats +-- the 4-character string 'null' as "tagged" and wrongly clears user_id, +-- detaching the node from its owning user on upgrade. +-- Fixes: https://github.com/juanfont/headscale/issues/3323 + +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; + +-- Migrations table: every entry BEFORE clear-tagged-node-user-id has been +-- applied. That 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'); + +-- 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); +INSERT INTO users VALUES(2,'2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL,'user2','User Two','user2@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: tags='null' (untagged, nil slice marshalled to JSON null), owned by user2. +-- After migration: user_id MUST be preserved (this is the bug). +INSERT INTO nodes VALUES(1,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e01','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605501','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57701','[]','{}','100.64.0.1','fd7a:115c:a1e0::1','node1','node1',2,'cli','null',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 2: genuinely tagged, owned by user1. +-- After migration: user_id MUST be cleared to NULL (tagged nodes are owned by tags). +INSERT INTO nodes VALUES(2,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e02','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605502','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57702','[]','{}','100.64.0.2','fd7a:115c:a1e0::2','node2','node2',1,'cli','["tag:server"]',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: empty-array tags (untagged), owned by user1. +-- After migration: user_id MUST be 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',NULL,'[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL); + +-- Node 4: empty-string tags (untagged), owned by user1. +-- After migration: user_id MUST be 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',NULL,'[]','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',2); +INSERT INTO sqlite_sequence VALUES('nodes',4); +CREATE INDEX idx_users_deleted_at ON users(deleted_at); +CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix); +CREATE INDEX idx_policies_deleted_at ON policies(deleted_at); +CREATE UNIQUE INDEX idx_provider_identifier ON users(provider_identifier) WHERE provider_identifier IS NOT NULL; +CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier); +CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''; + +COMMIT; diff --git a/hscontrol/db/testdata/sqlite/recover_null_tags_user_id_migration_test.sql b/hscontrol/db/testdata/sqlite/recover_null_tags_user_id_migration_test.sql new file mode 100644 index 000000000..163dcd352 --- /dev/null +++ b/hscontrol/db/testdata/sqlite/recover_null_tags_user_id_migration_test.sql @@ -0,0 +1,87 @@ +-- Test SQL dump for the null-tags user_id RECOVERY migration. +-- +-- Represents a database that already upgraded to 0.29.0, where the buggy +-- clear-tagged-node-user-id migration (202602201200) already cleared +-- user_id on untagged nodes whose tags column held 'null'. The recovery +-- migration runs against this state and re-derives user_id from the node's +-- pre-auth key where possible. +-- Fixes: https://github.com/juanfont/headscale/issues/3323 + +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; + +-- Migrations table: everything through the current last migration has been +-- applied (this DB already ran the buggy clear-tagged migration). The new +-- recovery 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'); +INSERT INTO migrations VALUES('202605221435-clear-zero-time-node-expiry'); + +-- 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); +INSERT INTO users VALUES(2,'2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL,'user2','User Two','user2@example.com',NULL,NULL,NULL); + +-- Pre-auth keys table. Key 1 belongs to user2, key 2 to user1. +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); +INSERT INTO pre_auth_keys VALUES(1,NULL,2,1,false,true,NULL,'2024-01-01 00:00:00+00:00',NULL,'pak1',NULL); +INSERT INTO pre_auth_keys VALUES(2,NULL,1,1,false,true,NULL,'2024-01-01 00:00:00+00:00',NULL,'pak2',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 +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: authkey-registered, tags='null', already orphaned (user_id NULL) by +-- the buggy migration. auth_key_id=1 (user2). Recovery: user_id -> 2. +INSERT INTO nodes VALUES(1,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e01','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605501','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57701','[]','{}','100.64.0.1','fd7a:115c:a1e0::1','node1','node1',NULL,'authkey','null',1,'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 2: genuinely tagged, user_id correctly cleared. Must stay NULL. +INSERT INTO nodes VALUES(2,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e02','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605502','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57702','[]','{}','100.64.0.2','fd7a:115c:a1e0::2','node2','node2',NULL,'authkey','["tag:server"]',2,'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: CLI-registered, tags='null', orphaned, no auth_key_id. +-- Unrecoverable: must stay NULL. +INSERT INTO nodes VALUES(3,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e03','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605503','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57703','[]','{}','100.64.0.3','fd7a:115c:a1e0::3','node3','node3',NULL,'cli','null',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 4: authkey-registered, untouched (user_id still set). Must stay user1. +INSERT INTO nodes VALUES(4,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e04','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605504','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57704','[]','{}','100.64.0.4','fd7a:115c:a1e0::4','node4','node4',1,'authkey','null',2,'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); + +-- 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',2); +INSERT INTO sqlite_sequence VALUES('pre_auth_keys',2); +INSERT INTO sqlite_sequence VALUES('nodes',4); +CREATE INDEX idx_users_deleted_at ON users(deleted_at); +CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix); +CREATE INDEX idx_policies_deleted_at ON policies(deleted_at); +CREATE UNIQUE INDEX idx_provider_identifier ON users(provider_identifier) WHERE provider_identifier IS NOT NULL; +CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier); +CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''; + +COMMIT; diff --git a/hscontrol/db/text_serialiser.go b/hscontrol/db/text_serialiser.go index 7c39b8bed..c315772bc 100644 --- a/hscontrol/db/text_serialiser.go +++ b/hscontrol/db/text_serialiser.go @@ -47,45 +47,45 @@ func (TextSerialiser) Scan(ctx context.Context, field *schema.Field, dst reflect fieldValue = fieldValue.Elem() } - if dbValue != nil { - var bytes []byte + if dbValue == nil { + return nil + } - switch v := dbValue.(type) { - case []byte: - bytes = v - case string: - bytes = []byte(v) - default: - return fmt.Errorf("%w: %#v", errUnmarshalTextValue, dbValue) + var bytes []byte + + switch v := dbValue.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + return fmt.Errorf("%w: %#v", errUnmarshalTextValue, dbValue) + } + + if !isTextUnmarshaler(fieldValue) { + return fmt.Errorf("%w: %T", errUnsupportedType, fieldValue.Interface()) + } + + maybeInstantiatePtr(fieldValue) + f := fieldValue.MethodByName("UnmarshalText") + args := []reflect.Value{reflect.ValueOf(bytes)} + + ret := f.Call(args) + if !ret[0].IsNil() { + if err, ok := ret[0].Interface().(error); ok { + return decodingError(field.Name, err) } + } - if isTextUnmarshaler(fieldValue) { - maybeInstantiatePtr(fieldValue) - f := fieldValue.MethodByName("UnmarshalText") - args := []reflect.Value{reflect.ValueOf(bytes)} - - ret := f.Call(args) - if !ret[0].IsNil() { - if err, ok := ret[0].Interface().(error); ok { - return decodingError(field.Name, err) - } - } - - // If the underlying field is to a pointer type, we need to - // assign the value as a pointer to it. - // If it is not a pointer, we need to assign the value to the - // field. - dstField := field.ReflectValueOf(ctx, dst) - if dstField.Kind() == reflect.Pointer { - dstField.Set(fieldValue) - } else { - dstField.Set(fieldValue.Elem()) - } - - return nil - } else { - return fmt.Errorf("%w: %T", errUnsupportedType, fieldValue.Interface()) - } + // If the underlying field is to a pointer type, we need to + // assign the value as a pointer to it. + // If it is not a pointer, we need to assign the value to the + // field. + dstField := field.ReflectValueOf(ctx, dst) + if dstField.Kind() == reflect.Pointer { + dstField.Set(fieldValue) + } else { + dstField.Set(fieldValue.Elem()) } return nil diff --git a/hscontrol/db/users.go b/hscontrol/db/users.go index 3eee704c9..86ee936fe 100644 --- a/hscontrol/db/users.go +++ b/hscontrol/db/users.go @@ -12,11 +12,10 @@ import ( ) var ( - ErrUserExists = errors.New("user already exists") - ErrUserNotFound = errors.New("user not found") - ErrUserStillHasNodes = errors.New("user not empty: node(s) found") - ErrUserWhereInvalidCount = errors.New("expect 0 or 1 where User structs") - ErrUserNotUnique = errors.New("expected exactly one user") + ErrUserExists = errors.New("user already exists") + ErrUserNotFound = errors.New("user not found") + ErrUserStillHasNodes = errors.New("user not empty: node(s) found") + ErrUserNotUnique = errors.New("expected exactly one user") ) func (hsdb *HSDatabase) CreateUser(user types.User) (*types.User, error) { @@ -95,8 +94,6 @@ var ErrCannotChangeOIDCUser = errors.New("cannot edit OIDC user") // RenameUser renames a [types.User]. Returns error if the [types.User] does // not exist or if another [types.User] exists with the new name. func RenameUser(tx *gorm.DB, uid types.UserID, newName string) error { - var err error - oldUser, err := GetUserByID(tx, uid) if err != nil { return err @@ -154,24 +151,15 @@ func GetUserByOIDCIdentifier(tx *gorm.DB, id string) (*types.User, error) { return &user, nil } -func (hsdb *HSDatabase) ListUsers(where ...*types.User) ([]types.User, error) { - return ListUsers(hsdb.DB, where...) +func (hsdb *HSDatabase) ListUsers(filter *types.User) ([]types.User, error) { + return ListUsers(hsdb.DB, filter) } -// ListUsers gets all the existing users. -func ListUsers(tx *gorm.DB, where ...*types.User) ([]types.User, error) { - if len(where) > 1 { - return nil, fmt.Errorf("%w, got %d", ErrUserWhereInvalidCount, len(where)) - } - - var user *types.User - if len(where) == 1 { - user = where[0] - } - +// ListUsers gets all the existing users, optionally filtered by a non-nil filter. +func ListUsers(tx *gorm.DB, filter *types.User) ([]types.User, error) { users := []types.User{} - err := tx.Where(user).Find(&users).Error + err := tx.Where(filter).Find(&users).Error if err != nil { return nil, err } @@ -204,7 +192,7 @@ func ListNodesByUser(tx *gorm.DB, uid types.UserID) (types.Nodes, error) { uidPtr := uint(uid) - err := tx.Preload("AuthKey").Preload("AuthKey.User").Preload("User").Where(&types.Node{UserID: &uidPtr}).Find(&nodes).Error + err := preloadNode(tx).Where(&types.Node{UserID: &uidPtr}).Find(&nodes).Error if err != nil { return nil, err } @@ -217,10 +205,7 @@ func (hsdb *HSDatabase) CreateUserForTest(name ...string) *types.User { panic("CreateUserForTest can only be called during tests") } - userName := "testuser" - if len(name) > 0 && name[0] != "" { - userName = name[0] - } + userName := firstOr("testuser", name) user, err := hsdb.CreateUser(types.User{Name: userName}) if err != nil { @@ -235,10 +220,7 @@ func (hsdb *HSDatabase) CreateUsersForTest(count int, namePrefix ...string) []*t panic("CreateUsersForTest can only be called during tests") } - prefix := "testuser" - if len(namePrefix) > 0 && namePrefix[0] != "" { - prefix = namePrefix[0] - } + prefix := firstOr("testuser", namePrefix) users := make([]*types.User, count) for i := range count { diff --git a/hscontrol/db/users_test.go b/hscontrol/db/users_test.go index 36d836f91..2a755ff3c 100644 --- a/hscontrol/db/users_test.go +++ b/hscontrol/db/users_test.go @@ -17,7 +17,7 @@ func TestCreateAndDestroyUser(t *testing.T) { user := db.CreateUserForTest("test") assert.Equal(t, "test", user.Name) - users, err := db.ListUsers() + users, err := db.ListUsers(nil) require.NoError(t, err) assert.Len(t, users, 1) @@ -227,7 +227,7 @@ func TestRenameUser(t *testing.T) { userTest := db.CreateUserForTest("test") assert.Equal(t, "test", userTest.Name) - users, err := db.ListUsers() + users, err := db.ListUsers(nil) require.NoError(t, err) assert.Len(t, users, 1) diff --git a/hscontrol/db/versioncheck.go b/hscontrol/db/versioncheck.go index a071348c1..199b5964d 100644 --- a/hscontrol/db/versioncheck.go +++ b/hscontrol/db/versioncheck.go @@ -11,6 +11,7 @@ import ( "github.com/juanfont/headscale/hscontrol/types" "github.com/rs/zerolog/log" "gorm.io/gorm" + "gorm.io/gorm/clause" ) var errVersionUpgrade = errors.New("version upgrade not supported") @@ -66,22 +67,20 @@ func parseVersion(s string) (semver, error) { return semver{}, fmt.Errorf("%q: %w", s, errVersionFormat) } - major, err := strconv.Atoi(parts[0]) - if err != nil { - return semver{}, fmt.Errorf("invalid major version in %q: %w", s, err) + var out [3]int + + names := [...]string{"major", "minor", "patch"} + + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return semver{}, fmt.Errorf("invalid %s version in %q: %w", names[i], s, err) + } + + out[i] = n } - minor, err := strconv.Atoi(parts[1]) - if err != nil { - return semver{}, fmt.Errorf("invalid minor version in %q: %w", s, err) - } - - patch, err := strconv.Atoi(parts[2]) - if err != nil { - return semver{}, fmt.Errorf("invalid patch version in %q: %w", s, err) - } - - return semver{Major: major, Minor: minor, Patch: patch}, nil + return semver{Major: out[0], Minor: out[1], Patch: out[2]}, nil } // ensureDatabaseVersionTable creates the database_versions table if it @@ -118,23 +117,12 @@ func getDatabaseVersion(db *gorm.DB) (string, error) { func setDatabaseVersion(db *gorm.DB, version string) error { now := time.Now().UTC() - // Try update first, then insert if no rows affected. - result := db.Exec( - "UPDATE database_versions SET version = ?, updated_at = ? WHERE id = 1", - version, now, - ) - if result.Error != nil { - return fmt.Errorf("updating database version: %w", result.Error) - } - - if result.RowsAffected == 0 { - err := db.Exec( - "INSERT INTO database_versions (id, version, updated_at) VALUES (1, ?, ?)", - version, now, - ).Error - if err != nil { - return fmt.Errorf("inserting database version: %w", err) - } + err := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns([]string{"version", "updated_at"}), + }).Create(&DatabaseVersion{ID: 1, Version: version, UpdatedAt: now}).Error + if err != nil { + return fmt.Errorf("upserting database version: %w", err) } return nil diff --git a/hscontrol/debug.go b/hscontrol/debug.go index 49be244f2..cc2a360bf 100644 --- a/hscontrol/debug.go +++ b/hscontrol/debug.go @@ -1,12 +1,14 @@ package hscontrol import ( + "cmp" "context" "encoding/json" "fmt" "net" "net/http" "net/netip" + "slices" "strings" "time" @@ -48,51 +50,45 @@ func protectedDebugHandler(h http.Handler) http.Handler { }) } +// writeJSON marshals v with indentation and writes it as a 200 JSON response. +func writeJSON(w http.ResponseWriter, v any) { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + httpError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(b) +} + +// writeDebug renders a debug endpoint as JSON or text/plain depending on the +// request's Accept header. JSON is produced only when explicitly requested; +// text/plain is the default for backward compatibility. +func writeDebug(w http.ResponseWriter, r *http.Request, jsonVal func() any, textVal func() string) { + if strings.Contains(r.Header.Get("Accept"), "application/json") { + writeJSON(w, jsonVal()) + return + } + + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(textVal())) +} + func (h *Headscale) debugHTTPServer() *http.Server { debugMux := http.NewServeMux() debug := tsweb.Debugger(debugMux) // State overview endpoint debug.Handle("overview", "State overview", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check Accept header to determine response format - acceptHeader := r.Header.Get("Accept") - wantsJSON := strings.Contains(acceptHeader, "application/json") - - if wantsJSON { - overview := h.state.DebugOverviewJSON() - - overviewJSON, err := json.MarshalIndent(overview, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(overviewJSON) - } else { - // Default to text/plain for backward compatibility - overview := h.state.DebugOverview() - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(overview)) - } + writeDebug(w, r, func() any { return h.state.DebugOverviewJSON() }, h.state.DebugOverview) })) // Configuration endpoint debug.Handle("config", "Current configuration", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - config := h.state.DebugConfig() - - configJSON, err := json.MarshalIndent(config, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(configJSON) + writeJSON(w, h.state.DebugConfig()) })) // Policy endpoint @@ -123,157 +119,37 @@ func (h *Headscale) debugHTTPServer() *http.Server { return } - filterJSON, err := json.MarshalIndent(filter, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(filterJSON) + writeJSON(w, filter) })) // SSH policies endpoint debug.Handle("ssh", "SSH policies per node", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sshPolicies := h.state.DebugSSHPolicies() - - sshJSON, err := json.MarshalIndent(sshPolicies, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(sshJSON) + writeJSON(w, h.state.DebugSSHPolicies()) })) // DERP map endpoint debug.Handle("derp", "DERP map configuration", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check Accept header to determine response format - acceptHeader := r.Header.Get("Accept") - wantsJSON := strings.Contains(acceptHeader, "application/json") - - if wantsJSON { - derpInfo := h.state.DebugDERPJSON() - - derpJSON, err := json.MarshalIndent(derpInfo, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(derpJSON) - } else { - // Default to text/plain for backward compatibility - derpInfo := h.state.DebugDERPMap() - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(derpInfo)) - } + writeDebug(w, r, func() any { return h.state.DebugDERPJSON() }, h.state.DebugDERPMap) })) // [state.NodeStore] endpoint debug.Handle("nodestore", "NodeStore information", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check Accept header to determine response format - acceptHeader := r.Header.Get("Accept") - wantsJSON := strings.Contains(acceptHeader, "application/json") - - if wantsJSON { - nodeStoreNodes := h.state.DebugNodeStoreJSON() - - nodeStoreJSON, err := json.MarshalIndent(nodeStoreNodes, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(nodeStoreJSON) - } else { - // Default to text/plain for backward compatibility - nodeStoreInfo := h.state.DebugNodeStore() - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(nodeStoreInfo)) - } + writeDebug(w, r, func() any { return h.state.DebugNodeStoreJSON() }, h.state.DebugNodeStore) })) // Registration cache endpoint debug.Handle("registration-cache", "Registration cache information", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cacheInfo := h.state.DebugRegistrationCache() - - cacheJSON, err := json.MarshalIndent(cacheInfo, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(cacheJSON) + writeJSON(w, h.state.DebugRegistrationCache()) })) // Routes endpoint debug.Handle("routes", "Primary routes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check Accept header to determine response format - acceptHeader := r.Header.Get("Accept") - wantsJSON := strings.Contains(acceptHeader, "application/json") - - if wantsJSON { - routes := h.state.DebugRoutes() - - routesJSON, err := json.MarshalIndent(routes, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(routesJSON) - } else { - // Default to text/plain for backward compatibility - routes := h.state.DebugRoutesString() - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(routes)) - } + writeDebug(w, r, func() any { return h.state.DebugRoutes() }, h.state.DebugRoutesString) })) // Policy manager endpoint debug.Handle("policy-manager", "Policy manager state", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check Accept header to determine response format - acceptHeader := r.Header.Get("Accept") - wantsJSON := strings.Contains(acceptHeader, "application/json") - - if wantsJSON { - policyManagerInfo := h.state.DebugPolicyManagerJSON() - - policyManagerJSON, err := json.MarshalIndent(policyManagerInfo, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(policyManagerJSON) - } else { - // Default to text/plain for backward compatibility - policyManagerInfo := h.state.DebugPolicyManager() - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(policyManagerInfo)) - } + writeDebug(w, r, func() any { return h.state.DebugPolicyManagerJSON() }, h.state.DebugPolicyManager) })) debug.Handle("mapresponses", "Map responses for all nodes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -290,43 +166,12 @@ func (h *Headscale) debugHTTPServer() *http.Server { return } - resJSON, err := json.MarshalIndent(res, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(resJSON) + writeJSON(w, res) })) // [mapper.Batcher] endpoint debug.Handle("batcher", "Batcher connected nodes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check Accept header to determine response format - acceptHeader := r.Header.Get("Accept") - wantsJSON := strings.Contains(acceptHeader, "application/json") - - if wantsJSON { - batcherInfo := h.debugBatcherJSON() - - batcherJSON, err := json.MarshalIndent(batcherInfo, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(batcherJSON) - } else { - // Default to text/plain for backward compatibility - batcherInfo := h.debugBatcher() - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(batcherInfo)) - } + writeDebug(w, r, func() any { return h.debugBatcherJSON() }, h.debugBatcher) })) // Ping endpoint: sends a [tailcfg.PingRequest] to a node and waits for it to respond. @@ -419,13 +264,9 @@ func (h *Headscale) debugBatcher() string { } // Sort by node ID - for i := 0; i < len(nodes); i++ { - for j := i + 1; j < len(nodes); j++ { - if nodes[i].id > nodes[j].id { - nodes[i], nodes[j] = nodes[j], nodes[i] - } - } - } + slices.SortFunc(nodes, func(a, b nodeStatus) int { + return cmp.Compare(a.id, b.id) + }) // Output sorted nodes for _, node := range nodes { diff --git a/hscontrol/derp/derp.go b/hscontrol/derp/derp.go index 3d0b7d5bb..beeb66ada 100644 --- a/hscontrol/derp/derp.go +++ b/hscontrol/derp/derp.go @@ -22,19 +22,13 @@ import ( ) func loadDERPMapFromPath(path string) (*tailcfg.DERPMap, error) { - derpFile, err := os.Open(path) + b, err := os.ReadFile(path) if err != nil { return nil, err } - defer derpFile.Close() var derpMap tailcfg.DERPMap - b, err := io.ReadAll(derpFile) - if err != nil { - return nil, err - } - err = yaml.Unmarshal(b, &derpMap) return &derpMap, err @@ -85,16 +79,12 @@ func mergeDERPMaps(derpMaps []*tailcfg.DERPMap) *tailcfg.DERPMap { for _, derpMap := range derpMaps { // Clone each region: copying the pointer would let a later in-place - // shuffle (shuffleRegionNoClone) alias regions shared with the source - // map or a previously served map, racing concurrent readers. + // shuffle alias regions shared with the source map or a previously + // served map, racing concurrent readers. for id, region := range derpMap.Regions { - result.Regions[id] = region.Clone() - } - } - - for id, region := range result.Regions { - if region == nil { - delete(result.Regions, id) + if cloned := region.Clone(); cloned != nil { + result.Regions[id] = cloned + } } } @@ -152,14 +142,13 @@ func shuffleDERPMap(dm *tailcfg.DERPMap) { continue } - dm.Regions[id] = shuffleRegionNoClone(region) + derpRandom().Shuffle(len(region.Nodes), reflect.Swapper(region.Nodes)) } } var crc64Table = crc64.MakeTable(crc64.ISO) var ( - derpRandomOnce sync.Once derpRandomInst *rand.Rand derpRandomMu sync.Mutex ) @@ -168,12 +157,10 @@ func derpRandom() *rand.Rand { derpRandomMu.Lock() defer derpRandomMu.Unlock() - derpRandomOnce.Do(func() { + if derpRandomInst == nil { seed := cmp.Or(viper.GetString("dns.base_domain"), time.Now().String()) - rnd := rand.New(rand.NewSource(0)) //nolint:gosec // weak random is fine for DERP scrambling - rnd.Seed(int64(crc64.Checksum([]byte(seed), crc64Table))) //nolint:gosec // safe conversion - derpRandomInst = rnd - }) + derpRandomInst = rand.New(rand.NewSource(int64(crc64.Checksum([]byte(seed), crc64Table)))) //nolint:gosec // weak random is fine for DERP scrambling + } return derpRandomInst } @@ -182,11 +169,5 @@ func resetDerpRandomForTesting() { derpRandomMu.Lock() defer derpRandomMu.Unlock() - derpRandomOnce = sync.Once{} derpRandomInst = nil } - -func shuffleRegionNoClone(r *tailcfg.DERPRegion) *tailcfg.DERPRegion { - derpRandom().Shuffle(len(r.Nodes), reflect.Swapper(r.Nodes)) - return r -} diff --git a/hscontrol/derp/server/derp_server.go b/hscontrol/derp/server/derp_server.go index fdca651a2..e164c608e 100644 --- a/hscontrol/derp/server/derp_server.go +++ b/hscontrol/derp/server/derp_server.go @@ -76,20 +76,16 @@ func (d *DERPServer) GenerateRegion() (tailcfg.DERPRegion, error) { return tailcfg.DERPRegion{}, err } - var ( - host string - port int - portStr string - ) - // Extract hostname and port from URL - host, portStr, err = net.SplitHostPort(serverURL.Host) + host, portStr, err := net.SplitHostPort(serverURL.Host) + + var port int + if err != nil { + host = serverURL.Host if serverURL.Scheme == "https" { - host = serverURL.Host port = 443 } else { - host = serverURL.Host port = 80 } } else { @@ -371,15 +367,10 @@ func (d *DERPServer) ServeSTUN() { } func serverSTUNListener(ctx context.Context, packetConn *net.UDPConn) { - var ( - buf [64 << 10]byte - bytesRead int - udpAddr *net.UDPAddr - err error - ) + var buf [64 << 10]byte for { - bytesRead, udpAddr, err = packetConn.ReadFromUDP(buf[:]) + bytesRead, udpAddr, err := packetConn.ReadFromUDP(buf[:]) if err != nil { if ctx.Err() != nil { return diff --git a/hscontrol/dns/extrarecords.go b/hscontrol/dns/extrarecords.go index 5dfc223d0..05f42bc9d 100644 --- a/hscontrol/dns/extrarecords.go +++ b/hscontrol/dns/extrarecords.go @@ -27,7 +27,7 @@ type ExtraRecordsMan struct { updateCh chan []tailcfg.DNSRecord closeCh chan struct{} - hashes map[string][32]byte + hash [32]byte } // NewExtraRecordsManager creates a new [ExtraRecordsMan] and starts watching the file at the given path. @@ -52,12 +52,10 @@ func NewExtraRecordsManager(path string) (*ExtraRecordsMan, error) { } er := &ExtraRecordsMan{ - watcher: watcher, - path: path, - records: set.SetOf(records), - hashes: map[string][32]byte{ - path: hash, - }, + watcher: watcher, + path: path, + records: set.SetOf(records), + hash: hash, closeCh: make(chan struct{}), updateCh: make(chan []tailcfg.DNSRecord), } @@ -160,18 +158,16 @@ func (e *ExtraRecordsMan) updateRecords() { e.mu.Lock() // If there has not been any change, ignore the update. - if oldHash, ok := e.hashes[e.path]; ok { - if newHash == oldHash { - e.mu.Unlock() + if newHash == e.hash { + e.mu.Unlock() - return - } + return } oldCount := e.records.Len() e.records = set.SetOf(records) - e.hashes[e.path] = newHash + e.hash = newHash toSend := e.records.Slice() log.Trace().Caller().Interface("records", e.records).Msgf("extra records updated from path, count old: %d, new: %d", oldCount, e.records.Len()) @@ -190,22 +186,24 @@ func (e *ExtraRecordsMan) updateRecords() { // readExtraRecordsFromPath reads a JSON file of [tailcfg.DNSRecord] // and returns the records and the hash of the file. func readExtraRecordsFromPath(path string) ([]tailcfg.DNSRecord, [32]byte, error) { + var zero [32]byte + b, err := os.ReadFile(path) if err != nil { - return nil, [32]byte{}, fmt.Errorf("reading path: %s, err: %w", path, err) + return nil, zero, fmt.Errorf("reading path: %s, err: %w", path, err) } // If the read was triggered too fast, and the file is not complete, ignore the update // if the file is empty. A consecutive update will be triggered when the file is complete. if len(b) == 0 { - return nil, [32]byte{}, nil + return nil, zero, nil } var records []tailcfg.DNSRecord err = json.Unmarshal(b, &records) if err != nil { - return nil, [32]byte{}, fmt.Errorf("unmarshalling records, content: %q: %w", string(b), err) + return nil, zero, fmt.Errorf("unmarshalling records, content: %q: %w", string(b), err) } hash := sha256.Sum256(b) diff --git a/hscontrol/grpcv1.go b/hscontrol/grpcv1.go deleted file mode 100644 index 687fa8577..000000000 --- a/hscontrol/grpcv1.go +++ /dev/null @@ -1,950 +0,0 @@ -//go:generate buf generate --template ../buf.gen.yaml -o .. ../proto - -// nolint -package hscontrol - -import ( - "context" - "errors" - "fmt" - "io" - "net/netip" - "os" - "slices" - "sort" - "strings" - "time" - - "github.com/rs/zerolog/log" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/timestamppb" - "gorm.io/gorm" - "tailscale.com/net/tsaddr" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/views" - - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" - policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" - "github.com/juanfont/headscale/hscontrol/state" - "github.com/juanfont/headscale/hscontrol/types" - "github.com/juanfont/headscale/hscontrol/util" - "github.com/juanfont/headscale/hscontrol/util/zlog/zf" -) - -type headscaleV1APIServer struct { // v1.HeadscaleServiceServer - v1.UnimplementedHeadscaleServiceServer - h *Headscale -} - -func newHeadscaleV1APIServer(h *Headscale) v1.HeadscaleServiceServer { - return headscaleV1APIServer{ - h: h, - } -} - -func (api headscaleV1APIServer) CreateUser( - ctx context.Context, - request *v1.CreateUserRequest, -) (*v1.CreateUserResponse, error) { - newUser := types.User{ - Name: request.GetName(), - DisplayName: request.GetDisplayName(), - Email: request.GetEmail(), - ProfilePicURL: request.GetPictureUrl(), - } - user, policyChanged, err := api.h.state.CreateUser(newUser) - if err != nil { - return nil, status.Errorf(codes.Internal, "creating user: %s", err) - } - - // [state.State.CreateUser] returns a policy change response if the user creation affected policy. - // This triggers a full policy re-evaluation for all connected nodes. - api.h.Change(policyChanged) - - return &v1.CreateUserResponse{User: user.Proto()}, nil -} - -func (api headscaleV1APIServer) RenameUser( - ctx context.Context, - request *v1.RenameUserRequest, -) (*v1.RenameUserResponse, error) { - oldUser, err := api.h.state.GetUserByID(types.UserID(request.GetOldId())) - if err != nil { - return nil, err - } - - _, c, err := api.h.state.RenameUser(types.UserID(oldUser.ID), request.GetNewName()) - if err != nil { - return nil, err - } - - // Send policy update notifications if needed - api.h.Change(c) - - newUser, err := api.h.state.GetUserByName(request.GetNewName()) - if err != nil { - return nil, err - } - - return &v1.RenameUserResponse{User: newUser.Proto()}, nil -} - -func (api headscaleV1APIServer) DeleteUser( - ctx context.Context, - request *v1.DeleteUserRequest, -) (*v1.DeleteUserResponse, error) { - user, err := api.h.state.GetUserByID(types.UserID(request.GetId())) - if err != nil { - return nil, err - } - - policyChanged, err := api.h.state.DeleteUser(types.UserID(user.ID)) - if err != nil { - return nil, err - } - - // Use the change returned from [state.State.DeleteUser] which includes proper policy updates - api.h.Change(policyChanged) - - return &v1.DeleteUserResponse{}, nil -} - -func (api headscaleV1APIServer) ListUsers( - ctx context.Context, - request *v1.ListUsersRequest, -) (*v1.ListUsersResponse, error) { - var err error - var users []types.User - - switch { - case request.GetName() != "": - users, err = api.h.state.ListUsersWithFilter(&types.User{Name: request.GetName()}) - case request.GetEmail() != "": - users, err = api.h.state.ListUsersWithFilter(&types.User{Email: request.GetEmail()}) - case request.GetId() != 0: - users, err = api.h.state.ListUsersWithFilter(&types.User{Model: gorm.Model{ID: uint(request.GetId())}}) - default: - users, err = api.h.state.ListAllUsers() - } - if err != nil { - return nil, err - } - - response := make([]*v1.User, len(users)) - for index, user := range users { - response[index] = user.Proto() - } - - sort.Slice(response, func(i, j int) bool { - return response[i].Id < response[j].Id - }) - - return &v1.ListUsersResponse{Users: response}, nil -} - -func (api headscaleV1APIServer) CreatePreAuthKey( - ctx context.Context, - request *v1.CreatePreAuthKeyRequest, -) (*v1.CreatePreAuthKeyResponse, error) { - var expiration time.Time - if request.GetExpiration() != nil { - expiration = request.GetExpiration().AsTime() - } - - for _, tag := range request.AclTags { - err := validateTag(tag) - if err != nil { - return &v1.CreatePreAuthKeyResponse{ - PreAuthKey: nil, - }, status.Error(codes.InvalidArgument, err.Error()) - } - } - - var userID *types.UserID - if request.GetUser() != 0 { - user, err := api.h.state.GetUserByID(types.UserID(request.GetUser())) - if err != nil { - return nil, err - } - userID = user.TypedID() - } - - preAuthKey, err := api.h.state.CreatePreAuthKey( - userID, - request.GetReusable(), - request.GetEphemeral(), - &expiration, - request.AclTags, - ) - if err != nil { - return nil, err - } - - return &v1.CreatePreAuthKeyResponse{PreAuthKey: preAuthKey.Proto()}, nil -} - -func (api headscaleV1APIServer) ExpirePreAuthKey( - ctx context.Context, - request *v1.ExpirePreAuthKeyRequest, -) (*v1.ExpirePreAuthKeyResponse, error) { - err := api.h.state.ExpirePreAuthKey(request.GetId()) - if err != nil { - return nil, err - } - - return &v1.ExpirePreAuthKeyResponse{}, nil -} - -func (api headscaleV1APIServer) DeletePreAuthKey( - ctx context.Context, - request *v1.DeletePreAuthKeyRequest, -) (*v1.DeletePreAuthKeyResponse, error) { - err := api.h.state.DeletePreAuthKey(request.GetId()) - if err != nil { - return nil, err - } - - return &v1.DeletePreAuthKeyResponse{}, nil -} - -func (api headscaleV1APIServer) ListPreAuthKeys( - ctx context.Context, - request *v1.ListPreAuthKeysRequest, -) (*v1.ListPreAuthKeysResponse, error) { - preAuthKeys, err := api.h.state.ListPreAuthKeys() - if err != nil { - return nil, err - } - - response := make([]*v1.PreAuthKey, len(preAuthKeys)) - for index, key := range preAuthKeys { - response[index] = key.Proto() - } - - sort.Slice(response, func(i, j int) bool { - return response[i].Id < response[j].Id - }) - - return &v1.ListPreAuthKeysResponse{PreAuthKeys: response}, nil -} - -func (api headscaleV1APIServer) RegisterNode( - ctx context.Context, - request *v1.RegisterNodeRequest, -) (*v1.RegisterNodeResponse, error) { - // Generate ephemeral registration key for tracking this registration flow in logs - registrationKey, err := util.GenerateRegistrationKey() - if err != nil { - log.Warn().Err(err).Msg("failed to generate registration key") - registrationKey = "" // Continue without key if generation fails - } - - log.Trace(). - Caller(). - Str(zf.UserName, request.GetUser()). - Str(zf.RegistrationID, request.GetKey()). - Str(zf.RegistrationKey, registrationKey). - Msg("registering node") - - registrationId, err := types.AuthIDFromString(request.GetKey()) - if err != nil { - return nil, err - } - - user, err := api.h.state.GetUserByName(request.GetUser()) - if err != nil { - return nil, fmt.Errorf("looking up user: %w", err) - } - - node, nodeChange, err := api.h.state.HandleNodeFromAuthPath( - registrationId, - types.UserID(user.ID), - nil, - util.RegisterMethodCLI, - ) - if err != nil { - log.Error(). - Str(zf.RegistrationKey, registrationKey). - Err(err). - Msg("failed to register node") - return nil, err - } - - log.Info(). - Str(zf.RegistrationKey, registrationKey). - EmbedObject(node). - Msg("node registered successfully") - - // This is a bit of a back and forth, but we have a bit of a chicken and egg - // dependency here. - // Because the way the policy manager works, we need to have the node - // in the database, then add it to the policy manager and then we can - // approve the route. This means we get this dance where the node is - // first added to the database, then we add it to the policy manager via - // SaveNode (which automatically updates the policy manager) and then we can auto approve the routes. - // As that only approves the struct object, we need to save it again and - // ensure we send an update. - // This works, but might be another good candidate for doing some sort of - // eventbus. - routeChange, err := api.h.state.AutoApproveRoutes(node) - if err != nil { - return nil, fmt.Errorf("auto approving routes: %w", err) - } - - // Send both changes. Empty changes are ignored by [Headscale.Change]. - api.h.Change(nodeChange, routeChange) - - return &v1.RegisterNodeResponse{Node: node.Proto()}, nil -} - -func (api headscaleV1APIServer) GetNode( - ctx context.Context, - request *v1.GetNodeRequest, -) (*v1.GetNodeResponse, error) { - node, ok := api.h.state.GetNodeByID(types.NodeID(request.GetNodeId())) - if !ok { - return nil, status.Errorf(codes.NotFound, "node not found") - } - - resp := node.Proto() - - return &v1.GetNodeResponse{Node: resp}, nil -} - -func (api headscaleV1APIServer) SetTags( - ctx context.Context, - request *v1.SetTagsRequest, -) (*v1.SetTagsResponse, error) { - // Validate tags not empty - tagged nodes must have at least one tag - if len(request.GetTags()) == 0 { - return &v1.SetTagsResponse{ - Node: nil, - }, status.Error( - codes.InvalidArgument, - "cannot remove all tags from a node - tagged nodes must have at least one tag", - ) - } - - // Validate tag format - for _, tag := range request.GetTags() { - err := validateTag(tag) - if err != nil { - return nil, err - } - } - - // User XOR Tags: nodes are either tagged or user-owned, never both. - // Setting tags on a user-owned node converts it to a tagged node. - // Once tagged, a node cannot be converted back to user-owned. - _, found := api.h.state.GetNodeByID(types.NodeID(request.GetNodeId())) - if !found { - return &v1.SetTagsResponse{ - Node: nil, - }, status.Error(codes.NotFound, "node not found") - } - - node, nodeChange, err := api.h.state.SetNodeTags(types.NodeID(request.GetNodeId()), request.GetTags()) - if err != nil { - return &v1.SetTagsResponse{ - Node: nil, - }, status.Error(codes.InvalidArgument, err.Error()) - } - - api.h.Change(nodeChange) - - log.Trace(). - Caller(). - EmbedObject(node). - Strs("tags", request.GetTags()). - Msg("changing tags of node") - - return &v1.SetTagsResponse{Node: node.Proto()}, nil -} - -func (api headscaleV1APIServer) SetApprovedRoutes( - ctx context.Context, - request *v1.SetApprovedRoutesRequest, -) (*v1.SetApprovedRoutesResponse, error) { - log.Debug(). - Caller(). - Uint64(zf.NodeID, request.GetNodeId()). - Strs("requestedRoutes", request.GetRoutes()). - Msg("gRPC SetApprovedRoutes called") - - var newApproved []netip.Prefix - for _, route := range request.GetRoutes() { - prefix, err := netip.ParsePrefix(route) - if err != nil { - return nil, fmt.Errorf("parsing route: %w", err) - } - - // If the prefix is an exit route, add both. The client expect both - // to annotate the node as an exit node. - if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() { - newApproved = append(newApproved, tsaddr.AllIPv4(), tsaddr.AllIPv6()) - } else { - newApproved = append(newApproved, prefix) - } - } - slices.SortFunc(newApproved, netip.Prefix.Compare) - newApproved = slices.Compact(newApproved) - - node, nodeChange, err := api.h.state.SetApprovedRoutes(types.NodeID(request.GetNodeId()), newApproved) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - - // Always propagate node changes from [state.State.SetApprovedRoutes] - api.h.Change(nodeChange) - - proto := node.Proto() - // Populate [types.Node.SubnetRoutes] with [tailcfg.Node.PrimaryRoutes] to ensure it includes only the - // routes that are actively served from the node (per architectural requirement in types/node.go) - primaryRoutes := api.h.state.GetNodePrimaryRoutes(node.ID()) - proto.SubnetRoutes = util.PrefixesToString(primaryRoutes) - - log.Debug(). - Caller(). - EmbedObject(node). - Strs("approvedRoutes", util.PrefixesToString(node.ApprovedRoutes().AsSlice())). - Strs("primaryRoutes", util.PrefixesToString(primaryRoutes)). - Strs("finalSubnetRoutes", proto.SubnetRoutes). - Msg("gRPC SetApprovedRoutes completed") - - return &v1.SetApprovedRoutesResponse{Node: proto}, nil -} - -func validateTag(tag string) error { - if strings.Index(tag, "tag:") != 0 { - return errors.New("tag must start with the string 'tag:'") - } - if strings.ToLower(tag) != tag { - return errors.New("tag should be lowercase") - } - if len(strings.Fields(tag)) > 1 { - return errors.New("tags must not contain spaces") - } - return nil -} - -func (api headscaleV1APIServer) DeleteNode( - ctx context.Context, - request *v1.DeleteNodeRequest, -) (*v1.DeleteNodeResponse, error) { - node, ok := api.h.state.GetNodeByID(types.NodeID(request.GetNodeId())) - if !ok { - return nil, status.Errorf(codes.NotFound, "node not found") - } - - nodeChange, err := api.h.state.DeleteNode(node) - if err != nil { - return nil, err - } - - api.h.Change(nodeChange) - - return &v1.DeleteNodeResponse{}, nil -} - -func (api headscaleV1APIServer) ExpireNode( - ctx context.Context, - request *v1.ExpireNodeRequest, -) (*v1.ExpireNodeResponse, error) { - if request.GetDisableExpiry() && request.GetExpiry() != nil { - return nil, status.Error( - codes.InvalidArgument, - "cannot set both disable_expiry and expiry", - ) - } - - // Handle disable expiry request - node will never expire. - if request.GetDisableExpiry() { - node, nodeChange, err := api.h.state.SetNodeExpiry( - types.NodeID(request.GetNodeId()), nil, - ) - if err != nil { - return nil, err - } - - api.h.Change(nodeChange) - - log.Trace(). - Caller(). - EmbedObject(node). - Msg("node expiry disabled") - - return &v1.ExpireNodeResponse{Node: node.Proto()}, nil - } - - expiry := time.Now() - if request.GetExpiry() != nil { - expiry = request.GetExpiry().AsTime() - } - - node, nodeChange, err := api.h.state.SetNodeExpiry( - types.NodeID(request.GetNodeId()), &expiry, - ) - if err != nil { - return nil, err - } - - // TODO(kradalby): Ensure that both the selfupdate and peer updates are sent - api.h.Change(nodeChange) - - log.Trace(). - Caller(). - EmbedObject(node). - Time(zf.ExpiresAt, expiry). - Msg("node expired") - - return &v1.ExpireNodeResponse{Node: node.Proto()}, nil -} - -func (api headscaleV1APIServer) RenameNode( - ctx context.Context, - request *v1.RenameNodeRequest, -) (*v1.RenameNodeResponse, error) { - node, nodeChange, err := api.h.state.RenameNode(types.NodeID(request.GetNodeId()), request.GetNewName()) - if err != nil { - return nil, err - } - - // TODO(kradalby): investigate if we need selfupdate - api.h.Change(nodeChange) - - log.Trace(). - Caller(). - EmbedObject(node). - Str(zf.NewName, request.GetNewName()). - Msg("node renamed") - - return &v1.RenameNodeResponse{Node: node.Proto()}, nil -} - -func (api headscaleV1APIServer) ListNodes( - ctx context.Context, - request *v1.ListNodesRequest, -) (*v1.ListNodesResponse, error) { - // TODO(kradalby): it looks like this can be simplified a lot, - // the filtering of nodes by user, vs nodes as a whole can - // probably be done once. - // TODO(kradalby): This should be done in one tx. - if request.GetUser() != "" { - user, err := api.h.state.GetUserByName(request.GetUser()) - if err != nil { - return nil, err - } - - nodes := api.h.state.ListNodesByUser(types.UserID(user.ID)) - - response := nodesToProto(api.h.state, nodes) - return &v1.ListNodesResponse{Nodes: response}, nil - } - - nodes := api.h.state.ListNodes() - - response := nodesToProto(api.h.state, nodes) - return &v1.ListNodesResponse{Nodes: response}, nil -} - -func nodesToProto(state *state.State, nodes views.Slice[types.NodeView]) []*v1.Node { - response := make([]*v1.Node, nodes.Len()) - for index, node := range nodes.All() { - resp := node.Proto() - - // Tags-as-identity: tagged nodes show as [types.TaggedDevices] user in API responses - // (UserID may be set internally for "created by" tracking) - if node.IsTagged() { - resp.User = types.TaggedDevices.Proto() - } - - resp.SubnetRoutes = util.PrefixesToString(append(state.GetNodePrimaryRoutes(node.ID()), node.ExitRoutes()...)) - response[index] = resp - } - - sort.Slice(response, func(i, j int) bool { - return response[i].Id < response[j].Id - }) - - return response -} - -func (api headscaleV1APIServer) BackfillNodeIPs( - ctx context.Context, - request *v1.BackfillNodeIPsRequest, -) (*v1.BackfillNodeIPsResponse, error) { - log.Trace().Caller().Msg("backfill called") - - if !request.Confirmed { - return nil, errors.New("not confirmed, aborting") - } - - changes, err := api.h.state.BackfillNodeIPs() - if err != nil { - return nil, err - } - - return &v1.BackfillNodeIPsResponse{Changes: changes}, nil -} - -func (api headscaleV1APIServer) CreateApiKey( - ctx context.Context, - request *v1.CreateApiKeyRequest, -) (*v1.CreateApiKeyResponse, error) { - var expiration time.Time - if request.GetExpiration() != nil { - expiration = request.GetExpiration().AsTime() - } - - apiKey, _, err := api.h.state.CreateAPIKey(&expiration) - if err != nil { - return nil, err - } - - return &v1.CreateApiKeyResponse{ApiKey: apiKey}, nil -} - -// apiKeyIdentifier is implemented by requests that identify an API key. -type apiKeyIdentifier interface { - GetId() uint64 - GetPrefix() string -} - -// getAPIKey retrieves an API key by ID or prefix from the request. -// Returns InvalidArgument if neither or both are provided. -func (api headscaleV1APIServer) getAPIKey(req apiKeyIdentifier) (*types.APIKey, error) { - hasID := req.GetId() != 0 - hasPrefix := req.GetPrefix() != "" - - switch { - case hasID && hasPrefix: - return nil, status.Error(codes.InvalidArgument, "provide either id or prefix, not both") - case hasID: - return api.h.state.GetAPIKeyByID(req.GetId()) - case hasPrefix: - return api.h.state.GetAPIKey(req.GetPrefix()) - default: - return nil, status.Error(codes.InvalidArgument, "must provide id or prefix") - } -} - -func (api headscaleV1APIServer) ExpireApiKey( - ctx context.Context, - request *v1.ExpireApiKeyRequest, -) (*v1.ExpireApiKeyResponse, error) { - apiKey, err := api.getAPIKey(request) - if err != nil { - return nil, err - } - - err = api.h.state.ExpireAPIKey(apiKey) - if err != nil { - return nil, err - } - - return &v1.ExpireApiKeyResponse{}, nil -} - -func (api headscaleV1APIServer) ListApiKeys( - ctx context.Context, - request *v1.ListApiKeysRequest, -) (*v1.ListApiKeysResponse, error) { - apiKeys, err := api.h.state.ListAPIKeys() - if err != nil { - return nil, err - } - - response := make([]*v1.ApiKey, len(apiKeys)) - for index, key := range apiKeys { - response[index] = key.Proto() - } - - sort.Slice(response, func(i, j int) bool { - return response[i].Id < response[j].Id - }) - - return &v1.ListApiKeysResponse{ApiKeys: response}, nil -} - -func (api headscaleV1APIServer) DeleteApiKey( - ctx context.Context, - request *v1.DeleteApiKeyRequest, -) (*v1.DeleteApiKeyResponse, error) { - apiKey, err := api.getAPIKey(request) - if err != nil { - return nil, err - } - - if err := api.h.state.DestroyAPIKey(*apiKey); err != nil { - return nil, err - } - - return &v1.DeleteApiKeyResponse{}, nil -} - -func (api headscaleV1APIServer) GetPolicy( - _ context.Context, - _ *v1.GetPolicyRequest, -) (*v1.GetPolicyResponse, error) { - switch api.h.cfg.Policy.Mode { - case types.PolicyModeDB: - p, err := api.h.state.GetPolicy() - if err != nil { - return nil, fmt.Errorf("loading ACL from database: %w", err) - } - - return &v1.GetPolicyResponse{ - Policy: p.Data, - UpdatedAt: timestamppb.New(p.UpdatedAt), - }, nil - case types.PolicyModeFile: - // Read the file and return the contents as-is. - absPath := util.AbsolutePathFromConfigPath(api.h.cfg.Policy.Path) - f, err := os.Open(absPath) - if err != nil { - return nil, fmt.Errorf("reading policy from path %q: %w", absPath, err) - } - - defer f.Close() - - b, err := io.ReadAll(f) - if err != nil { - return nil, fmt.Errorf("reading policy from file: %w", err) - } - - return &v1.GetPolicyResponse{Policy: string(b)}, nil - } - - return nil, fmt.Errorf("no supported policy mode found in configuration, policy.mode: %q", api.h.cfg.Policy.Mode) -} - -func (api headscaleV1APIServer) SetPolicy( - _ context.Context, - request *v1.SetPolicyRequest, -) (*v1.SetPolicyResponse, error) { - if api.h.cfg.Policy.Mode != types.PolicyModeDB { - return nil, types.ErrPolicyUpdateIsDisabled - } - - p := request.GetPolicy() - - // Validate and reject configuration that would error when applied - // when creating a map response. This requires nodes, so there is still - // a scenario where they might be allowed if the server has no nodes - // yet, but it should help for the general case and for hot reloading - // configurations. - nodes := api.h.state.ListNodes() - - _, err := api.h.state.SetPolicy([]byte(p)) - if err != nil { - return nil, fmt.Errorf("setting policy: %w", err) - } - - if nodes.Len() > 0 { - _, err = api.h.state.SSHPolicy(nodes.At(0)) - if err != nil { - return nil, fmt.Errorf("verifying SSH rules: %w", err) - } - } - - updated, err := api.h.state.SetPolicyInDB(p) - if err != nil { - return nil, err - } - - // Always reload policy to ensure route re-evaluation, even if policy content hasn't changed. - // This ensures that routes are re-evaluated for auto-approval in cases where routes - // were manually disabled but could now be auto-approved with the current policy. - cs, err := api.h.state.ReloadPolicy() - if err != nil { - return nil, fmt.Errorf("reloading policy: %w", err) - } - - if len(cs) > 0 { - api.h.Change(cs...) - } else { - log.Debug(). - Caller(). - Msg("No policy changes to distribute because ReloadPolicy returned empty changeset") - } - - response := &v1.SetPolicyResponse{ - Policy: updated.Data, - UpdatedAt: timestamppb.New(updated.UpdatedAt), - } - - log.Debug(). - Caller(). - Msg("gRPC SetPolicy completed successfully because response prepared") - - return response, nil -} - -// CheckPolicy validates the given policy against the server's live users -// and nodes, running its `tests` block as a sandbox. Nothing is persisted -// and the live PolicyManager is not touched. Works regardless of -// policy.mode so operators can validate a policy file before storing it. -func (api headscaleV1APIServer) CheckPolicy( - _ context.Context, - request *v1.CheckPolicyRequest, -) (*v1.CheckPolicyResponse, error) { - polB := []byte(request.GetPolicy()) - - users, err := api.h.state.ListAllUsers() - if err != nil { - return nil, status.Errorf(codes.Internal, "loading users: %s", err) - } - - nodes := api.h.state.ListNodes() - - pm, err := policyv2.NewPolicyManager(polB, users, nodes) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - - if _, err := pm.SetPolicy(polB); err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - - return &v1.CheckPolicyResponse{}, nil -} - -// The following service calls are for testing and debugging -func (api headscaleV1APIServer) DebugCreateNode( - ctx context.Context, - request *v1.DebugCreateNodeRequest, -) (*v1.DebugCreateNodeResponse, error) { - user, err := api.h.state.GetUserByName(request.GetUser()) - if err != nil { - return nil, err - } - - routes, err := util.StringToIPPrefix(request.GetRoutes()) - if err != nil { - return nil, err - } - - log.Trace(). - Caller(). - Interface("route-prefix", routes). - Interface("route-str", request.GetRoutes()). - Msg("Creating routes for node") - - registrationId, err := types.AuthIDFromString(request.GetKey()) - if err != nil { - return nil, err - } - - regData := &types.RegistrationData{ - NodeKey: key.NewNode().Public(), - MachineKey: key.NewMachine().Public(), - Hostname: request.GetName(), - Expiry: &time.Time{}, // zero time, not nil — preserves proto JSON round-trip semantics - } - - log.Debug(). - Caller(). - Str("registration_id", registrationId.String()). - Msg("adding debug machine via CLI, appending to registration cache") - - authRegReq := types.NewRegisterAuthRequest(regData) - api.h.state.SetAuthCacheEntry(registrationId, authRegReq) - - // Echo back a synthetic [types.Node] so the debug response surface stays - // stable. The actual node is created later by [headscaleV1APIServer.AuthApprove] via - // [state.State.HandleNodeFromAuthPath] using the cached [types.RegistrationData]. - echoNode := types.Node{ - NodeKey: regData.NodeKey, - MachineKey: regData.MachineKey, - Hostname: regData.Hostname, - User: user, - Expiry: &time.Time{}, - LastSeen: &time.Time{}, - Hostinfo: &tailcfg.Hostinfo{ - Hostname: request.GetName(), - OS: "TestOS", - RoutableIPs: routes, - }, - } - - return &v1.DebugCreateNodeResponse{Node: echoNode.Proto()}, nil -} - -func (api headscaleV1APIServer) Health( - ctx context.Context, - request *v1.HealthRequest, -) (*v1.HealthResponse, error) { - var healthErr error - response := &v1.HealthResponse{} - - if err := api.h.state.PingDB(ctx); err != nil { - healthErr = fmt.Errorf("pinging database: %w", err) - } else { - response.DatabaseConnectivity = true - } - - if healthErr != nil { - log.Error().Err(healthErr).Msg("health check failed") - } - - return response, healthErr -} - -func (api headscaleV1APIServer) AuthRegister( - ctx context.Context, - request *v1.AuthRegisterRequest, -) (*v1.AuthRegisterResponse, error) { - resp, err := api.RegisterNode(ctx, &v1.RegisterNodeRequest{ - Key: request.GetAuthId(), - User: request.GetUser(), - }) - if err != nil { - return nil, err - } - - return &v1.AuthRegisterResponse{Node: resp.GetNode()}, nil -} - -func (api headscaleV1APIServer) AuthApprove( - ctx context.Context, - request *v1.AuthApproveRequest, -) (*v1.AuthApproveResponse, error) { - authID, err := types.AuthIDFromString(request.GetAuthId()) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid auth_id: %v", err) - } - - authReq, ok := api.h.state.GetAuthCacheEntry(authID) - if !ok { - return nil, status.Errorf(codes.NotFound, "no pending auth session for auth_id %s", authID) - } - - authReq.FinishAuth(types.AuthVerdict{}) - - return &v1.AuthApproveResponse{}, nil -} - -func (api headscaleV1APIServer) AuthReject( - ctx context.Context, - request *v1.AuthRejectRequest, -) (*v1.AuthRejectResponse, error) { - authID, err := types.AuthIDFromString(request.GetAuthId()) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid auth_id: %v", err) - } - - authReq, ok := api.h.state.GetAuthCacheEntry(authID) - if !ok { - return nil, status.Errorf(codes.NotFound, "no pending auth session for auth_id %s", authID) - } - - authReq.FinishAuth(types.AuthVerdict{ - Err: fmt.Errorf("auth request rejected"), - }) - - return &v1.AuthRejectResponse{}, nil -} - -func (api headscaleV1APIServer) mustEmbedUnimplementedHeadscaleServiceServer() {} diff --git a/hscontrol/grpcv1_test.go b/hscontrol/grpcv1_test.go deleted file mode 100644 index 1bb4cbe2d..000000000 --- a/hscontrol/grpcv1_test.go +++ /dev/null @@ -1,819 +0,0 @@ -package hscontrol - -import ( - "context" - "testing" - "time" - - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" - "github.com/juanfont/headscale/hscontrol/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "tailscale.com/tailcfg" - "tailscale.com/types/key" -) - -func Test_validateTag(t *testing.T) { - type args struct { - tag string - } - - tests := []struct { - name string - args args - wantErr bool - }{ - { - name: "valid tag", - args: args{tag: "tag:test"}, - wantErr: false, - }, - { - name: "tag without tag prefix", - args: args{tag: "test"}, - wantErr: true, - }, - { - name: "uppercase tag", - args: args{tag: "tag:tEST"}, - wantErr: true, - }, - { - name: "tag that contains space", - args: args{tag: "tag:this is a spaced tag"}, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateTag(tt.args.tag) - if (err != nil) != tt.wantErr { - t.Errorf("validateTag() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -// TestSetTags_Conversion tests the conversion of user-owned nodes to tagged nodes. -// The tags-as-identity model allows one-way conversion from user-owned to tagged. -// Tag authorization is checked via the policy manager - unauthorized tags are rejected. -func TestSetTags_Conversion(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - // Create test user and nodes - user := app.state.CreateUserForTest("test-user") - - // Create a pre-auth key WITHOUT tags for user-owned node - pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) - require.NoError(t, err) - - machineKey1 := key.NewMachine() - nodeKey1 := key.NewNode() - - // Register a user-owned node (via untagged PreAuthKey) - userOwnedReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey1.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "user-owned-node", - }, - } - _, err = app.handleRegisterWithAuthKey(userOwnedReq, machineKey1.Public()) - require.NoError(t, err) - - // Get the created node - userOwnedNode, found := app.state.GetNodeByNodeKey(nodeKey1.Public()) - require.True(t, found) - - // Create API server instance - apiServer := newHeadscaleV1APIServer(app) - - tests := []struct { - name string - nodeID uint64 - tags []string - wantErr bool - wantCode codes.Code - wantErrMessage string - }{ - { - // Conversion is allowed, but tag authorization fails without tagOwners - name: "reject unauthorized tags on user-owned node", - nodeID: uint64(userOwnedNode.ID()), - tags: []string{"tag:server"}, - wantErr: true, - wantCode: codes.InvalidArgument, - wantErrMessage: "requested tags", - }, - { - // Conversion is allowed, but tag authorization fails without tagOwners - name: "reject multiple unauthorized tags", - nodeID: uint64(userOwnedNode.ID()), - tags: []string{"tag:server", "tag:database"}, - wantErr: true, - wantCode: codes.InvalidArgument, - wantErrMessage: "requested tags", - }, - { - name: "reject non-existent node", - nodeID: 99999, - tags: []string{"tag:server"}, - wantErr: true, - wantCode: codes.NotFound, - wantErrMessage: "node not found", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{ - NodeId: tt.nodeID, - Tags: tt.tags, - }) - - if tt.wantErr { - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok, "error should be a gRPC status error") - assert.Equal(t, tt.wantCode, st.Code()) - assert.Contains(t, st.Message(), tt.wantErrMessage) - assert.Nil(t, resp.GetNode()) - } else { - require.NoError(t, err) - assert.NotNil(t, resp) - assert.NotNil(t, resp.GetNode()) - } - }) - } -} - -// TestSetTags_TaggedNode tests that [headscaleV1APIServer.SetTags] correctly identifies tagged nodes -// and doesn't reject them with the "user-owned nodes" error. -// Note: This test doesn't validate ACL tag authorization - that's tested elsewhere. -func TestSetTags_TaggedNode(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - // Create test user and tagged pre-auth key - user := app.state.CreateUserForTest("test-user") - pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, []string{"tag:initial"}) - require.NoError(t, err) - - machineKey := key.NewMachine() - nodeKey := key.NewNode() - - // Register a tagged node (via tagged PreAuthKey) - taggedReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "tagged-node", - }, - } - _, err = app.handleRegisterWithAuthKey(taggedReq, machineKey.Public()) - require.NoError(t, err) - - // Get the created node - taggedNode, found := app.state.GetNodeByNodeKey(nodeKey.Public()) - require.True(t, found) - assert.True(t, taggedNode.IsTagged(), "Node should be tagged") - assert.False(t, taggedNode.UserID().Valid(), "Tagged node should not have UserID") - - // Create API server instance - apiServer := newHeadscaleV1APIServer(app) - - // Test: [headscaleV1APIServer.SetTags] should work on tagged nodes. - resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{ - NodeId: uint64(taggedNode.ID()), - Tags: []string{"tag:initial"}, // Keep existing tag to avoid ACL validation issues - }) - - // The call should NOT fail with "cannot set tags on user-owned nodes" - if err != nil { - st, ok := status.FromError(err) - require.True(t, ok) - // If error is about unauthorized tags, that's fine - ACL validation is working - // If error is about user-owned nodes, that's the bug we're testing for - assert.NotContains(t, st.Message(), "user-owned nodes", "Should not reject tagged nodes as user-owned") - } else { - // Success is also fine - assert.NotNil(t, resp) - } -} - -// TestSetTags_CannotRemoveAllTags tests that [headscaleV1APIServer.SetTags] rejects attempts to remove -// all tags from a tagged node, enforcing Tailscale's requirement that tagged -// nodes must have at least one tag. -func TestSetTags_CannotRemoveAllTags(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - // Create test user and tagged pre-auth key - user := app.state.CreateUserForTest("test-user") - pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, []string{"tag:server"}) - require.NoError(t, err) - - machineKey := key.NewMachine() - nodeKey := key.NewNode() - - // Register a tagged node - taggedReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "tagged-node", - }, - } - _, err = app.handleRegisterWithAuthKey(taggedReq, machineKey.Public()) - require.NoError(t, err) - - // Get the created node - taggedNode, found := app.state.GetNodeByNodeKey(nodeKey.Public()) - require.True(t, found) - assert.True(t, taggedNode.IsTagged()) - - // Create API server instance - apiServer := newHeadscaleV1APIServer(app) - - // Attempt to remove all tags (empty array) - resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{ - NodeId: uint64(taggedNode.ID()), - Tags: []string{}, // Empty - attempting to remove all tags - }) - - // Should fail with InvalidArgument error - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok, "error should be a gRPC status error") - assert.Equal(t, codes.InvalidArgument, st.Code()) - assert.Contains(t, st.Message(), "cannot remove all tags") - assert.Nil(t, resp.GetNode()) -} - -// TestSetTags_ClearsUserIDInDatabase tests that converting a user-owned node -// to a tagged node via [headscaleV1APIServer.SetTags] correctly persists user_id = NULL in the -// database, not just in-memory. -func TestSetTags_ClearsUserIDInDatabase(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - user := app.state.CreateUserForTest("tag-owner") - err := app.state.UpdatePolicyManagerUsersForTest() - require.NoError(t, err) - - _, err = app.state.SetPolicy([]byte(`{ - "tagOwners": {"tag:server": ["tag-owner@"]}, - "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}] - }`)) - require.NoError(t, err) - - // Register a user-owned node (untagged PreAuthKey). - pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) - require.NoError(t, err) - - machineKey := key.NewMachine() - nodeKey := key.NewNode() - - regReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "user-owned-node", - }, - } - _, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public()) - require.NoError(t, err) - - node, found := app.state.GetNodeByNodeKey(nodeKey.Public()) - require.True(t, found) - require.False(t, node.IsTagged(), "node should start as user-owned") - require.True(t, node.UserID().Valid(), "user-owned node must have UserID") - - nodeID := node.ID() - - // Convert to tagged via [headscaleV1APIServer.SetTags] API. - apiServer := newHeadscaleV1APIServer(app) - _, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{ - NodeId: uint64(nodeID), - Tags: []string{"tag:server"}, - }) - require.NoError(t, err) - - // Verify in-memory state is correct. - nsNode, found := app.state.GetNodeByID(nodeID) - require.True(t, found) - assert.True(t, nsNode.IsTagged(), "NodeStore: node should be tagged") - assert.False(t, nsNode.UserID().Valid(), - "NodeStore: UserID should be nil for tagged node") - - // THE CRITICAL CHECK: verify database has user_id = NULL. - dbNode, err := app.state.DB().GetNodeByID(nodeID) - require.NoError(t, err) - assert.Nil(t, dbNode.UserID, - "Database: user_id must be NULL after converting to tagged node") - assert.True(t, dbNode.IsTagged(), - "Database: tags must be set") -} - -// TestSetTags_NodeDisappearsFromUserListing tests issue #3161: -// after converting a user-owned node to tagged, it must no longer appear -// when listing nodes filtered by the original user. -// https://github.com/juanfont/headscale/issues/3161 -func TestSetTags_NodeDisappearsFromUserListing(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - user := app.state.CreateUserForTest("list-user") - err := app.state.UpdatePolicyManagerUsersForTest() - require.NoError(t, err) - - _, err = app.state.SetPolicy([]byte(`{ - "tagOwners": {"tag:web": ["list-user@"]}, - "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}] - }`)) - require.NoError(t, err) - - // Register a user-owned node. - pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) - require.NoError(t, err) - - machineKey := key.NewMachine() - nodeKey := key.NewNode() - - regReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "web-server", - }, - } - _, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public()) - require.NoError(t, err) - - node, found := app.state.GetNodeByNodeKey(nodeKey.Public()) - require.True(t, found) - - // Verify node appears under user before tagging. - apiServer := newHeadscaleV1APIServer(app) - resp, err := apiServer.ListNodes(context.Background(), &v1.ListNodesRequest{ - User: "list-user", - }) - require.NoError(t, err) - assert.Len(t, resp.GetNodes(), 1, "user-owned node should appear under user") - - // Convert to tagged. - _, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{ - NodeId: uint64(node.ID()), - Tags: []string{"tag:web"}, - }) - require.NoError(t, err) - - // Node must NOT appear when listing by original user. - resp, err = apiServer.ListNodes(context.Background(), &v1.ListNodesRequest{ - User: "list-user", - }) - require.NoError(t, err) - assert.Empty(t, resp.GetNodes(), - "tagged node must not appear when listing nodes for original user") - - // Node must still appear in unfiltered listing. - allResp, err := apiServer.ListNodes(context.Background(), &v1.ListNodesRequest{}) - require.NoError(t, err) - require.Len(t, allResp.GetNodes(), 1) - assert.Contains(t, allResp.GetNodes()[0].GetTags(), "tag:web") -} - -// TestSetTags_NodeStoreAndDBConsistency verifies that after [headscaleV1APIServer.SetTags], the -// in-memory [state.NodeStore] and the database agree on the node's ownership state. -func TestSetTags_NodeStoreAndDBConsistency(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - user := app.state.CreateUserForTest("consistency-user") - err := app.state.UpdatePolicyManagerUsersForTest() - require.NoError(t, err) - - _, err = app.state.SetPolicy([]byte(`{ - "tagOwners": {"tag:db": ["consistency-user@"]}, - "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}] - }`)) - require.NoError(t, err) - - pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) - require.NoError(t, err) - - machineKey := key.NewMachine() - nodeKey := key.NewNode() - - regReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "db-node", - }, - } - _, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public()) - require.NoError(t, err) - - node, found := app.state.GetNodeByNodeKey(nodeKey.Public()) - require.True(t, found) - - nodeID := node.ID() - - // Convert to tagged. - apiServer := newHeadscaleV1APIServer(app) - _, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{ - NodeId: uint64(nodeID), - Tags: []string{"tag:db"}, - }) - require.NoError(t, err) - - // In-memory state. - nsNode, found := app.state.GetNodeByID(nodeID) - require.True(t, found) - - // Database state. - dbNode, err := app.state.DB().GetNodeByID(nodeID) - require.NoError(t, err) - - // Both must agree: tagged, no UserID. - assert.True(t, nsNode.IsTagged(), "NodeStore: should be tagged") - assert.True(t, dbNode.IsTagged(), "Database: should be tagged") - - assert.False(t, nsNode.UserID().Valid(), - "NodeStore: UserID should be nil") - assert.Nil(t, dbNode.UserID, - "Database: user_id should be NULL") - - assert.Equal(t, - nsNode.UserID().Valid(), - dbNode.UserID != nil, - "NodeStore and database must agree on UserID state") -} - -// TestSetTags_UserDeletionDoesNotCascadeToTaggedNode tests that deleting the -// original user does not cascade-delete a node that was converted to tagged -// via [headscaleV1APIServer.SetTags]. This catches the real-world consequence of stale user_id: -// ON DELETE CASCADE would destroy the tagged node. -func TestSetTags_UserDeletionDoesNotCascadeToTaggedNode(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - user := app.state.CreateUserForTest("doomed-user") - err := app.state.UpdatePolicyManagerUsersForTest() - require.NoError(t, err) - - _, err = app.state.SetPolicy([]byte(`{ - "tagOwners": {"tag:survivor": ["doomed-user@"]}, - "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}] - }`)) - require.NoError(t, err) - - pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) - require.NoError(t, err) - - machineKey := key.NewMachine() - nodeKey := key.NewNode() - - regReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "survivor-node", - }, - } - _, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public()) - require.NoError(t, err) - - node, found := app.state.GetNodeByNodeKey(nodeKey.Public()) - require.True(t, found) - - nodeID := node.ID() - - // Convert to tagged. - apiServer := newHeadscaleV1APIServer(app) - _, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{ - NodeId: uint64(nodeID), - Tags: []string{"tag:survivor"}, - }) - require.NoError(t, err) - - // Delete the original user. - _, err = app.state.DeleteUser(*user.TypedID()) - require.NoError(t, err) - - // The tagged node must survive in both [state.NodeStore] and database. - nsNode, found := app.state.GetNodeByID(nodeID) - require.True(t, found, "tagged node must survive user deletion in NodeStore") - assert.True(t, nsNode.IsTagged()) - - dbNode, err := app.state.DB().GetNodeByID(nodeID) - require.NoError(t, err, "tagged node must survive user deletion in database") - assert.True(t, dbNode.IsTagged()) - assert.Nil(t, dbNode.UserID) -} - -// TestDeleteUser_ReturnsProperChangeSignal tests issue #2967 fix: -// When a user is deleted, the state should return a non-empty change signal -// to ensure policy manager is updated and clients are notified immediately. -func TestDeleteUser_ReturnsProperChangeSignal(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - // Create a user - user := app.state.CreateUserForTest("test-user-to-delete") - require.NotNil(t, user) - - // Delete the user and verify a non-empty change is returned - // Without the fix, [state.State.DeleteUser] returned an empty change, - // causing stale policy state until another user operation triggered an update. - changeSignal, err := app.state.DeleteUser(*user.TypedID()) - require.NoError(t, err, "DeleteUser should succeed") - assert.False(t, changeSignal.IsEmpty(), "DeleteUser should return a non-empty change signal (issue #2967)") -} - -// TestDeleteUser_TaggedNodeSurvives tests that deleting a user succeeds when -// the user's only nodes are tagged, and that those nodes remain in the -// [state.NodeStore] with nil UserID. -func TestDeleteUser_TaggedNodeSurvives(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - user := app.state.CreateUserForTest("legacy-user") - - // Register a tagged node via the full auth flow. - tags := []string{"tag:server"} - pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, tags) - require.NoError(t, err) - - machineKey := key.NewMachine() - nodeKey := key.NewNode() - - regReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "tagged-server", - }, - Expiry: time.Now().Add(24 * time.Hour), - } - - resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public()) - require.NoError(t, err) - require.True(t, resp.MachineAuthorized) - - // Verify the registered node has nil UserID (enforced at registration). - node, found := app.state.GetNodeByNodeKey(nodeKey.Public()) - require.True(t, found) - require.True(t, node.IsTagged()) - assert.False(t, node.UserID().Valid(), - "tagged node should have nil UserID after registration") - - nodeID := node.ID() - - // [state.NodeStore] should not list the tagged node under any user. - nodesForUser := app.state.ListNodesByUser(types.UserID(user.ID)) - assert.Equal(t, 0, nodesForUser.Len(), - "tagged nodes should not appear in nodesByUser index") - - // Delete the user. - changeSignal, err := app.state.DeleteUser(*user.TypedID()) - require.NoError(t, err) - assert.False(t, changeSignal.IsEmpty()) - - // Tagged node survives in the [state.NodeStore]. - nodeAfter, found := app.state.GetNodeByID(nodeID) - require.True(t, found, "tagged node should survive user deletion") - assert.True(t, nodeAfter.IsTagged()) - assert.False(t, nodeAfter.UserID().Valid()) - - // Tagged node appears in the global list. - allNodes := app.state.ListNodes() - foundInAll := false - - for _, n := range allNodes.All() { - if n.ID() == nodeID { - foundInAll = true - - break - } - } - - assert.True(t, foundInAll, "tagged node should appear in the global node list") -} - -// TestExpireApiKey_ByID tests that API keys can be expired by ID. -func TestExpireApiKey_ByID(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - // Create an API key - createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{}) - require.NoError(t, err) - require.NotEmpty(t, createResp.GetApiKey()) - - // List keys to get the ID - listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{}) - require.NoError(t, err) - require.Len(t, listResp.GetApiKeys(), 1) - - keyID := listResp.GetApiKeys()[0].GetId() - - // Expire by ID - _, err = apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{ - Id: keyID, - }) - require.NoError(t, err) - - // Verify key is expired (expiration is set to now or in the past) - listResp, err = apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{}) - require.NoError(t, err) - require.Len(t, listResp.GetApiKeys(), 1) - assert.NotNil(t, listResp.GetApiKeys()[0].GetExpiration(), "expiration should be set") -} - -// TestExpireApiKey_ByPrefix tests that API keys can still be expired by prefix. -func TestExpireApiKey_ByPrefix(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - // Create an API key - createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{}) - require.NoError(t, err) - require.NotEmpty(t, createResp.GetApiKey()) - - // List keys to get the prefix - listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{}) - require.NoError(t, err) - require.Len(t, listResp.GetApiKeys(), 1) - - keyPrefix := listResp.GetApiKeys()[0].GetPrefix() - - // Expire by prefix - _, err = apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{ - Prefix: keyPrefix, - }) - require.NoError(t, err) -} - -// TestDeleteApiKey_ByID tests that API keys can be deleted by ID. -func TestDeleteApiKey_ByID(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - // Create an API key - createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{}) - require.NoError(t, err) - require.NotEmpty(t, createResp.GetApiKey()) - - // List keys to get the ID - listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{}) - require.NoError(t, err) - require.Len(t, listResp.GetApiKeys(), 1) - - keyID := listResp.GetApiKeys()[0].GetId() - - // Delete by ID - _, err = apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{ - Id: keyID, - }) - require.NoError(t, err) - - // Verify key is deleted - listResp, err = apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{}) - require.NoError(t, err) - assert.Empty(t, listResp.GetApiKeys()) -} - -// TestDeleteApiKey_ByPrefix tests that API keys can still be deleted by prefix. -func TestDeleteApiKey_ByPrefix(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - // Create an API key - createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{}) - require.NoError(t, err) - require.NotEmpty(t, createResp.GetApiKey()) - - // List keys to get the prefix - listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{}) - require.NoError(t, err) - require.Len(t, listResp.GetApiKeys(), 1) - - keyPrefix := listResp.GetApiKeys()[0].GetPrefix() - - // Delete by prefix - _, err = apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{ - Prefix: keyPrefix, - }) - require.NoError(t, err) - - // Verify key is deleted - listResp, err = apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{}) - require.NoError(t, err) - assert.Empty(t, listResp.GetApiKeys()) -} - -// TestExpireApiKey_NoIdentifier tests that an error is returned when neither ID nor prefix is provided. -func TestExpireApiKey_NoIdentifier(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - _, err := apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok, "error should be a gRPC status error") - assert.Equal(t, codes.InvalidArgument, st.Code()) - assert.Contains(t, st.Message(), "must provide id or prefix") -} - -// TestDeleteApiKey_NoIdentifier tests that an error is returned when neither ID nor prefix is provided. -func TestDeleteApiKey_NoIdentifier(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - _, err := apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok, "error should be a gRPC status error") - assert.Equal(t, codes.InvalidArgument, st.Code()) - assert.Contains(t, st.Message(), "must provide id or prefix") -} - -// TestExpireApiKey_BothIdentifiers tests that an error is returned when both ID and prefix are provided. -func TestExpireApiKey_BothIdentifiers(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - _, err := apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{ - Id: 1, - Prefix: "test", - }) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok, "error should be a gRPC status error") - assert.Equal(t, codes.InvalidArgument, st.Code()) - assert.Contains(t, st.Message(), "provide either id or prefix, not both") -} - -// TestDeleteApiKey_BothIdentifiers tests that an error is returned when both ID and prefix are provided. -func TestDeleteApiKey_BothIdentifiers(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - _, err := apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{ - Id: 1, - Prefix: "test", - }) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok, "error should be a gRPC status error") - assert.Equal(t, codes.InvalidArgument, st.Code()) - assert.Contains(t, st.Message(), "provide either id or prefix, not both") -} diff --git a/hscontrol/handlers.go b/hscontrol/handlers.go index 750915ec8..242673b38 100644 --- a/hscontrol/handlers.go +++ b/hscontrol/handlers.go @@ -148,20 +148,12 @@ func (h *Headscale) handleVerifyRequest( return NewHTTPError(http.StatusBadRequest, "Bad Request: invalid JSON", fmt.Errorf("parsing DERP client request: %w", err)) } - nodes := h.state.ListNodes() - - // Check if any node has the requested NodeKey - var nodeKeyFound bool - - for _, node := range nodes.All() { - if node.NodeKey() == derpAdmitClientRequest.NodePublic { - nodeKeyFound = true - break - } - } + allow := h.state.ListNodes().ContainsFunc(func(n types.NodeView) bool { + return n.NodeKey() == derpAdmitClientRequest.NodePublic + }) resp := &tailcfg.DERPAdmitClientResponse{ - Allow: nodeKeyFound, + Allow: allow, } return json.NewEncoder(writer).Encode(resp) @@ -180,13 +172,18 @@ func (h *Headscale) VerifyHandler( req.Body = http.MaxBytesReader(writer, req.Body, verifyBodyLimit) + // Set the Content-Type before any body byte is written. The first + // Write in handleVerifyRequest triggers an implicit WriteHeader that + // snapshots the header map, so setting it afterwards is a no-op. The + // error path resets the Content-Type via http.Error, so error + // responses remain text/plain. + writer.Header().Set("Content-Type", "application/json") + err := h.handleVerifyRequest(req, writer) if err != nil { httpError(writer, err) return } - - writer.Header().Set("Content-Type", "application/json") } // KeyHandler provides the Headscale pub key @@ -300,18 +297,23 @@ func NewAuthProviderWeb(serverURL string) *AuthProviderWeb { } } -func (a *AuthProviderWeb) RegisterURL(authID types.AuthID) string { +// authPathURL builds an auth-flow URL of the form +// "//", trimming a trailing slash from serverURL. +func authPathURL(serverURL, kind string, authID types.AuthID) string { return fmt.Sprintf( - "%s/register/%s", - strings.TrimSuffix(a.serverURL, "/"), - authID.String()) + "%s/%s/%s", + strings.TrimSuffix(serverURL, "/"), + kind, + authID.String(), + ) +} + +func (a *AuthProviderWeb) RegisterURL(authID types.AuthID) string { + return authPathURL(a.serverURL, "register", authID) } func (a *AuthProviderWeb) AuthURL(authID types.AuthID) string { - return fmt.Sprintf( - "%s/auth/%s", - strings.TrimSuffix(a.serverURL, "/"), - authID.String()) + return authPathURL(a.serverURL, "auth", authID) } func (a *AuthProviderWeb) AuthHandler( @@ -338,7 +340,7 @@ func (a *AuthProviderWeb) AuthHandler( } func authIDFromRequest(req *http.Request) (types.AuthID, error) { - raw, err := urlParam[string](req, "auth_id") + raw, err := stringParam(req, "auth_id") if err != nil { return "", NewHTTPError(http.StatusBadRequest, "invalid auth id", fmt.Errorf("parsing auth_id from URL: %w", err)) } diff --git a/hscontrol/handlers_test.go b/hscontrol/handlers_test.go index c1a4a0941..9fe315c28 100644 --- a/hscontrol/handlers_test.go +++ b/hscontrol/handlers_test.go @@ -3,13 +3,19 @@ package hscontrol import ( "bytes" "context" + "encoding/json" "errors" "net/http" "net/http/httptest" + "net/netip" "strings" "testing" + "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" + "tailscale.com/types/key" ) var errTestUnexpected = errors.New("unexpected failure") @@ -47,6 +53,69 @@ func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) { "oversized body must surface 413") } +// TestVerifyHandler_SuccessSetsJSONContentType verifies that a successful +// POST to /verify advertises Content-Type: application/json. The header +// must be set before the JSON body is written, otherwise the implicit +// WriteHeader on first Write locks in a sniffed content type and the +// later Header().Set becomes a no-op. +func TestVerifyHandler_SuccessSetsJSONContentType(t *testing.T) { + tmpDir := t.TempDir() + + prefixV4 := netip.MustParsePrefix("100.64.0.0/10") + prefixV6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48") + + cfg := &types.Config{ + ServerURL: "http://localhost:0", + NoisePrivateKeyPath: tmpDir + "/noise_private.key", + PrefixV4: &prefixV4, + PrefixV6: &prefixV6, + IPAllocation: types.IPAllocationStrategySequential, + Database: types.DatabaseConfig{ + Type: "sqlite3", + Sqlite: types.SqliteConfig{ + Path: tmpDir + "/headscale_test.db", + }, + }, + Policy: types.PolicyConfig{ + Mode: types.PolicyModeDB, + }, + } + + h, err := NewHeadscale(cfg) + require.NoError(t, err) + + reqBody, err := json.Marshal(tailcfg.DERPAdmitClientRequest{ + NodePublic: key.NewNode().Public(), + }) + require.NoError(t, err) + + // A real HTTP server is required to observe the bug: the first body + // Write triggers an implicit WriteHeader that snapshots the header + // map, so a Content-Type set afterwards never reaches the wire. + // An httptest.ResponseRecorder does not snapshot, so it would hide + // the defect. + srv := httptest.NewServer(http.HandlerFunc(h.VerifyHandler)) + defer srv.Close() + + httpReq, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + srv.URL+"/verify", + bytes.NewReader(reqBody), + ) + require.NoError(t, err) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + require.NoError(t, err) + + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json", resp.Header.Get("Content-Type"), + "successful /verify response must advertise application/json") +} + // errorAsHTTPError is a small local helper that unwraps an [HTTPError] // from an error chain. func errorAsHTTPError(err error) (HTTPError, bool) { diff --git a/hscontrol/mapper/batcher.go b/hscontrol/mapper/batcher.go index bcbcbae7d..078ebd8b5 100644 --- a/hscontrol/mapper/batcher.go +++ b/hscontrol/mapper/batcher.go @@ -315,11 +315,7 @@ func (b *Batcher) AddNode( initialMap, err := b.MapResponseFromChange(id, change.FullSelf(id)) if err != nil { nlog.Error().Err(err).Msg("initial map generation failed") - nodeConn.removeConnectionByChannel(c) - - if !nodeConn.hasActiveConnections() { - nodeConn.markDisconnected() - } + nodeConn.detach(c) return fmt.Errorf("generating initial map for node %d: %w", id, err) } @@ -343,11 +339,7 @@ func (b *Batcher) AddNode( nlog.Error().Err(ErrInitialMapSendTimeout).Msg("initial map send timeout") nlog.Debug().Caller().Dur("timeout.duration", 5*time.Second). //nolint:mnd Msg("initial map send timed out because channel was blocked or receiver not ready") - nodeConn.removeConnectionByChannel(c) - - if !nodeConn.hasActiveConnections() { - nodeConn.markDisconnected() - } + nodeConn.detach(c) return fmt.Errorf("%w for node %d", ErrInitialMapSendTimeout, id) } diff --git a/hscontrol/mapper/batcher_test.go b/hscontrol/mapper/batcher_test.go index 49aeb54d5..2e36d5aa0 100644 --- a/hscontrol/mapper/batcher_test.go +++ b/hscontrol/mapper/batcher_test.go @@ -66,7 +66,7 @@ func (t *testBatcherWrapper) AddNode(id types.NodeID, c chan<- *tailcfg.MapRespo return fmt.Errorf("%w: %d", errNodeNotFoundAfterAdd, id) } - t.AddWork(change.NodeOnlineFor(node)) + t.AddWork(change.NodeOnline(node.ID())) return nil } @@ -90,7 +90,7 @@ func (t *testBatcherWrapper) RemoveNode(id types.NodeID, c chan<- *tailcfg.MapRe // Do this BEFORE removing from batcher so the change can be processed node, ok := t.state.GetNodeByID(id) if ok { - t.AddWork(change.NodeOfflineFor(node)) + t.AddWork(change.NodeOffline(node.ID())) } // Finally remove from the real batcher diff --git a/hscontrol/mapper/builder.go b/hscontrol/mapper/builder.go index ee3653ae4..eaf4dde44 100644 --- a/hscontrol/mapper/builder.go +++ b/hscontrol/mapper/builder.go @@ -1,14 +1,16 @@ package mapper import ( + "cmp" "net/netip" "slices" - "sort" "time" "github.com/juanfont/headscale/hscontrol/policy" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util/zlog/zf" + "github.com/rs/zerolog/log" "tailscale.com/tailcfg" "tailscale.com/types/views" "tailscale.com/util/multierr" @@ -61,6 +63,16 @@ func (b *MapResponseBuilder) hasErrors() bool { return len(b.errs) > 0 } +// node looks up the requesting node, recording ErrNodeNotFoundMapper on miss. +func (b *MapResponseBuilder) node() (types.NodeView, bool) { + nv, ok := b.mapper.state.GetNodeByID(b.nodeID) + if !ok { + b.addError(ErrNodeNotFoundMapper) + } + + return nv, ok +} + // WithCapabilityVersion sets the capability version for the response. func (b *MapResponseBuilder) WithCapabilityVersion(capVer tailcfg.CapabilityVersion) *MapResponseBuilder { b.capVer = capVer @@ -69,9 +81,8 @@ func (b *MapResponseBuilder) WithCapabilityVersion(capVer tailcfg.CapabilityVers // WithSelfNode adds the requesting node to the response. func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder { - nv, ok := b.mapper.state.GetNodeByID(b.nodeID) + nv, ok := b.node() if !ok { - b.addError(ErrNodeNotFoundMapper) return b } @@ -136,15 +147,22 @@ func (b *MapResponseBuilder) WithDebugConfig() *MapResponseBuilder { // WithSSHPolicy adds SSH policy configuration for the requesting node. func (b *MapResponseBuilder) WithSSHPolicy() *MapResponseBuilder { - node, ok := b.mapper.state.GetNodeByID(b.nodeID) + node, ok := b.node() if !ok { - b.addError(ErrNodeNotFoundMapper) return b } sshPolicy, err := b.mapper.state.SSHPolicy(node) if err != nil { - b.addError(err) + // SSH policy is optional for a node to function. Rather than fail the + // whole map (leaving the node unable to connect), log and continue + // without it; the node still receives a usable netmap. + log.Warn().Caller(). + Err(err). + Uint64(zf.NodeID, node.ID().Uint64()). + Str(zf.NodeHostname, node.Hostname()). + Msg("building map response: skipping SSH policy for node; node will receive a map without SSH rules") + return b } @@ -155,9 +173,8 @@ func (b *MapResponseBuilder) WithSSHPolicy() *MapResponseBuilder { // WithDNSConfig adds DNS configuration for the requesting node. func (b *MapResponseBuilder) WithDNSConfig() *MapResponseBuilder { - node, ok := b.mapper.state.GetNodeByID(b.nodeID) + node, ok := b.node() if !ok { - b.addError(ErrNodeNotFoundMapper) return b } @@ -168,9 +185,8 @@ func (b *MapResponseBuilder) WithDNSConfig() *MapResponseBuilder { // WithUserProfiles adds user profiles for the requesting node and given peers. func (b *MapResponseBuilder) WithUserProfiles(peers views.Slice[types.NodeView]) *MapResponseBuilder { - node, ok := b.mapper.state.GetNodeByID(b.nodeID) + node, ok := b.node() if !ok { - b.addError(ErrNodeNotFoundMapper) return b } @@ -185,9 +201,8 @@ func (b *MapResponseBuilder) WithUserProfiles(peers views.Slice[types.NodeView]) // For autogroup:self policies, it returns per-node compiled rules. // For global policies, it returns the global filter reduced for this node. func (b *MapResponseBuilder) WithPacketFilters() *MapResponseBuilder { - node, ok := b.mapper.state.GetNodeByID(b.nodeID) + node, ok := b.node() if !ok { - b.addError(ErrNodeNotFoundMapper) return b } @@ -279,7 +294,19 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ( return b.mapper.state.RoutesForPeer(node, peer, matchers) }, b.mapper.cfg, allCapMaps[peer.ID()]) if err != nil { - return nil, err + // One peer with invalid data (e.g. an empty or over-long + // GivenName that fails GetFQDN) must not blank out the map for + // every node that can see it. Drop the offending peer, log it + // with the identity an operator needs to fix it, and keep + // building from the remaining valid peers. + log.Warn().Caller(). + Err(err). + Uint64(zf.NodeID, peer.ID().Uint64()). + Str(zf.NodeHostname, peer.Hostname()). + Uint64("map.viewer.node.id", b.nodeID.Uint64()). + Msgf("dropping peer %d from map response: invalid node data; fix with `headscale nodes rename %d `", peer.ID(), peer.ID()) + + continue } // [tailcfg.Node.CapMap] on a peer carries the small set of @@ -296,8 +323,8 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ( } // Peers is always returned sorted by Node.ID. - sort.SliceStable(tailPeers, func(x, y int) bool { - return tailPeers[x].ID < tailPeers[y].ID + slices.SortStableFunc(tailPeers, func(a, b *tailcfg.Node) int { + return cmp.Compare(a.ID, b.ID) }) return tailPeers, nil diff --git a/hscontrol/mapper/initialmap_storm_test.go b/hscontrol/mapper/initialmap_storm_test.go new file mode 100644 index 000000000..877a8d97b --- /dev/null +++ b/hscontrol/mapper/initialmap_storm_test.go @@ -0,0 +1,189 @@ +//go:build !race + +// This is a timing-sensitive performance regression test; the race detector's +// ~10x slowdown makes its wall-clock assertion meaningless, so it is excluded +// from -race builds. The concurrency correctness of the policy lock change it +// guards is covered under -race by TestPolicyManagerConcurrentReads in +// hscontrol/policy/v2. + +package mapper + +import ( + "net/netip" + "runtime" + "slices" + "sync" + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/db" + "github.com/juanfont/headscale/hscontrol/derp" + "github.com/juanfont/headscale/hscontrol/state" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" +) + +// setupStormBatcher builds a real state+batcher with production-default +// NodeStore batching so the reconnect-storm contention is realistic. It mirrors +// setupBatcherWithTestData but lets the test control BatcherWorkers and the +// policy. +func setupStormBatcher(tb testing.TB, nodeCount, workers int, policy string) (*TestData, func()) { + tb.Helper() + + tmpDir := tb.TempDir() + prefixV4 := netip.MustParsePrefix("100.64.0.0/10") + prefixV6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48") + + cfg := &types.Config{ + Database: types.DatabaseConfig{ + Type: types.DatabaseSqlite, + Sqlite: types.SqliteConfig{Path: tmpDir + "/headscale_test.db"}, + }, + PrefixV4: &prefixV4, + PrefixV6: &prefixV6, + IPAllocation: types.IPAllocationStrategySequential, + BaseDomain: "headscale.test", + Policy: types.PolicyConfig{Mode: types.PolicyModeDB}, + DERP: types.DERPConfig{ + ServerEnabled: false, + DERPMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{999: {RegionID: 999}}, + }, + }, + Tuning: types.Tuning{ + BatchChangeDelay: 10 * time.Millisecond, + BatcherWorkers: workers, + // Production defaults: coalesce writes so the storm is not + // exaggerated by an unrealistically small NodeStore batch. + NodeStoreBatchSize: 100, + NodeStoreBatchTimeout: 500 * time.Millisecond, + }, + } + + database, err := db.NewHeadscaleDatabase(cfg) + require.NoError(tb, err) + + users := database.CreateUsersForTest(1, "testuser") + dbNodes := database.CreateRegisteredNodesForTest(users[0], nodeCount, "node") + + allNodes := make([]node, 0, nodeCount) + for i := range dbNodes { + allNodes = append(allNodes, node{ + n: dbNodes[i], + ch: make(chan *tailcfg.MapResponse, normalBufferSize), + }) + } + + st, err := state.NewState(cfg) + require.NoError(tb, err) + + derpMap, err := derp.GetDERPMap(cfg.DERP) + require.NoError(tb, err) + st.SetDERPMap(derpMap) + + _, err = st.SetPolicy([]byte(policy)) + require.NoError(tb, err) + + batcher := wrapBatcherForTest(NewBatcherAndMapper(cfg, st), st) + batcher.Start() + + td := &TestData{ + Database: database, + Users: users, + Nodes: allNodes, + State: st, + Config: cfg, + Batcher: batcher, + } + + return td, func() { + batcher.Close() + st.Close() + database.Close() + } +} + +// TestInitialMapNotStarvedByReconnectStorm reproduces juanfont/headscale#3346. +// +// When every node redials at once (e.g. after a server upgrade restart), each +// connection writes the NodeStore (UpdateNodeFromMapRequest + Connect) and the +// batcher generates its initial map. All of that reads the policy through the +// PolicyManager. Before the fix the PolicyManager guarded every read with a +// single exclusive mutex, so the NodeStore writer's O(n^2) BuildPeerMap and +// every node's FilterForNode serialised against each other. On a per-node +// filter policy (autogroup:self, via, relay grants) each hold is expensive, so +// under the storm time-to-initial-map grew without bound. +// +// On the production server in #3346 this drove the batcher's per-node +// total.duration from ~4s to ~76s; tailscale clients aborted the map POST +// first and reported +// +// PollNetMap: Post ".../machine/map": unexpected EOF +// +// then redialled, feeding the storm so it never converged. An allow-all policy +// does NOT reproduce this — BuildPeerMap is cheap there; the per-node filter +// path is what makes it expensive, matching a real deployment's ACLs. +// +// The fix makes PolicyManager reads take a shared RLock so map generation runs +// concurrently. AddNode blocks until the initial map is generated and handed to +// the node channel, so its wall-clock duration is the time-to-initial-map the +// client experiences. Without the fix this test's slowest node takes ~10s+ at +// this scale (lock-bound, and more workers do not help); with it, generation +// parallelises across workers and stays well within a client's patience. +func TestInitialMapNotStarvedByReconnectStorm(t *testing.T) { + if testing.Short() { + t.Skip("timing-sensitive storm regression; skipped in -short") + } + + const ( + nodeCount = 300 + + // A per-node-filter policy: forces BuildPeerMap and FilterForNode onto + // the slow path that recompiles filter rules per node, the same shape + // as a real ACL using autogroup:self / via / relay grants. + perNodeFilterPolicy = `{"acls":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self:*"]}]}` + + // Deliberately roomy so it passes on CI's few-core runners, where the + // single-writer BuildPeerMap sets the floor (~10s) whatever the reads + // do. It still trips on a hang or a return to the ~76s serialised + // behaviour; the fine-grained concurrency is verified separately by + // TestPolicyManagerConcurrentReads under -race. + maxAcceptableLatency = 30 * time.Second + ) + + // Use the real available parallelism, as production does. + workers := runtime.NumCPU() + + td, cleanup := setupStormBatcher(t, nodeCount, workers, perNodeFilterPolicy) + defer cleanup() + + latencies := make([]time.Duration, nodeCount) + + var wg sync.WaitGroup + + for i := range td.Nodes { + wg.Go(func() { + n := &td.Nodes[i] + + start := time.Now() + err := td.Batcher.AddNode(n.n.ID, n.ch, tailcfg.CapabilityVersion(100), nil) + latencies[i] = time.Since(start) + + assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine + }) + } + + wg.Wait() + + slices.Sort(latencies) + p50 := latencies[len(latencies)/2] + p95 := latencies[len(latencies)*95/100] + maxLatency := latencies[len(latencies)-1] + t.Logf("initial-map latency over %d nodes (workers=%d): p50=%s p95=%s max=%s", + nodeCount, workers, p50, p95, maxLatency) + + require.Less(t, maxLatency, maxAcceptableLatency, + "slowest initial map took %s: policy reads are serialising instead of running concurrently (issue #3346)", maxLatency) +} diff --git a/hscontrol/mapper/mapper.go b/hscontrol/mapper/mapper.go index a9a430738..bd04d2e73 100644 --- a/hscontrol/mapper/mapper.go +++ b/hscontrol/mapper/mapper.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io/fs" + "maps" "net/url" "os" "path" @@ -51,12 +52,6 @@ type mapper struct { created time.Time } -//nolint:unused -type patch struct { - timestamp time.Time - change *tailcfg.PeerChange -} - func newMapper( cfg *types.Config, state *state.State, @@ -76,7 +71,6 @@ func generateUserProfiles( peers views.Slice[types.NodeView], ) []tailcfg.UserProfile { userMap := make(map[uint]*types.UserView) - ids := make([]uint, 0, len(userMap)) user := node.Owner() if !user.Valid() { @@ -87,9 +81,7 @@ func generateUserProfiles( return nil } - userID := user.Model().ID - userMap[userID] = &user - ids = append(ids, userID) + userMap[user.Model().ID] = &user for _, peer := range peers.All() { peerUser := peer.Owner() @@ -97,20 +89,13 @@ func generateUserProfiles( continue } - peerUserID := peerUser.Model().ID - userMap[peerUserID] = &peerUser - ids = append(ids, peerUserID) + userMap[peerUser.Model().ID] = &peerUser } - slices.Sort(ids) - ids = slices.Compact(ids) - var profiles []tailcfg.UserProfile - for _, id := range ids { - if userMap[id] != nil { - profiles = append(profiles, userMap[id].TailscaleUserProfile()) - } + for _, id := range slices.Sorted(maps.Keys(userMap)) { + profiles = append(profiles, userMap[id].TailscaleUserProfile()) } return profiles @@ -505,15 +490,9 @@ func (m *mapper) filterVisiblePeerPatches( return nil } - var filtered []*tailcfg.PeerChange - - for _, patch := range patches { - if _, vis := visible[patch.NodeID]; vis { - filtered = append(filtered, patch) - } - } - - return filtered + return filterByVisible(visible, patches, func(p *tailcfg.PeerChange) tailcfg.NodeID { + return p.NodeID + }) } // filterVisibleNodes restricts a peer slice to the nodes the recipient can see @@ -530,15 +509,27 @@ func (m *mapper) filterVisibleNodes( return views.SliceOf([]types.NodeView{}) } - var filtered []types.NodeView + return views.SliceOf(filterByVisible(visible, peers.AsSlice(), func(p types.NodeView) tailcfg.NodeID { + return p.ID().NodeID() + })) +} - for _, peer := range peers.All() { - if _, vis := visible[peer.ID().NodeID()]; vis { - filtered = append(filtered, peer) +// filterByVisible keeps only the items whose key resolves to a NodeID present +// in the visible set, preserving input order. +func filterByVisible[T any]( + visible map[tailcfg.NodeID]struct{}, + items []T, + key func(T) tailcfg.NodeID, +) []T { + var filtered []T + + for _, it := range items { + if _, ok := visible[key(it)]; ok { + filtered = append(filtered, it) } } - return views.SliceOf(filtered) + return filtered } func writeDebugMapResponse( diff --git a/hscontrol/mapper/mapper_test.go b/hscontrol/mapper/mapper_test.go index 94880c783..9b19c9498 100644 --- a/hscontrol/mapper/mapper_test.go +++ b/hscontrol/mapper/mapper_test.go @@ -3,6 +3,7 @@ package mapper import ( "fmt" "net/netip" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -536,6 +537,94 @@ func TestBuildFromChangeVisibilityMatchesFullMap(t *testing.T) { } } +// TestFullMapResponseSurvivesPeerWithInvalidName proves a single node with an +// FQDN-invalid GivenName must not break map generation for its peers. +// +// A node whose stored GivenName is empty (ErrNodeHasNoGivenName) or yields an +// FQDN longer than MaxHostnameLength (ErrHostnameTooLong) makes GetFQDN, and +// therefore TailNode, return an error. buildTailPeers used to abort the entire +// peer list on the first such error, so MapResponseBuilder.Build() failed for +// every node that could see the bad peer; on the initial-connection path that +// surfaced as "PollNetMap: ... unexpected EOF" and the "Unable to connect to +// the Tailscale coordination server" health warning. A legacy DB row loads +// verbatim (NewNodeStore reads db.ListNodes() without re-sanitising names), so +// the bad peer persists across restart. The build for an unaffected viewer +// must succeed: the bad peer is dropped, valid peers and self survive. +func TestFullMapResponseSurvivesPeerWithInvalidName(t *testing.T) { + for _, tt := range []struct { + name string + badName string + }{ + {"empty given name", ""}, + {"over-long fqdn", strings.Repeat("a", types.MaxHostnameLength+1)}, + } { + t.Run(tt.name, func(t *testing.T) { + tmp := t.TempDir() + p4 := netip.MustParsePrefix("100.64.0.0/10") + p6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48") + cfg := &types.Config{ + Database: types.DatabaseConfig{ + Type: types.DatabaseSqlite, + Sqlite: types.SqliteConfig{Path: tmp + "/h.db"}, + }, + PrefixV4: &p4, + PrefixV6: &p6, + IPAllocation: types.IPAllocationStrategySequential, + BaseDomain: "headscale.test", + Policy: types.PolicyConfig{Mode: types.PolicyModeDB}, + DERP: types.DERPConfig{ + DERPMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{999: {RegionID: 999}}, + }, + }, + Tuning: types.Tuning{ + NodeStoreBatchSize: state.TestBatchSize, + NodeStoreBatchTimeout: state.TestBatchTimeout, + }, + } + + database, err := db.NewHeadscaleDatabase(cfg) + require.NoError(t, err) + + user := database.CreateUserForTest("u1") + n1 := database.CreateRegisteredNodeForTest(user, "n1") // viewer, valid + bad := database.CreateRegisteredNodeForTest(user, "bad") // peer, name corrupted below + good := database.CreateRegisteredNodeForTest(user, "good") // peer, valid control + + // Simulate a legacy/corrupt row that v29 loads verbatim. + require.NoError(t, database.DB. + Model(&types.Node{}). + Where("id = ?", bad.ID). + Update("given_name", tt.badName).Error) + require.NoError(t, database.Close()) + + s, err := state.NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + // Allow-all so n1 sees both peers; the bad one must still be dropped. + _, err = s.SetPolicy([]byte(`{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`)) + require.NoError(t, err) + + m := &mapper{state: s, cfg: cfg} + capVer := tailcfg.CurrentCapabilityVersion + + resp, err := m.fullMapResponse(n1.ID, capVer) + require.NoError(t, err, "n1's map must build despite a peer with an invalid name") + require.NotNil(t, resp) + require.NotNil(t, resp.Node, "n1 must receive its own self node") + + peers := map[tailcfg.NodeID]bool{} + for _, p := range resp.Peers { + peers[p.ID] = true + } + + assert.False(t, peers[bad.ID.NodeID()], "the peer with an invalid name must be dropped") + assert.True(t, peers[good.ID.NodeID()], "valid peers must remain in the map") + }) + } +} + // TestGenerateDNSConfigNilHostinfoNoPanic proves generateDNSConfig does not // panic when a node's Hostinfo is nil (e.g. a legacy DB row with a NULL // host_info column). addNextDNSMetadata dereferenced node.Hostinfo().OS() diff --git a/hscontrol/mapper/node_conn.go b/hscontrol/mapper/node_conn.go index b91d2c7c2..97d024a85 100644 --- a/hscontrol/mapper/node_conn.go +++ b/hscontrol/mapper/node_conn.go @@ -3,6 +3,7 @@ package mapper import ( "errors" "fmt" + "slices" "strconv" "sync" "sync/atomic" @@ -142,9 +143,7 @@ func (mc *multiChannelNodeConn) stopConnection(conn *connectionEntry) { // Caller must hold mc.mutex. func (mc *multiChannelNodeConn) removeConnectionAtIndexLocked(i int, stopConnection bool) *connectionEntry { conn := mc.connections[i] - copy(mc.connections[i:], mc.connections[i+1:]) - mc.connections[len(mc.connections)-1] = nil // release pointer for GC - mc.connections = mc.connections[:len(mc.connections)-1] + mc.connections = slices.Delete(mc.connections, i, i+1) if stopConnection { mc.stopConnection(conn) @@ -183,6 +182,16 @@ func (mc *multiChannelNodeConn) removeConnectionByChannel(c chan<- *tailcfg.MapR return false } +// detach removes the connection for the given channel and marks the node +// disconnected if no active connections remain. +func (mc *multiChannelNodeConn) detach(c chan<- *tailcfg.MapResponse) { + mc.removeConnectionByChannel(c) + + if !mc.hasActiveConnections() { + mc.markDisconnected() + } +} + // hasActiveConnections checks if the node has any active connections. func (mc *multiChannelNodeConn) hasActiveConnections() bool { mc.mutex.RLock() @@ -300,6 +309,7 @@ func (mc *multiChannelNodeConn) send(data *tailcfg.MapResponse) error { snapshot = append(snapshot, conn) } } + mc.mutex.RUnlock() if len(snapshot) == 0 { @@ -343,32 +353,22 @@ func (mc *multiChannelNodeConn) send(data *tailcfg.MapResponse) error { // Remove by pointer identity: only remove entries that still exist // in the current connections slice and match a failed pointer. // New connections added since the snapshot are not affected. - failedSet := make(map[*connectionEntry]struct{}, len(failed)) - for _, f := range failed { - failedSet[f] = struct{}{} - } - - clean := mc.connections[:0] - for _, conn := range mc.connections { - if _, isFailed := failedSet[conn]; !isFailed { - clean = append(clean, conn) - } else { - mc.log.Debug(). - Str(zf.ConnID, conn.id). - Msg("send: removing failed connection") - // Tear down the owning session so the old serveLongPoll - // goroutine exits instead of lingering as a stale session. - mc.stopConnection(conn) + // DeleteFunc preserves order and zeroes trailing slots so removed + // *connectionEntry values are not retained by the backing array. + mc.connections = slices.DeleteFunc(mc.connections, func(conn *connectionEntry) bool { + if !slices.Contains(failed, conn) { + return false } - } - // Nil out trailing slots so removed *connectionEntry values - // are not retained by the backing array. - for i := len(clean); i < len(mc.connections); i++ { - mc.connections[i] = nil - } + mc.log.Debug(). + Str(zf.ConnID, conn.id). + Msg("send: removing failed connection") + // Tear down the owning session so the old serveLongPoll + // goroutine exits instead of lingering as a stale session. + mc.stopConnection(conn) - mc.connections = clean + return true + }) mc.mutex.Unlock() } diff --git a/hscontrol/noise.go b/hscontrol/noise.go index 8f65a1ea4..3c6bb6e2a 100644 --- a/hscontrol/noise.go +++ b/hscontrol/noise.go @@ -31,9 +31,6 @@ var ErrUnsupportedClientVersion = errors.New("unsupported client version") // ErrMissingURLParameter is returned when a required URL parameter is not provided. var ErrMissingURLParameter = errors.New("missing URL parameter") -// ErrUnsupportedURLParameterType is returned when a URL parameter has an unsupported type. -var ErrUnsupportedURLParameterType = errors.New("unsupported URL parameter type") - // ErrNoAuthSession is returned when an auth_id does not match any active auth session. var ErrNoAuthSession = errors.New("no auth session found") @@ -338,40 +335,27 @@ func (h *Headscale) PingResponseHandler( } } -func urlParam[T any](req *http.Request, key string) (T, error) { - var zero T - +func stringParam(req *http.Request, key string) (string, error) { param := chi.URLParam(req, key) if param == "" { - return zero, fmt.Errorf("%w: %s", ErrMissingURLParameter, key) + return "", fmt.Errorf("%w: %s", ErrMissingURLParameter, key) } - var value T - switch any(value).(type) { - case string: - v, ok := any(param).(T) - if !ok { - return zero, fmt.Errorf("%w: %T", ErrUnsupportedURLParameterType, value) - } + return param, nil +} - value = v - case types.NodeID: - id, err := types.ParseNodeID(param) - if err != nil { - return zero, fmt.Errorf("parsing %s: %w", key, err) - } - - v, ok := any(id).(T) - if !ok { - return zero, fmt.Errorf("%w: %T", ErrUnsupportedURLParameterType, value) - } - - value = v - default: - return zero, fmt.Errorf("%w: %T", ErrUnsupportedURLParameterType, value) +func nodeIDParam(req *http.Request, key string) (types.NodeID, error) { + param := chi.URLParam(req, key) + if param == "" { + return 0, fmt.Errorf("%w: %s", ErrMissingURLParameter, key) } - return value, nil + id, err := types.ParseNodeID(param) + if err != nil { + return 0, fmt.Errorf("parsing %s: %w", key, err) + } + + return id, nil } // SSHActionHandler handles the /ssh-action endpoint, returning a @@ -381,7 +365,7 @@ func (ns *noiseServer) SSHActionHandler( writer http.ResponseWriter, req *http.Request, ) { - srcNodeID, err := urlParam[types.NodeID](req, "src_node_id") + srcNodeID, err := nodeIDParam(req, "src_node_id") if err != nil { httpError(writer, NewHTTPError( http.StatusBadRequest, @@ -392,7 +376,7 @@ func (ns *noiseServer) SSHActionHandler( return } - dstNodeID, err := urlParam[types.NodeID](req, "dst_node_id") + dstNodeID, err := nodeIDParam(req, "dst_node_id") if err != nil { httpError(writer, NewHTTPError( http.StatusBadRequest, diff --git a/hscontrol/noise_test.go b/hscontrol/noise_test.go index 922ef5051..99b3ec4bf 100644 --- a/hscontrol/noise_test.go +++ b/hscontrol/noise_test.go @@ -459,6 +459,71 @@ func TestSSHActionHandler_RejectsMissingSessionWithoutCheck(t *testing.T) { "a bogus auth_id with no active check must be rejected, body=%s", rec.Body.String()) } +// TestTS2021Route_AcceptsGETAndPOST reproduces a regression where the +// browser/WASM control client could not connect. Tailscale's JS/WASM control +// client opens /ts2021 as a WebSocket, which is an HTTP GET upgrade; the native +// Go client uses an HTTP POST upgrade. The gorilla->chi router migration +// registered /ts2021 for POST only, so the GET WebSocket handshake was rejected +// with 405 Method Not Allowed by the router before it could reach +// NoiseUpgradeHandler. Both methods must route to the handler. +// +// NoiseUpgradeHandler dispatches on the Upgrade header, not the HTTP method, so +// once the route is reachable the handler handles both upgrade styles. The +// httptest recorder is not an http.Hijacker, so the upgrade itself fails past +// the router (501 for the WebSocket path, 400 for the native path) — the point +// is only that neither is 405, i.e. the router no longer rejects GET early. +func TestTS2021Route_AcceptsGETAndPOST(t *testing.T) { + t.Parallel() + + handler := createTestApp(t).HTTPHandler() + + tests := []struct { + name string + method string + headers map[string]string + }{ + { + name: "websocket_get_from_wasm_client", + method: http.MethodGet, + headers: map[string]string{ + "Connection": "Upgrade", + "Upgrade": "websocket", + "Sec-WebSocket-Version": "13", + "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Protocol": "tailscale-control-protocol", + }, + }, + { + name: "native_post_upgrade", + method: http.MethodPost, + headers: map[string]string{ + "Connection": "upgrade", + "Upgrade": "tailscale-control-protocol", + "X-Tailscale-Handshake": "AAAA", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(context.Background(), tt.method, + "/ts2021?X-Tailscale-Handshake=AAAA", nil) + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.NotEqual(t, http.StatusMethodNotAllowed, rec.Code, + "%s /ts2021 must reach NoiseUpgradeHandler, not be rejected by the router with 405", + tt.method) + }) + } +} + // newSSHActionFollowUpRequest is like newSSHActionRequest but carries the // auth_id query parameter that marks a follow-up poll. func newSSHActionFollowUpRequest(t *testing.T, src, dst types.NodeID, authID types.AuthID) *http.Request { diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index 7f1e3fae5..bd660e4d1 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -20,6 +20,7 @@ import ( "github.com/juanfont/headscale/hscontrol/util" "github.com/rs/zerolog/log" "golang.org/x/oauth2" + "tailscale.com/util/rands" ) const ( @@ -54,6 +55,7 @@ var ( "authenticated principal does not match any allowed user", ) errOIDCUnverifiedEmail = errors.New("authenticated principal has an unverified email") + errInvalidPKCEMethod = errors.New("invalid pkce.method") ) // AuthInfo contains both auth ID and verifier information for OIDC validation. @@ -83,9 +85,9 @@ func NewAuthProviderOIDC( serverURL string, cfg *types.OIDCConfig, ) (*AuthProviderOIDC, error) { - var err error - // grab oidc config if it hasn't been already - oidcProvider, err := oidc.NewProvider(context.Background(), cfg.Issuer) //nolint:contextcheck + // Use the caller's context (bounded, see app.go) so a slow or unreachable + // issuer fails discovery within the timeout instead of hanging startup. + oidcProvider, err := oidc.NewProvider(ctx, cfg.Issuer) if err != nil { return nil, fmt.Errorf("creating OIDC provider from issuer config: %w", err) } @@ -115,11 +117,17 @@ func NewAuthProviderOIDC( }, nil } +// cookiesSecure reports whether the OIDC cookies should carry the Secure flag. +// It keys off the configured server_url scheme, not req.TLS, so cookies stay +// Secure behind a TLS-terminating reverse proxy (where the proxy→Headscale hop +// is plain HTTP and req.TLS is nil). Deriving it from config avoids trusting a +// spoofable X-Forwarded-Proto header. +func (a *AuthProviderOIDC) cookiesSecure() bool { + return strings.HasPrefix(a.serverURL, "https://") +} + func (a *AuthProviderOIDC) AuthURL(authID types.AuthID) string { - return fmt.Sprintf( - "%s/auth/%s", - strings.TrimSuffix(a.serverURL, "/"), - authID.String()) + return authPathURL(a.serverURL, "auth", authID) } func (a *AuthProviderOIDC) AuthHandler( @@ -130,10 +138,7 @@ func (a *AuthProviderOIDC) AuthHandler( } func (a *AuthProviderOIDC) RegisterURL(authID types.AuthID) string { - return fmt.Sprintf( - "%s/register/%s", - strings.TrimSuffix(a.serverURL, "/"), - authID.String()) + return authPathURL(a.serverURL, "register", authID) } // RegisterHandler registers the OIDC callback handler with the given router. @@ -160,18 +165,10 @@ func (a *AuthProviderOIDC) authHandler( } // Set the state and nonce cookies to protect against CSRF attacks - state, err := setCSRFCookie(writer, req, "state") - if err != nil { - httpUserError(writer, err) - return - } + state := setCSRFCookie(writer, req, "state", a.cookiesSecure()) // Set the state and nonce cookies to protect against CSRF attacks - nonce, err := setCSRFCookie(writer, req, "nonce") - if err != nil { - httpUserError(writer, err) - return - } + nonce := setCSRFCookie(writer, req, "nonce", a.cookiesSecure()) registrationInfo := AuthInfo{ AuthID: authID, @@ -192,6 +189,12 @@ func (a *AuthProviderOIDC) authHandler( case types.PKCEMethodPlain: // oauth2 does not have a plain challenge option, so we add it manually extras = append(extras, oauth2.SetAuthURLParam("code_challenge_method", "plain"), oauth2.SetAuthURLParam("code_challenge", verifier)) + default: + // An unknown method must not silently emit no challenge: a + // verifier was generated and is sent at token exchange, so a + // missing challenge degrades to no-PKCE without anyone noticing. + httpError(writer, NewHTTPError(http.StatusInternalServerError, "internal server error", fmt.Errorf("%w: %q", errInvalidPKCEMethod, a.cfg.PKCE.Method))) + return } } @@ -269,6 +272,11 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler( return } + // The state/nonce cookies have served their CSRF purpose; clear them so a + // single-use pair does not linger in the browser until MaxAge. + clearOIDCCallbackCookie(writer, stateCookieName) + clearOIDCCallbackCookie(writer, nonceCookieName) + nodeExpiry := a.determineNodeExpiry(idToken.Expiry) var claims types.OIDCClaims @@ -302,8 +310,6 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler( if userinfo2.Groups != nil { claims.Groups = userinfo2.Groups } - } else { - util.LogErr(err, "could not get userinfo; only using claims from id token") } // The user claims are now updated from the userinfo endpoint so we can verify the user @@ -479,7 +485,7 @@ func (a *AuthProviderOIDC) getOauth2Token( return nil, NewHTTPError(http.StatusForbidden, "invalid code", fmt.Errorf("exchanging code for token: %w", err)) } - return oauth2Token, err + return oauth2Token, nil } // extractIDToken extracts the ID token from the oauth2 token. @@ -597,13 +603,17 @@ func doOIDCAuthorization( return nil } -// getAuthInfoFromState retrieves the registration ID from the state. +// getAuthInfoFromState retrieves and consumes the auth info for a state. The +// entry is removed on read so a state is single-use: a replayed callback cannot +// resolve the same auth session twice, even within the cache TTL. func (a *AuthProviderOIDC) getAuthInfoFromState(state string) *AuthInfo { authInfo, ok := a.authCache.Get(state) if !ok { return nil } + a.authCache.Remove(state) + return &authInfo } @@ -657,6 +667,29 @@ func (a *AuthProviderOIDC) createOrUpdateUserFromClaim( // browser do not collide. const registerConfirmCSRFCookie = "headscale_register_confirm" +// setRegisterConfirmCookie writes the per-session register-confirm CSRF +// cookie. Pass the CSRF token and authCacheExpiration seconds to set it; +// pass ("", -1) to clear it after the registration is finalised. +func setRegisterConfirmCookie( + writer http.ResponseWriter, + req *http.Request, + authID types.AuthID, + value string, + maxAge int, + secure bool, +) { + //nolint:gosec // G124: Secure from server_url scheme or req.TLS; HttpOnly + SameSite already set + http.SetCookie(writer, &http.Cookie{ + Name: registerConfirmCSRFCookie, + Value: value, + Path: "/register/confirm/" + authID.String(), + MaxAge: maxAge, + Secure: secure || req.TLS != nil, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) +} + // renderRegistrationConfirmInterstitial captures the resolved OIDC // identity and node expiry into the cached [types.AuthRequest], sets the CSRF // cookie, and renders the confirmation page that the user must @@ -685,12 +718,7 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial( return } - csrf, err := util.GenerateRandomStringURLSafe(32) - if err != nil { - httpUserError(writer, fmt.Errorf("generating csrf token: %w", err)) - - return - } + csrf := rands.HexString(32) authReq.SetPendingConfirmation(&types.PendingRegistrationConfirmation{ UserID: user.ID, @@ -698,16 +726,7 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial( CSRF: csrf, }) - //nolint:gosec // G124: Secure set conditionally via req.TLS; HttpOnly + SameSite already set - http.SetCookie(writer, &http.Cookie{ - Name: registerConfirmCSRFCookie, - Value: csrf, - Path: "/register/confirm/" + authID.String(), - MaxAge: int(authCacheExpiration.Seconds()), - Secure: req.TLS != nil, - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - }) + setRegisterConfirmCookie(writer, req, authID, csrf, int(authCacheExpiration.Seconds()), a.cookiesSecure()) regData := authReq.RegistrationData() @@ -824,16 +843,7 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler( } // Clear the CSRF cookie now that the registration is final. - //nolint:gosec // G124: Secure set conditionally via req.TLS; HttpOnly + SameSite already set - http.SetCookie(writer, &http.Cookie{ - Name: registerConfirmCSRFCookie, - Value: "", - Path: "/register/confirm/" + authID.String(), - MaxAge: -1, - Secure: req.TLS != nil, - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - }) + setRegisterConfirmCookie(writer, req, authID, "", -1, a.cookiesSecure()) content := renderRegistrationSuccessTemplate(user, newNode) @@ -929,19 +939,27 @@ func getCookieName(baseName, value string) string { return fmt.Sprintf("%s_%s", baseName, value[:n]) } -func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string, error) { - val, err := util.GenerateRandomStringURLSafe(64) - if err != nil { - return val, err - } +// clearOIDCCallbackCookie expires a /oidc/callback cookie by name. Matching the +// path the cookie was set with is required for the browser to drop it. +func clearOIDCCallbackCookie(w http.ResponseWriter, name string) { + //nolint:gosec // G124: a deletion cookie (empty value, MaxAge<0); security attributes are moot + http.SetCookie(w, &http.Cookie{ + Name: name, + Path: "/oidc/callback", + MaxAge: -1, + }) +} - //nolint:gosec // G124: Secure set conditionally via r.TLS; HttpOnly + SameSite set below +func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string, secure bool) string { + val := rands.HexString(64) + + //nolint:gosec // G124: Secure from server_url scheme or req.TLS; HttpOnly + SameSite set below c := &http.Cookie{ Path: "/oidc/callback", Name: getCookieName(name, val), Value: val, MaxAge: int(time.Hour.Seconds()), - Secure: r.TLS != nil, + Secure: secure || r.TLS != nil, HttpOnly: true, // Lax, not Strict: the OIDC callback is a cross-site top-level GET // redirect from the IdP that must still carry this cookie. Strict @@ -952,5 +970,5 @@ func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string, } http.SetCookie(w, c) - return val, nil + return val } diff --git a/hscontrol/oidc_test.go b/hscontrol/oidc_test.go index 0472f01cf..04d331649 100644 --- a/hscontrol/oidc_test.go +++ b/hscontrol/oidc_test.go @@ -4,7 +4,9 @@ import ( "net/http" "net/http/httptest" "testing" + "time" + "github.com/hashicorp/golang-lru/v2/expirable" "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -186,11 +188,76 @@ func TestSetCSRFCookieSameSite(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/abcdef0123456789", nil) - _, err := setCSRFCookie(w, r, "state") - require.NoError(t, err) + setCSRFCookie(w, r, "state", false) cookies := w.Result().Cookies() require.Len(t, cookies, 1) assert.Equal(t, http.SameSiteLaxMode, cookies[0].SameSite, "OIDC CSRF cookie must explicitly set SameSite=Lax") } + +// TestExtractCodeAndStateParam covers the callback's first trust-boundary +// checks: both params required, and a too-short state is rejected before +// getCookieName can slice out of range. +func TestExtractCodeAndStateParam(t *testing.T) { + _, _, err := extractCodeAndStateParamFromRequest( + httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback", nil)) + require.Error(t, err) + + _, _, err = extractCodeAndStateParamFromRequest( + httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback?code=c&state=abc", nil)) + require.ErrorIs(t, err, errOIDCStateTooShort) + + code, state, err := extractCodeAndStateParamFromRequest( + httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback?code=c&state=abcdef0123", nil)) + require.NoError(t, err) + assert.Equal(t, "c", code) + assert.Equal(t, "abcdef0123", state) +} + +// TestGetAuthInfoFromStateSingleUse asserts a consumed OIDC state cannot be +// resolved twice, so a replayed callback cannot re-bind the same session. +func TestGetAuthInfoFromStateSingleUse(t *testing.T) { + a := &AuthProviderOIDC{ + authCache: expirable.NewLRU[string, AuthInfo](16, nil, time.Minute), + } + a.authCache.Add("state-x", AuthInfo{Registration: true}) + + got := a.getAuthInfoFromState("state-x") + require.NotNil(t, got) + assert.True(t, got.Registration) + + assert.Nil(t, a.getAuthInfoFromState("state-x"), "a consumed state must not resolve again") +} + +// TestClearOIDCCallbackCookie asserts the cookie is expired (negative MaxAge) on +// the same path it was set with, so the browser drops it. +func TestClearOIDCCallbackCookie(t *testing.T) { + w := httptest.NewRecorder() + clearOIDCCallbackCookie(w, "state_abcdef") + + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + assert.Equal(t, "state_abcdef", cookies[0].Name) + assert.Negative(t, cookies[0].MaxAge, "deletion cookie must have negative MaxAge") +} + +// TestSetCSRFCookieSecure verifies the Secure flag is driven by the secure +// argument (derived from the configured https server_url), not only req.TLS, so +// cookies stay Secure behind a TLS-terminating reverse proxy where req.TLS is +// nil. +func TestSetCSRFCookieSecure(t *testing.T) { + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/abcdef0123456789", nil) + + secureRec := httptest.NewRecorder() + setCSRFCookie(secureRec, r, "state", true) + require.Len(t, secureRec.Result().Cookies(), 1) + assert.True(t, secureRec.Result().Cookies()[0].Secure, + "https server_url must set Secure even when req.TLS is nil (proxy case)") + + plainRec := httptest.NewRecorder() + setCSRFCookie(plainRec, r, "state", false) + require.Len(t, plainRec.Result().Cookies(), 1) + assert.False(t, plainRec.Result().Cookies()[0].Secure, + "plain-http server_url without req.TLS must not set Secure") +} diff --git a/hscontrol/platform_config.go b/hscontrol/platform_config.go index bc19d7ca0..28a918bce 100644 --- a/hscontrol/platform_config.go +++ b/hscontrol/platform_config.go @@ -3,7 +3,6 @@ package hscontrol import ( "bytes" _ "embed" - "html/template" "net/http" textTemplate "text/template" @@ -59,32 +58,20 @@ func (h *Headscale) ApplePlatformConfig( URL: h.cfg.ServerURL, } - var payload bytes.Buffer - - switch platform { - case "macos-standalone": - err := macosStandaloneTemplate.Execute(&payload, platformConfig) - if err != nil { - httpError(writer, err) - return - } - case "macos-app-store": - err := macosAppStoreTemplate.Execute(&payload, platformConfig) - if err != nil { - httpError(writer, err) - return - } - case "ios": - err := iosTemplate.Execute(&payload, platformConfig) - if err != nil { - httpError(writer, err) - return - } - default: + payloadType, ok := applePayloadType[platform] + if !ok { httpError(writer, NewHTTPError(http.StatusBadRequest, "platform must be ios, macos-app-store or macos-standalone", nil)) return } + platformConfig.PayloadType = payloadType + + var payload bytes.Buffer + if err := payloadTemplate.Execute(&payload, platformConfig); err != nil { //nolint:noinlineerr + httpError(writer, err) + return + } + config := AppleMobileConfig{ UUID: id, URL: h.cfg.ServerURL, @@ -110,8 +97,17 @@ type AppleMobileConfig struct { } type AppleMobilePlatformConfig struct { - UUID uuid.UUID - URL string + UUID uuid.UUID + URL string + PayloadType string +} + +// applePayloadType maps a platform request path to the Tailscale IPN +// PayloadType emitted in the rendered Apple profile. +var applePayloadType = map[string]string{ + "ios": "io.tailscale.ipn.ios", + "macos-app-store": "io.tailscale.ipn.macos", + "macos-standalone": "io.tailscale.ipn.macsys", } var commonTemplate = textTemplate.Must( @@ -141,10 +137,10 @@ var commonTemplate = textTemplate.Must( `), ) -var iosTemplate = textTemplate.Must(textTemplate.New("iosTemplate").Parse(` +var payloadTemplate = textTemplate.Must(textTemplate.New("payloadTemplate").Parse(` PayloadType - io.tailscale.ipn.ios + {{.PayloadType}} PayloadUUID {{.UUID}} PayloadIdentifier @@ -158,37 +154,3 @@ var iosTemplate = textTemplate.Must(textTemplate.New("iosTemplate").Parse(` {{.URL}} `)) - -var macosAppStoreTemplate = template.Must(template.New("macosTemplate").Parse(` - - PayloadType - io.tailscale.ipn.macos - PayloadUUID - {{.UUID}} - PayloadIdentifier - com.github.juanfont.headscale - PayloadVersion - 1 - PayloadEnabled - - ControlURL - {{.URL}} - -`)) - -var macosStandaloneTemplate = template.Must(template.New("macosStandaloneTemplate").Parse(` - - PayloadType - io.tailscale.ipn.macsys - PayloadUUID - {{.UUID}} - PayloadIdentifier - com.github.juanfont.headscale - PayloadVersion - 1 - PayloadEnabled - - ControlURL - {{.URL}} - -`)) diff --git a/hscontrol/policy/matcher/export_test.go b/hscontrol/policy/matcher/export_test.go new file mode 100644 index 000000000..63ec9f09d --- /dev/null +++ b/hscontrol/policy/matcher/export_test.go @@ -0,0 +1,13 @@ +package matcher + +// MatchFromStrings builds a [Match] from raw source and destination +// strings. Unparseable entries are silently dropped (fail-open): the +// resulting [Match] is narrower than the input described, but never +// wider. Callers that need strict validation should pre-validate +// their inputs via [util.ParseIPSet]. +func MatchFromStrings(sources, destinations []string) Match { + return Match{ + srcs: buildIPSet(sources), + dests: buildIPSet(destinations), + } +} diff --git a/hscontrol/policy/matcher/matcher.go b/hscontrol/policy/matcher/matcher.go index fd5378341..acfd35b4a 100644 --- a/hscontrol/policy/matcher/matcher.go +++ b/hscontrol/policy/matcher/matcher.go @@ -52,14 +52,8 @@ func MatchesFromFilterRules(rules []tailcfg.FilterRule) []Match { // [policy.ReduceNodes], hiding the cap target // from the source unless a companion IP-level rule also exists. func MatchFromFilterRule(rule tailcfg.FilterRule) Match { - srcs := new(netipx.IPSetBuilder) dests := new(netipx.IPSetBuilder) - for _, srcIP := range rule.SrcIPs { - set, _ := util.ParseIPSet(srcIP, nil) - srcs.AddSet(set) - } - for _, dp := range rule.DstPorts { set, _ := util.ParseIPSet(dp.IP, nil) dests.AddSet(set) @@ -71,45 +65,29 @@ func MatchFromFilterRule(rule tailcfg.FilterRule) Match { } } - srcsSet, _ := srcs.IPSet() destsSet, _ := dests.IPSet() return Match{ - srcs: srcsSet, + srcs: buildIPSet(rule.SrcIPs), dests: destsSet, } } -// MatchFromStrings builds a [Match] from raw source and destination -// strings. Unparseable entries are silently dropped (fail-open): the -// resulting [Match] is narrower than the input described, but never -// wider. Callers that need strict validation should pre-validate -// their inputs via [util.ParseIPSet]. -func MatchFromStrings(sources, destinations []string) Match { - srcs := new(netipx.IPSetBuilder) - dests := new(netipx.IPSetBuilder) +// buildIPSet parses each string via [util.ParseIPSet] and unions the +// results into a single [netipx.IPSet]. Unparseable entries are silently +// dropped (fail-open): the result is narrower than the input described, +// but never wider. +func buildIPSet(addrs []string) *netipx.IPSet { + builder := new(netipx.IPSetBuilder) - for _, srcIP := range sources { - set, _ := util.ParseIPSet(srcIP, nil) - - srcs.AddSet(set) + for _, addr := range addrs { + set, _ := util.ParseIPSet(addr, nil) + builder.AddSet(set) } - for _, dest := range destinations { - set, _ := util.ParseIPSet(dest, nil) + set, _ := builder.IPSet() - dests.AddSet(set) - } - - srcsSet, _ := srcs.IPSet() - destsSet, _ := dests.IPSet() - - match := Match{ - srcs: srcsSet, - dests: destsSet, - } - - return match + return set } func (m *Match) SrcsContainsIPs(ips ...netip.Addr) bool { @@ -142,12 +120,5 @@ func (m *Match) DestsIsTheInternet() bool { // Superset-of-[util.TheInternet] check handles merged filter rules // where the internet prefixes are combined with other dests. - theInternet := util.TheInternet() - for _, prefix := range theInternet.Prefixes() { - if !m.dests.ContainsPrefix(prefix) { - return false - } - } - - return true + return util.IPSetSubsetOf(util.TheInternet(), m.dests) } diff --git a/hscontrol/policy/pm.go b/hscontrol/policy/pm.go index 4c9dc3970..ffde6361a 100644 --- a/hscontrol/policy/pm.go +++ b/hscontrol/policy/pm.go @@ -33,6 +33,12 @@ type PolicyManager interface { // TagExists reports whether the given tag is defined in the policy. TagExists(tag string) bool + // TagOwnedByTags reports whether a credential holding ownerTags may apply + // tag: true if tag is one of ownerTags, or tag's tag-to-tag ownership chain + // transitively includes one of ownerTags. Authorises the tags an OAuth + // access token may set on the auth keys it mints. + TagOwnedByTags(tag string, ownerTags []string) bool + // NodeCanApproveRoute reports whether the given node can approve the given route. NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool diff --git a/hscontrol/policy/policy.go b/hscontrol/policy/policy.go index 82e942f9d..94316eb72 100644 --- a/hscontrol/policy/policy.go +++ b/hscontrol/policy/policy.go @@ -8,7 +8,6 @@ import ( "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" "github.com/rs/zerolog/log" - "github.com/samber/lo" "tailscale.com/types/views" ) @@ -66,8 +65,7 @@ func ApproveRoutesWithPolicy(pm PolicyManager, nv types.NodeView, currentApprove } // Start with ALL currently approved routes - we never remove approved routes - newApproved := make([]netip.Prefix, len(currentApproved)) - copy(newApproved, currentApproved) + newApproved := slices.Clone(currentApproved) // Then, check for new routes that can be auto-approved for _, route := range announcedRoutes { @@ -86,13 +84,12 @@ func ApproveRoutesWithPolicy(pm PolicyManager, nv types.NodeView, currentApprove // Sort and deduplicate slices.SortFunc(newApproved, netip.Prefix.Compare) newApproved = slices.Compact(newApproved) - newApproved = lo.Filter(newApproved, func(route netip.Prefix, index int) bool { - return route.IsValid() + newApproved = slices.DeleteFunc(newApproved, func(route netip.Prefix) bool { + return !route.IsValid() }) // Sort the current approved for comparison - sortedCurrent := make([]netip.Prefix, len(currentApproved)) - copy(sortedCurrent, currentApproved) + sortedCurrent := slices.Clone(currentApproved) slices.SortFunc(sortedCurrent, netip.Prefix.Compare) // Only update if the routes actually changed diff --git a/hscontrol/policy/policyutil/reduce.go b/hscontrol/policy/policyutil/reduce.go index 66ee2e674..fc662aacb 100644 --- a/hscontrol/policy/policyutil/reduce.go +++ b/hscontrol/policy/policyutil/reduce.go @@ -6,7 +6,6 @@ import ( "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" - "go4.org/netipx" "tailscale.com/tailcfg" ) @@ -66,7 +65,7 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf // Exit-route advertisers need rules targeting the // public internet so the kernel filter accepts // traffic forwarded by autogroup:internet sources. - if hasExitRoutes && ipSetSubsetOf(expanded, util.TheInternet()) { + if hasExitRoutes && util.IPSetSubsetOf(expanded, util.TheInternet()) { dests = append(dests, dest) } } @@ -83,20 +82,6 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf return ret } -func ipSetSubsetOf(candidate, container *netipx.IPSet) bool { - if candidate == nil || container == nil { - return false - } - - for _, pref := range candidate.Prefixes() { - if !container.ContainsPrefix(pref) { - return false - } - } - - return true -} - // reduceCapGrantRule filters a [tailcfg.CapGrant] rule to only include // [tailcfg.CapGrant] entries whose Dsts match the given node's IPs. When a // broad prefix (e.g. 100.64.0.0/10 from dst:*) contains a node's IP, it is diff --git a/hscontrol/policy/v2/compiled.go b/hscontrol/policy/v2/compiled.go index 1f8c9ad41..642100c7d 100644 --- a/hscontrol/policy/v2/compiled.go +++ b/hscontrol/policy/v2/compiled.go @@ -11,6 +11,7 @@ import ( "go4.org/netipx" "tailscale.com/tailcfg" "tailscale.com/types/views" + "tailscale.com/util/set" ) // grantCategory classifies a grant by what per-node work it needs. @@ -324,22 +325,34 @@ func (pol *Policy) compileOneGrant( ) } - // Classify and store deferred self data. - switch { - case len(autogroupSelfDests) > 0: + // Classify and store deferred self data. The struct literal already + // initializes category to grantCategoryRegular (the zero value). + if len(autogroupSelfDests) > 0 { cg.category = grantCategorySelf cg.self = &selfGrantData{ resolvedSrcs: resolvedSrcs, internetProtocols: grant.InternetProtocols, app: grant.App, } - default: - cg.category = grantCategoryRegular } return cg, nil } +// mergeResolvedSrcs merges every prefix from the resolved sources into a +// single [resolved] address set. +func mergeResolvedSrcs(resolvedSrcs []ResolvedAddresses) (resolved, error) { + var b netipx.IPSetBuilder + + for _, ips := range resolvedSrcs { + for _, pref := range ips.Prefixes() { + b.AddPrefix(pref) + } + } + + return newResolved(&b) +} + // compileOneViaGrant resolves sources for a via grant and stores the // deferred per-node data. The actual via-node matching and route // intersection happens in [compileViaForNode]. @@ -364,15 +377,7 @@ func (pol *Policy) compileOneViaGrant( } // Build merged SrcIPs. - var srcIPs netipx.IPSetBuilder - - for _, ips := range resolvedSrcs { - for _, pref := range ips.Prefixes() { - srcIPs.AddPrefix(pref) - } - } - - srcResolved, err := newResolved(&srcIPs) + srcResolved, err := mergeResolvedSrcs(resolvedSrcs) if err != nil { return nil, err } @@ -446,20 +451,8 @@ func buildSrcIPStrings( hasWildcard, hasDangerAll bool, nodes views.Slice[types.NodeView], ) []string { - var merged netipx.IPSetBuilder - - for _, ips := range resolvedSrcs { - for _, pref := range ips.Prefixes() { - merged.AddPrefix(pref) - } - } - - srcResolved, err := newResolved(&merged) - if err != nil { - return nil - } - - if srcResolved.Empty() { + srcResolved, err := mergeResolvedSrcs(resolvedSrcs) + if err != nil || srcResolved.Empty() { return nil } @@ -472,15 +465,12 @@ func buildSrcIPStrings( // individual IPs from non-wildcard sources alongside the // merged CGNAT ranges rather than absorbing them. if hasWildcard && len(nonWildcardSrcs) > 0 { - seen := make(map[string]bool, len(srcIPStrs)) - for _, s := range srcIPStrs { - seen[s] = true - } + seen := set.SetOf(srcIPStrs) for _, ips := range nonWildcardSrcs { for _, s := range ips.Strings() { - if !seen[s] { - seen[s] = true + if !seen.Contains(s) { + seen.Add(s) srcIPStrs = append(srcIPStrs, s) } } @@ -627,7 +617,7 @@ func collectRelayTargetIPs(grants []compiledGrant) (*netipx.IPSet, error) { // 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{} + tags := make(map[Tag]struct{}) for i := range grants { if grants[i].via == nil { @@ -635,14 +625,14 @@ func collectViaTargetTags(grants []compiledGrant) map[Tag]struct{} { } for _, t := range grants[i].via.viaTags { - if tags == nil { - tags = make(map[Tag]struct{}) - } - tags[t] = struct{}{} } } + if len(tags) == 0 { + return nil + } + return tags } diff --git a/hscontrol/policy/v2/filter.go b/hscontrol/policy/v2/filter.go index 1d2c43568..6eb99707f 100644 --- a/hscontrol/policy/v2/filter.go +++ b/hscontrol/policy/v2/filter.go @@ -1,12 +1,10 @@ package v2 import ( - "cmp" "errors" "fmt" "net/netip" "slices" - "strconv" "strings" "time" @@ -23,13 +21,22 @@ var ( errSelfInSources = errors.New("autogroup:self cannot be used in sources") ) -// companionCaps maps certain well-known Tailscale capabilities to +// companionCap pairs a well-known Tailscale capability with its +// companion capability. +type companionCap struct { + original tailcfg.PeerCapability + companion tailcfg.PeerCapability +} + +// companionCaps lists certain well-known Tailscale capabilities and // their companion capability. When a grant includes one of these // capabilities, Tailscale automatically generates an additional // [tailcfg.FilterRule] with the companion capability and a nil CapMap value. -var companionCaps = map[tailcfg.PeerCapability]tailcfg.PeerCapability{ - tailcfg.PeerCapabilityTaildrive: tailcfg.PeerCapabilityTaildriveSharer, - tailcfg.PeerCapabilityRelay: tailcfg.PeerCapabilityRelayTarget, +// The slice is ordered by the original capability name so that +// generated companion rules are emitted deterministically. +var companionCaps = []companionCap{ + {tailcfg.PeerCapabilityTaildrive, tailcfg.PeerCapabilityTaildriveSharer}, + {tailcfg.PeerCapabilityRelay, tailcfg.PeerCapabilityRelayTarget}, } // companionCapGrantRules returns additional [tailcfg.FilterRule]s for any @@ -48,38 +55,22 @@ func companionCapGrantRules( srcPrefixes []netip.Prefix, capMap tailcfg.PeerCapMap, ) []tailcfg.FilterRule { - // Process in deterministic order by original capability name. - type pair struct { - original tailcfg.PeerCapability - companion tailcfg.PeerCapability - } + companions := make([]tailcfg.FilterRule, 0, len(companionCaps)) - var pairs []pair - - for cap, companion := range companionCaps { - if _, ok := capMap[cap]; ok { - pairs = append(pairs, pair{cap, companion}) - } - } - - slices.SortFunc(pairs, func(a, b pair) int { - return cmp.Compare(a.original, b.original) - }) - - companions := make([]tailcfg.FilterRule, 0, len(pairs)) - - for _, p := range pairs { - companions = append(companions, tailcfg.FilterRule{ - SrcIPs: dstIPStrings, - CapGrant: []tailcfg.CapGrant{ - { - Dsts: srcPrefixes, - CapMap: tailcfg.PeerCapMap{ - p.companion: nil, + for _, c := range companionCaps { + if _, ok := capMap[c.original]; ok { + companions = append(companions, tailcfg.FilterRule{ + SrcIPs: dstIPStrings, + CapGrant: []tailcfg.CapGrant{ + { + Dsts: srcPrefixes, + CapMap: tailcfg.PeerCapMap{ + c.companion: nil, + }, }, }, - }, - }) + }) + } } return companions @@ -238,7 +229,7 @@ func checkPeriodFromRule(rule SSH) time.Duration { } } -func sshCheck(baseURL string, _ time.Duration) tailcfg.SSHAction { +func sshCheck(baseURL string) tailcfg.SSHAction { holdURL := baseURL + "/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID?local_user=$LOCAL_USER" return tailcfg.SSHAction{ @@ -302,7 +293,7 @@ func (pol *Policy) compileSSHPolicy( case SSHActionAccept: action = sshAccept case SSHActionCheck: - action = sshCheck(baseURL, checkPeriodFromRule(rule)) + action = sshCheck(baseURL) default: return nil, fmt.Errorf( "parsing SSH policy, unknown action %q, index: %d: %w", @@ -425,12 +416,7 @@ func (pol *Policy) compileSSHPolicy( allPrincipals = append(allPrincipals, taggedPrincipals...) if len(allPrincipals) > 0 { - rules = append(rules, &tailcfg.SSHRule{ - Principals: allPrincipals, - SSHUsers: baseUserMap, - Action: &action, - AcceptEnv: acceptEnv, - }) + appendRules(allPrincipals, 0, false) } } } else if hasLocalpart && slices.ContainsFunc(node.IPs(), srcIPs.Contains) { @@ -610,14 +596,7 @@ func groupSourcesByUser( // filterRuleKey generates a unique key for merging based on [tailcfg.FilterRule.SrcIPs] // and [tailcfg.FilterRule.IPProto]. func filterRuleKey(rule tailcfg.FilterRule) string { - srcKey := strings.Join(rule.SrcIPs, ",") - - protoStrs := make([]string, len(rule.IPProto)) - for i, p := range rule.IPProto { - protoStrs[i] = strconv.Itoa(p) - } - - return srcKey + "|" + strings.Join(protoStrs, ",") + return fmt.Sprintf("%s|%v", strings.Join(rule.SrcIPs, ","), rule.IPProto) } // mergeFilterRules merges rules with identical [tailcfg.FilterRule.SrcIPs] and diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index a7b55a2a6..ae76ad267 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -15,6 +15,7 @@ import ( "github.com/juanfont/headscale/hscontrol/policy/matcher" "github.com/juanfont/headscale/hscontrol/policy/policyutil" "github.com/juanfont/headscale/hscontrol/types" + "github.com/puzpuzpuz/xsync/v4" "github.com/rs/zerolog/log" "go4.org/netipx" "tailscale.com/net/tsaddr" @@ -28,7 +29,10 @@ import ( var ErrInvalidTagOwner = errors.New("tag owner is not an Alias") type PolicyManager struct { - mu sync.Mutex + // RWMutex, not Mutex, so concurrent map generation does not serialise on + // reads. The per-node caches are xsync.Maps so a read can fill them without + // taking the write lock. + mu sync.RWMutex pol *Policy users []types.User nodes views.Slice[types.NodeView] @@ -55,7 +59,7 @@ type PolicyManager struct { viaTargetTags map[Tag]struct{} // Lazy map of SSH policies - sshPolicyMap map[types.NodeID]*tailcfg.SSHPolicy + sshPolicyMap *xsync.Map[types.NodeID, *tailcfg.SSHPolicy] // compiledGrants are the grants with sources pre-resolved. // The single source of truth for filter compilation. Both @@ -64,12 +68,12 @@ type PolicyManager struct { userNodeIdx userNodeIndex // Lazy map of per-node filter rules (reduced, for packet filters) - filterRulesMap map[types.NodeID][]tailcfg.FilterRule + filterRulesMap *xsync.Map[types.NodeID, []tailcfg.FilterRule] // Lazy map of per-node matchers derived from UNREDUCED filter // rules. Only populated on the slow path when needsPerNodeFilter // is true; the fast path returns pm.matchers directly. - matchersForNodeMap map[types.NodeID][]matcher.Match + matchersForNodeMap *xsync.Map[types.NodeID, []matcher.Match] // needsPerNodeFilter is true when any compiled grant requires // per-node work (autogroup:self or via grants). @@ -197,9 +201,9 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node pol: policy, users: users, nodes: nodes, - sshPolicyMap: make(map[types.NodeID]*tailcfg.SSHPolicy, nodes.Len()), - filterRulesMap: make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()), - matchersForNodeMap: make(map[types.NodeID][]matcher.Match, nodes.Len()), + sshPolicyMap: xsync.NewMap[types.NodeID, *tailcfg.SSHPolicy](), + filterRulesMap: xsync.NewMap[types.NodeID, []tailcfg.FilterRule](), + matchersForNodeMap: xsync.NewMap[types.NodeID, []matcher.Match](), } _, err = pm.updateLocked() @@ -354,9 +358,9 @@ func (pm *PolicyManager) updateLocked() (bool, error) { // TODO(kradalby): This could potentially be optimized by only clearing the // policies for nodes that have changed. Particularly if the only difference is // that nodes has been added or removed. - clear(pm.sshPolicyMap) - clear(pm.filterRulesMap) - clear(pm.matchersForNodeMap) + pm.sshPolicyMap.Clear() + pm.filterRulesMap.Clear() + pm.matchersForNodeMap.Clear() } // If nothing changed, no need to update nodes @@ -400,8 +404,8 @@ func (pm *PolicyManager) NodeNeedsPeerRecompute(node types.NodeView) bool { return true } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() if pm.relayTargetIPs != nil && node.InIPSet(pm.relayTargetIPs) { return true @@ -422,10 +426,10 @@ func (pm *PolicyManager) NodeNeedsPeerRecompute(node types.NodeView) bool { // /machine/ssh/action/{src}/to/{dst}?local_user={local_user} per the // SaaS wire format. Cache is invalidated on policy reload. func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcfg.SSHPolicy, error) { - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() - if sshPol, ok := pm.sshPolicyMap[node.ID()]; ok { + if sshPol, ok := pm.sshPolicyMap.Load(node.ID()); ok { return sshPol, nil } @@ -434,7 +438,7 @@ func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcf return nil, fmt.Errorf("compiling SSH policy: %w", err) } - pm.sshPolicyMap[node.ID()] = sshPol + pm.sshPolicyMap.Store(node.ID(), sshPol) return sshPol, nil } @@ -450,8 +454,8 @@ func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcf func (pm *PolicyManager) SSHCheckParams( srcNodeID, dstNodeID types.NodeID, ) (time.Duration, bool) { - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() if pm.pol == nil || len(pm.pol.SSHs) == 0 { return 0, false @@ -584,8 +588,8 @@ func (pm *PolicyManager) Filter() ([]tailcfg.FilterRule, []matcher.Match) { return nil, nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() return pm.filter, pm.matchers } @@ -604,8 +608,8 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ return nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() // Precompute each node's subnet routes and exit-node status once; the // O(n^2) pair scans below would otherwise recompute them for every pair. @@ -726,7 +730,7 @@ func (pm *PolicyManager) filterForNodeLocked( return nil } - if rules, ok := pm.filterRulesMap[node.ID()]; ok { + if rules, ok := pm.filterRulesMap.Load(node.ID()); ok { return rules } @@ -738,7 +742,7 @@ func (pm *PolicyManager) filterForNodeLocked( } reduced := policyutil.ReduceFilterRules(node, unreduced) - pm.filterRulesMap[node.ID()] = reduced + pm.filterRulesMap.Store(node.ID(), reduced) return reduced } @@ -755,8 +759,8 @@ func (pm *PolicyManager) FilterForNode(node types.NodeView) ([]tailcfg.FilterRul return nil, nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() return pm.filterForNodeLocked(node), nil } @@ -776,8 +780,8 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match, return nil, nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() // For global policies, return the shared global matchers. // Via grants require per-node matchers because the global matchers @@ -786,7 +790,7 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match, return pm.matchers, nil } - if cached, ok := pm.matchersForNodeMap[node.ID()]; ok { + if cached, ok := pm.matchersForNodeMap.Load(node.ID()); ok { return cached, nil } @@ -794,7 +798,7 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match, // the stored compiled grants for this specific node. unreduced := pm.filterRulesForNodeLocked(node) matchers := matcher.MatchesFromFilterRules(unreduced) - pm.matchersForNodeMap[node.ID()] = matchers + pm.matchersForNodeMap.Store(node.ID(), matchers) return matchers, nil } @@ -813,7 +817,7 @@ func (pm *PolicyManager) SetUsers(users []types.User) (bool, error) { // Clear SSH policy map when users change to force SSH policy recomputation // This ensures that if SSH policy compilation previously failed due to missing users, // it will be retried with the new user list - clear(pm.sshPolicyMap) + pm.sshPolicyMap.Clear() changed, err := pm.updateLocked() if err != nil { @@ -822,7 +826,7 @@ func (pm *PolicyManager) SetUsers(users []types.User) (bool, error) { // If SSH policies exist, force a policy change when users are updated // This ensures nodes get updated SSH policies even if other policy hashes didn't change - if pm.pol != nil && pm.pol.SSHs != nil && len(pm.pol.SSHs) > 0 { + if pm.pol != nil && len(pm.pol.SSHs) > 0 { return true, nil } @@ -866,9 +870,9 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro if !needsUpdate { // This ensures fresh filter rules are generated for all nodes - clear(pm.sshPolicyMap) - clear(pm.filterRulesMap) - clear(pm.matchersForNodeMap) + pm.sshPolicyMap.Clear() + pm.filterRulesMap.Clear() + pm.matchersForNodeMap.Clear() } // Always return true when nodes changed, even if filter hash didn't change // (can happen with autogroup:self or when nodes are added but don't affect rules) @@ -878,15 +882,23 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro return false, nil } +// nodeIDViewMap indexes a slice of node views by node ID. On duplicate IDs the +// last view wins, matching the open-coded loops it replaces. +func nodeIDViewMap(s views.Slice[types.NodeView]) map[types.NodeID]types.NodeView { + m := make(map[types.NodeID]types.NodeView, s.Len()) + for _, n := range s.All() { + m[n.ID()] = n + } + + return m +} + func (pm *PolicyManager) nodesHavePolicyAffectingChanges(newNodes views.Slice[types.NodeView]) bool { if pm.nodes.Len() != newNodes.Len() { return true } - oldNodes := make(map[types.NodeID]types.NodeView, pm.nodes.Len()) - for _, node := range pm.nodes.All() { - oldNodes[node.ID()] = node - } + oldNodes := nodeIDViewMap(pm.nodes) for _, newNode := range newNodes.All() { oldNode, exists := oldNodes[newNode.ID()] @@ -921,8 +933,8 @@ func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool { return false } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() // pm.pol is written by SetPolicy under pm.mu; reading it before the // lock races with concurrent policy reloads. @@ -960,6 +972,66 @@ func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool { return false } +// TagOwnedByTags reports whether a credential holding ownerTags is authorised to +// apply tag. It is true when tag is one of ownerTags, or when tag's tagOwners +// chain (tag-to-tag ownership) transitively includes one of ownerTags. This is +// the tag-level check used when an OAuth access token mints an auth key: the +// requested tags must each be owned by the token's tags, so an operator token +// tagged tag:k8s-operator may mint tag:k8s keys when the policy declares +// "tag:k8s": ["tag:k8s-operator"]. It is purely tag-relational and does not +// consult node IPs. +func (pm *PolicyManager) TagOwnedByTags(tag string, ownerTags []string) bool { + if pm == nil { + return false + } + + owns := make(map[string]bool, len(ownerTags)) + for _, t := range ownerTags { + owns[t] = true + } + + // A credential may always apply a tag it directly holds; this needs no policy. + if owns[tag] { + return true + } + + pm.mu.RLock() + defer pm.mu.RUnlock() + + // Owned-by delegation requires the policy's tagOwners. + if pm.pol == nil { + return false + } + + // Walk tag-to-tag ownership transitively, guarding against cycles. + visited := make(map[Tag]bool) + + var walk func(t Tag) bool + + walk = func(t Tag) bool { + if visited[t] { + return false + } + + visited[t] = true + + for _, owner := range pm.pol.TagOwners[t] { + ot, ok := owner.(*Tag) + if !ok { + continue + } + + if owns[string(*ot)] || walk(*ot) { + return true + } + } + + return false + } + + return walk(Tag(tag)) +} + // userMatchesOwner checks if a user matches a tag owner entry. // This is used as a fallback when the node's IP is not in the [PolicyManager.tagOwnerMap]. func (pm *PolicyManager) userMatchesOwner(user types.UserView, owner Owner) bool { @@ -1010,8 +1082,8 @@ func (pm *PolicyManager) TagExists(tag string) bool { return false } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() // pm.pol is written by SetPolicy under pm.mu; reading it before the // lock races with concurrent policy reloads. @@ -1029,8 +1101,8 @@ func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Pr return false } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() // If the route to-be-approved is an exit route, then we need to check // if the node is in allowed to approve it. This is treated differently @@ -1092,8 +1164,8 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via return result } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() // pm.pol is written by SetPolicy under pm.mu; reading it before the // lock races with concurrent policy reloads. @@ -1311,8 +1383,8 @@ func (pm *PolicyManager) DebugString() string { // pm.pol, filter, matchers, and the derived maps are all written // under pm.mu by SetPolicy/SetUsers/SetNodes. - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() var sb strings.Builder @@ -1387,15 +1459,8 @@ func (pm *PolicyManager) DebugString() string { // the entire cache. func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.Slice[types.NodeView]) { // Build maps for efficient lookup - oldNodeMap := make(map[types.NodeID]types.NodeView) - for _, node := range oldNodes.All() { - oldNodeMap[node.ID()] = node - } - - newNodeMap := make(map[types.NodeID]types.NodeView) - for _, node := range newNodes.All() { - newNodeMap[node.ID()] = node - } + oldNodeMap := nodeIDViewMap(oldNodes) + newNodeMap := nodeIDViewMap(newNodes) // Track which users are affected by changes. // Tagged nodes don't participate in autogroup:self (identity is tag-based), @@ -1454,20 +1519,9 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S affectedUsers[newNode.TypedUserID()] = struct{}{} } - // Check if IPs changed (simple check - could be more sophisticated) - oldIPs := oldNode.IPs() - - newIPs := newNode.IPs() - if len(oldIPs) != len(newIPs) { + // Check if IPs changed. + if !slices.Equal(oldNode.IPs(), newNode.IPs()) { affectedUsers[newNode.TypedUserID()] = struct{}{} - } else { - // Check if any IPs are different - for i, oldIP := range oldIPs { - if i >= len(newIPs) || oldIP != newIPs[i] { - affectedUsers[newNode.TypedUserID()] = struct{}{} - break - } - } } } } @@ -1475,63 +1529,41 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S // Clear cache entries for affected users only. // For autogroup:self, we need to clear all nodes belonging to affected users // because autogroup:self rules depend on the entire user's device set. - for nodeID := range pm.filterRulesMap { - // Find the user for this cached node + pm.filterRulesMap.Range(func(nodeID types.NodeID, _ []tailcfg.FilterRule) bool { + // Find the user for this cached node using the already-built indexes. + node, ok := newNodeMap[nodeID] + if !ok { + node, ok = oldNodeMap[nodeID] + } + + // Node not found in either old or new list, clear it. + if !ok { + pm.filterRulesMap.Delete(nodeID) + pm.matchersForNodeMap.Delete(nodeID) + + return true + } + + // Tagged nodes don't participate in autogroup:self, so their cache + // doesn't need user-based invalidation; leave nodeUserID at zero. var nodeUserID types.UserID - - found := false - - // Check in new nodes first - for _, node := range newNodes.All() { - if node.ID() == nodeID { - // Tagged nodes don't participate in autogroup:self, - // so their cache doesn't need user-based invalidation. - if node.IsTagged() { - found = true - break - } - - nodeUserID = node.TypedUserID() - found = true - - break - } + if !node.IsTagged() { + nodeUserID = node.TypedUserID() } - // If not found in new nodes, check old nodes - if !found { - for _, node := range oldNodes.All() { - if node.ID() == nodeID { - if node.IsTagged() { - found = true - break - } - - nodeUserID = node.TypedUserID() - found = true - - break - } - } + // If the owning user is affected, clear this cache entry. + if _, affected := affectedUsers[nodeUserID]; affected { + pm.filterRulesMap.Delete(nodeID) + pm.matchersForNodeMap.Delete(nodeID) } - // If we found the user and they're affected, clear this cache entry - if found { - if _, affected := affectedUsers[nodeUserID]; affected { - delete(pm.filterRulesMap, nodeID) - delete(pm.matchersForNodeMap, nodeID) - } - } else { - // Node not found in either old or new list, clear it - delete(pm.filterRulesMap, nodeID) - delete(pm.matchersForNodeMap, nodeID) - } - } + return true + }) if len(affectedUsers) > 0 { log.Debug(). Int("affected_users", len(affectedUsers)). - Int("remaining_cache_entries", len(pm.filterRulesMap)). + Int("remaining_cache_entries", pm.filterRulesMap.Size()). Msg("Selectively cleared autogroup:self cache for affected users") } } @@ -1553,15 +1585,8 @@ func (pm *PolicyManager) invalidateNodeCache(newNodes views.Slice[types.NodeView // invalidateGlobalPolicyCache invalidates only nodes whose properties affecting // [policyutil.ReduceFilterRules] changed. For global policies, each node's filter is independent. func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.NodeView]) { - oldNodeMap := make(map[types.NodeID]types.NodeView) - for _, node := range pm.nodes.All() { - oldNodeMap[node.ID()] = node - } - - newNodeMap := make(map[types.NodeID]types.NodeView) - for _, node := range newNodes.All() { - newNodeMap[node.ID()] = node - } + oldNodeMap := nodeIDViewMap(pm.nodes) + newNodeMap := nodeIDViewMap(newNodes) // Invalidate nodes whose properties changed for nodeID, newNode := range newNodeMap { @@ -1572,23 +1597,27 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types. } if newNode.HasNetworkChanges(oldNode) { - delete(pm.filterRulesMap, nodeID) - delete(pm.matchersForNodeMap, nodeID) + pm.filterRulesMap.Delete(nodeID) + pm.matchersForNodeMap.Delete(nodeID) } } // Remove deleted nodes from cache - for nodeID := range pm.filterRulesMap { + pm.filterRulesMap.Range(func(nodeID types.NodeID, _ []tailcfg.FilterRule) bool { if _, exists := newNodeMap[nodeID]; !exists { - delete(pm.filterRulesMap, nodeID) + pm.filterRulesMap.Delete(nodeID) } - } - for nodeID := range pm.matchersForNodeMap { + return true + }) + + pm.matchersForNodeMap.Range(func(nodeID types.NodeID, _ []matcher.Match) bool { if _, exists := newNodeMap[nodeID]; !exists { - delete(pm.matchersForNodeMap, nodeID) + pm.matchersForNodeMap.Delete(nodeID) } - } + + return true + }) } // flattenTags resolves nested tag-owner references. Cycles @@ -1770,8 +1799,8 @@ func (pm *PolicyManager) NodeCapMap(id types.NodeID) tailcfg.NodeCapMap { return nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() src := pm.nodeAttrsMap[id] if len(src) == 0 { @@ -1794,8 +1823,8 @@ func (pm *PolicyManager) NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap { return nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() out := make(map[types.NodeID]tailcfg.NodeCapMap, len(pm.nodeAttrsMap)) maps.Copy(out, pm.nodeAttrsMap) diff --git a/hscontrol/policy/v2/policy_concurrency_test.go b/hscontrol/policy/v2/policy_concurrency_test.go new file mode 100644 index 000000000..b4b759f59 --- /dev/null +++ b/hscontrol/policy/v2/policy_concurrency_test.go @@ -0,0 +1,103 @@ +package v2 + +import ( + "fmt" + "sync" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +// TestPolicyManagerConcurrentReads is the correctness guard for the #3346 fix: +// PolicyManager read methods take a shared RLock and populate their per-node +// caches (filterRulesMap, matchersForNodeMap) concurrently. This test hammers +// those reads from many goroutines while a writer mutates the node set, so the +// race detector catches any unsafe access to the shared caches or policy state. +// +// It uses an autogroup:self policy so reads take the per-node filter slow path +// — the same path that made #3346's reconnect storm expensive — which is where +// the lazy caches are written. +func TestPolicyManagerConcurrentReads(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"}, + {Model: gorm.Model{ID: 3}, Name: "user3", Email: "user3@headscale.net"}, + } + + policy := `{ + "acls": [ + { + "action": "accept", + "src": ["autogroup:member"], + "dst": ["autogroup:self:*"] + } + ] + }` + + const nodeCount = 60 + + nodes := make(types.Nodes, 0, nodeCount) + for i := range nodeCount { + n := node( + fmt.Sprintf("node%d", i), + fmt.Sprintf("100.64.0.%d", i+1), + fmt.Sprintf("fd7a:115c:a1e0::%d", i+1), + users[i%len(users)], + ) + n.ID = types.NodeID(i + 1) //nolint:gosec // safe in test + nodes = append(nodes, n) + } + + pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice()) + require.NoError(t, err) + + const ( + readers = 16 + iterations = 60 + mutatorReloads = 30 + ) + + var wg sync.WaitGroup + + // Concurrent readers exercise every converted RLock read path, including + // the two lazily populated per-node caches. Assertions inside the + // goroutines use assert (not require) so a failure does not call + // t.FailNow from a non-test goroutine. + for r := range readers { + wg.Go(func() { + for i := range iterations { + nv := nodes[(r+i)%len(nodes)].View() + + rules, err := pm.FilterForNode(nv) + assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine + assert.NotNil(t, rules) + + _, err = pm.MatchersForNode(nv) + assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine + + pm.Filter() + pm.NodeCapMap(nv.ID()) + + // BuildPeerMap is the O(n^2) writer-side read; exercise it + // under RLock too, but not every iteration. + if i%8 == 0 { + assert.NotNil(t, pm.BuildPeerMap(nodes.ViewSlice())) + } + } + }) + } + + // A writer repeatedly re-sets the node set, invalidating and racing the + // caches the readers are populating. + wg.Go(func() { + for range mutatorReloads { + _, err := pm.SetNodes(nodes.ViewSlice()) + assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine + } + }) + + wg.Wait() +} diff --git a/hscontrol/policy/v2/policy_test.go b/hscontrol/policy/v2/policy_test.go index 9237f33fe..6a0d7ada9 100644 --- a/hscontrol/policy/v2/policy_test.go +++ b/hscontrol/policy/v2/policy_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/juanfont/headscale/hscontrol/policy/matcher" "github.com/juanfont/headscale/hscontrol/types" + "github.com/puzpuzpuz/xsync/v4" "github.com/stretchr/testify/require" "gorm.io/gorm" "tailscale.com/net/tsaddr" @@ -122,7 +123,7 @@ func TestInvalidateAutogroupSelfCache(t *testing.T) { require.NoError(t, err) } - require.Len(t, pm.filterRulesMap, len(initialNodes)) + require.Equal(t, len(initialNodes), pm.filterRulesMap.Size()) tests := []struct { name string @@ -207,19 +208,20 @@ func TestInvalidateAutogroupSelfCache(t *testing.T) { } } - pm.filterRulesMap = make(map[types.NodeID][]tailcfg.FilterRule) + pm.filterRulesMap.Clear() + for _, n := range initialNodes { _, err := pm.FilterForNode(n.View()) require.NoError(t, err) } - initialCacheSize := len(pm.filterRulesMap) + initialCacheSize := pm.filterRulesMap.Size() require.Equal(t, len(initialNodes), initialCacheSize) pm.invalidateAutogroupSelfCache(initialNodes.ViewSlice(), tt.newNodes.ViewSlice()) // Verify the expected number of cache entries were cleared - finalCacheSize := len(pm.filterRulesMap) + finalCacheSize := pm.filterRulesMap.Size() clearedEntries := initialCacheSize - finalCacheSize require.Equal(t, tt.expectedCleared, clearedEntries, tt.description) }) @@ -498,15 +500,19 @@ func TestInvalidateGlobalPolicyCache(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pm := &PolicyManager{ - nodes: tt.oldNodes.ViewSlice(), - filterRulesMap: tt.initialCache, + nodes: tt.oldNodes.ViewSlice(), + filterRulesMap: xsync.NewMap[types.NodeID, []tailcfg.FilterRule](), + matchersForNodeMap: xsync.NewMap[types.NodeID, []matcher.Match](), + } + for id, rules := range tt.initialCache { + pm.filterRulesMap.Store(id, rules) } pm.invalidateGlobalPolicyCache(tt.newNodes.ViewSlice()) // Verify cache state for nodeID, shouldExist := range tt.expectedCacheAfter { - _, exists := pm.filterRulesMap[nodeID] + _, exists := pm.filterRulesMap.Load(nodeID) require.Equal(t, shouldExist, exists, "node %d cache existence mismatch", nodeID) } }) @@ -2470,3 +2476,75 @@ func TestPeerRelayGrantMakesRelayVisible(t *testing.T) { }) } } + +func TestTagOwnedByTags(t *testing.T) { + // tag:leaf is owned by tag:mid, which is owned by tag:root: a tag-to-tag + // delegation chain, the shape an operator token uses to mint narrower keys. + const policy = `{ + "tagOwners": { + "tag:root": [], + "tag:mid": ["tag:root"], + "tag:leaf": ["tag:mid"], + "tag:lone": [] + }, + "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}] + }` + + pm, err := NewPolicyManager([]byte(policy), nil, types.Nodes{}.ViewSlice()) + require.NoError(t, err) + + tests := []struct { + name string + tag string + ownerTags []string + want bool + }{ + { + name: "directly held tag needs no policy", + tag: "tag:lone", + ownerTags: []string{"tag:lone"}, + want: true, + }, + { + name: "one-hop owned-by", + tag: "tag:mid", + ownerTags: []string{"tag:root"}, + want: true, + }, + { + name: "transitive chain root owns leaf", + tag: "tag:leaf", + ownerTags: []string{"tag:root"}, + want: true, + }, + { + name: "owning one link does not grant a sibling", + tag: "tag:lone", + ownerTags: []string{"tag:root"}, + want: false, + }, + { + name: "unowned tag denied", + tag: "tag:leaf", + ownerTags: []string{"tag:unrelated"}, + want: false, + }, + { + name: "empty owners deny a delegated tag", + tag: "tag:leaf", + ownerTags: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, pm.TagOwnedByTags(tt.tag, tt.ownerTags)) + }) + } + + t.Run("nil policy manager denies", func(t *testing.T) { + var nilPM *PolicyManager + require.False(t, nilPM.TagOwnedByTags("tag:leaf", []string{"tag:root"})) + }) +} diff --git a/hscontrol/policy/v2/sshtest.go b/hscontrol/policy/v2/sshtest.go index d2b07a631..89d339c76 100644 --- a/hscontrol/policy/v2/sshtest.go +++ b/hscontrol/policy/v2/sshtest.go @@ -2,6 +2,7 @@ package v2 import ( "fmt" + "maps" "net/netip" "slices" "strings" @@ -92,14 +93,7 @@ func (r SSHPolicyTestResults) Errors() string { } func sortedUsers(m map[string][]string) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - - slices.Sort(keys) - - return keys + return slices.Sorted(maps.Keys(m)) } // displayUser shows an empty username as `""` rather than blank. @@ -128,17 +122,13 @@ func (pm *PolicyManager) RunSSHTests() error { return nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() cache := make(map[types.NodeID]*tailcfg.SSHPolicy) results := runSSHPolicyTests(pm.pol, pm.users, pm.nodes, cache) - if results.AllPassed { - return nil - } - - return fmt.Errorf("%w:\n%s", errSSHPolicyTestsFailed, results.Errors()) + return wrapTestResult(errSSHPolicyTestsFailed, results.AllPassed, results.Errors) } // evaluateSSHTests runs the block against pol without mutating live state. @@ -154,11 +144,7 @@ func evaluateSSHTests( cache := make(map[types.NodeID]*tailcfg.SSHPolicy) results := runSSHPolicyTests(pol, users, nodes, cache) - if results.AllPassed { - return nil - } - - return fmt.Errorf("%w:\n%s", errSSHPolicyTestsFailed, results.Errors()) + return wrapTestResult(errSSHPolicyTestsFailed, results.AllPassed, results.Errors) } // runSSHPolicyTests evaluates every sshTests entry. The cache is keyed @@ -251,28 +237,21 @@ func runSSHPolicyTest( return res } - for _, user := range test.Accept { - evaluateAssertion( - pol, users, nodes, cache, - srcAddrs, dstNodes, user.String(), - assertAccept, &res, - ) - } - - for _, user := range test.Deny { - evaluateAssertion( - pol, users, nodes, cache, - srcAddrs, dstNodes, user.String(), - assertDeny, &res, - ) - } - - for _, user := range test.Check { - evaluateAssertion( - pol, users, nodes, cache, - srcAddrs, dstNodes, user.String(), - assertCheck, &res, - ) + for _, g := range []struct { + users []SSHUser + kind sshAssertion + }{ + {test.Accept, assertAccept}, + {test.Deny, assertDeny}, + {test.Check, assertCheck}, + } { + for _, user := range g.users { + evaluateAssertion( + pol, users, nodes, cache, + srcAddrs, dstNodes, user.String(), + g.kind, &res, + ) + } } return res @@ -409,10 +388,7 @@ func resolveSSHTestSource( return nil, 0, nil } - out := make([]netip.Addr, 0) - for a := range addrs.Iter() { - out = append(out, a) - } + out := slices.Collect(addrs.Iter()) var userID uint diff --git a/hscontrol/policy/v2/tailnet_state_caps.go b/hscontrol/policy/v2/tailnet_state_caps.go index ddb2ac315..9ee11bf60 100644 --- a/hscontrol/policy/v2/tailnet_state_caps.go +++ b/hscontrol/policy/v2/tailnet_state_caps.go @@ -13,6 +13,7 @@ package v2 // one exists. import ( + "maps" "slices" "strings" @@ -170,15 +171,10 @@ func stripUnmodelledTailnetStateCaps(cm tailcfg.NodeCapMap) tailcfg.NodeCapMap { return nil } - out := make(tailcfg.NodeCapMap, len(cm)) - - for k, v := range cm { - if isUnmodelledTailnetStateCap(k) { - continue - } - - out[k] = v - } + out := maps.Clone(cm) + maps.DeleteFunc(out, func(k tailcfg.NodeCapability, _ []tailcfg.RawMessage) bool { + return isUnmodelledTailnetStateCap(k) + }) if len(out) == 0 { return nil diff --git a/hscontrol/policy/v2/test.go b/hscontrol/policy/v2/test.go index 5f1189f2e..da79f7c72 100644 --- a/hscontrol/policy/v2/test.go +++ b/hscontrol/policy/v2/test.go @@ -196,6 +196,17 @@ func (r PolicyTestResults) Errors() string { return strings.Join(lines, "\n") } +// wrapTestResult returns nil when every test passed, otherwise wraps the +// rendered failure breakdown in sentinel. errs is passed uncalled so it is +// only evaluated on the failure path. +func wrapTestResult(sentinel error, allPassed bool, errs func() string) error { + if allPassed { + return nil + } + + return fmt.Errorf("%w:\n%s", sentinel, errs()) +} + // RunTests evaluates the policy's own `tests` block against the live compiled // filter and returns a wrapped error when any test fails. Callers that need // the per-test breakdown can call runPolicyTests directly. @@ -204,15 +215,12 @@ func (pm *PolicyManager) RunTests() error { return nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() results := runPolicyTests(pm.pol, pm.filter, pm.users, pm.nodes) - if results.AllPassed { - return nil - } - return fmt.Errorf("%w:\n%s", errPolicyTestsFailed, results.Errors()) + return wrapTestResult(errPolicyTestsFailed, results.AllPassed, results.Errors) } // evaluateTests runs the `tests` block against a fresh compilation of pol. @@ -233,11 +241,8 @@ func evaluateTests(pol *Policy, users []types.User, nodes views.Slice[types.Node } results := runPolicyTests(pol, filter, users, nodes) - if results.AllPassed { - return nil - } - return fmt.Errorf("%w:\n%s", errPolicyTestsFailed, results.Errors()) + return wrapTestResult(errPolicyTestsFailed, results.AllPassed, results.Errors) } // runPolicyTests is the pure evaluation function: given a policy, the @@ -285,39 +290,28 @@ func runPolicyTest(test PolicyTest, pol *Policy, filter []tailcfg.FilterRule, us return res } - for _, dst := range test.Accept { - allowed, err := evalReachability(srcPrefixes, dst, test.Proto, pol, filter, users, nodes) - if err != nil { - res.Passed = false - res.Errors = append(res.Errors, fmt.Sprintf("error testing %q: %v", dst, err)) + check := func(dsts []string, wantAllowed bool, ok, fail *[]string) { + for _, dst := range dsts { + allowed, err := evalReachability(srcPrefixes, dst, test.Proto, pol, filter, users, nodes) + if err != nil { + res.Passed = false + res.Errors = append(res.Errors, fmt.Sprintf("error testing %q: %v", dst, err)) - continue - } + continue + } - if allowed { - res.AcceptOK = append(res.AcceptOK, dst) - } else { - res.Passed = false - res.AcceptFail = append(res.AcceptFail, dst) + if allowed == wantAllowed { + *ok = append(*ok, dst) + } else { + res.Passed = false + + *fail = append(*fail, dst) + } } } - for _, dst := range test.Deny { - allowed, err := evalReachability(srcPrefixes, dst, test.Proto, pol, filter, users, nodes) - if err != nil { - res.Passed = false - res.Errors = append(res.Errors, fmt.Sprintf("error testing %q: %v", dst, err)) - - continue - } - - if !allowed { - res.DenyOK = append(res.DenyOK, dst) - } else { - res.Passed = false - res.DenyFail = append(res.DenyFail, dst) - } - } + check(test.Accept, true, &res.AcceptOK, &res.AcceptFail) + check(test.Deny, false, &res.DenyOK, &res.DenyFail) return res } diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 30b06ca13..50a89f8c1 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -20,6 +20,7 @@ import ( "tailscale.com/tailcfg" "tailscale.com/types/views" "tailscale.com/util/multierr" + "tailscale.com/util/set" "tailscale.com/util/slicesx" ) @@ -62,7 +63,7 @@ var ( // SaaS imposes no minimum (0s is accepted) so headscale matches. const ( SSHCheckPeriodDefault = 12 * time.Hour - SSHCheckPeriodMax = 168 * time.Hour + SSHCheckPeriodMax = 7 * 24 * time.Hour ) // ACL validation errors. @@ -104,9 +105,6 @@ var nodeAttrUnsupportedCaps = map[tailcfg.NodeCapability]string{ // Policy validation errors. var ( - ErrUnknownAliasType = errors.New("unknown alias type") - ErrUnknownAutoApprover = errors.New("unknown auto approver type") - ErrUnknownOwnerType = errors.New("unknown owner type") ErrInvalidUsername = errors.New("username must contain @") ErrUserNotFound = errors.New("user not found") ErrMultipleUsersFound = errors.New("multiple users found") @@ -144,8 +142,6 @@ var ( ErrHostNotDefined = errors.New("host not defined in policy") ErrSSHSourceAliasNotSupported = errors.New("alias not supported for SSH source") ErrSSHDestAliasNotSupported = errors.New("alias not supported for SSH destination") - ErrUnknownSSHDestAlias = errors.New("unknown SSH destination alias type") - ErrUnknownSSHSrcAlias = errors.New("unknown SSH source alias type") ErrUnknownField = errors.New("unknown field") ErrProtocolNoSpecificPorts = errors.New("protocol does not support specific ports") ErrTestEmptyAssertions = errors.New("test entry must have at least one of \"accept\" or \"deny\"") @@ -262,26 +258,7 @@ func (a AliasWithPorts) MarshalJSON() ([]byte, error) { return []byte(`""`), nil } - var alias string - - switch v := a.Alias.(type) { - case *Username: - alias = string(*v) - case *Group: - alias = string(*v) - case *Tag: - alias = string(*v) - case *Host: - alias = string(*v) - case *Prefix: - alias = v.String() - case *AutoGroup: - alias = string(*v) - case Asterix: - alias = "*" - default: - return nil, fmt.Errorf("%w: %T", ErrUnknownAliasType, v) - } + alias := a.String() // If no ports are specified if len(a.Ports) == 0 { @@ -341,15 +318,15 @@ func (a Asterix) resolve(_ *Policy, _ types.Users, _ views.Slice[types.NodeView] // IPSet merges overlapping ranges (e.g. 10.0.0.0/8 absorbs // 10.33.0.0/16), but Tailscale preserves individual route entries. func approvedSubnetRoutes(nodes views.Slice[types.NodeView]) []string { - seen := make(map[string]bool) + seen := make(set.Set[string]) var routes []string for _, node := range nodes.All() { for _, route := range node.SubnetRoutes() { s := route.String() - if !seen[s] { - seen[s] = true + if !seen.Contains(s) { + seen.Add(s) routes = append(routes, s) } } @@ -1132,24 +1109,7 @@ func (a *Aliases) MarshalJSON() ([]byte, error) { aliases := make([]string, len(*a)) for i, alias := range *a { - switch v := alias.(type) { - case *Username: - aliases[i] = string(*v) - case *Group: - aliases[i] = string(*v) - case *Tag: - aliases[i] = string(*v) - case *Host: - aliases[i] = string(*v) - case *Prefix: - aliases[i] = v.String() - case *AutoGroup: - aliases[i] = string(*v) - case Asterix: - aliases[i] = "*" - default: - return nil, fmt.Errorf("%w: %T", ErrUnknownAliasType, v) - } + aliases[i] = alias.String() } return json.Marshal(aliases) @@ -1226,16 +1186,7 @@ func (aa AutoApprovers) MarshalJSON() ([]byte, error) { approvers := make([]string, len(aa)) for i, approver := range aa { - switch v := approver.(type) { - case *Username: - approvers[i] = string(*v) - case *Tag: - approvers[i] = string(*v) - case *Group: - approvers[i] = string(*v) - default: - return nil, fmt.Errorf("%w: %T", ErrUnknownAutoApprover, v) - } + approvers[i] = approver.String() } return json.Marshal(approvers) @@ -1320,16 +1271,7 @@ func (o Owners) MarshalJSON() ([]byte, error) { owners := make([]string, len(o)) for i, owner := range o { - switch v := owner.(type) { - case *Username: - owners[i] = string(*v) - case *Group: - owners[i] = string(*v) - case *Tag: - owners[i] = string(*v) - default: - return nil, fmt.Errorf("%w: %T", ErrUnknownOwnerType, v) - } + owners[i] = owner.String() } return json.Marshal(owners) @@ -1358,10 +1300,8 @@ func (g *Groups) Contains(group *Group) error { return nil } - for defined := range map[Group]Usernames(*g) { - if defined == *group { - return nil - } + if _, ok := (*g)[*group]; ok { + return nil } return fmt.Errorf("%w: %q", ErrGroupNotDefined, group) @@ -1524,16 +1464,7 @@ func (to TagOwners) MarshalJSON() ([]byte, error) { ownerStrs := make([]string, len(owners)) for i, owner := range owners { - switch v := owner.(type) { - case *Username: - ownerStrs[i] = string(*v) - case *Group: - ownerStrs[i] = string(*v) - case *Tag: - ownerStrs[i] = string(*v) - default: - return nil, fmt.Errorf("%w: %T", ErrUnknownOwnerType, v) - } + ownerStrs[i] = owner.String() } rawTagOwners[tagStr] = ownerStrs @@ -1550,10 +1481,8 @@ func (to TagOwners) Contains(tagOwner *Tag) error { return nil } - for defined := range map[Tag]Owners(to) { - if defined == *tagOwner { - return nil - } + if _, ok := to[*tagOwner]; ok { + return nil } return fmt.Errorf("%w: %q", ErrTagNotDefined, tagOwner) @@ -1747,44 +1676,6 @@ func (p *Protocol) String() string { return string(*p) } -// Description returns the human-readable description of the [Protocol]. -func (p *Protocol) Description() string { - switch *p { - case ProtocolNameICMP: - return "Internet Control Message Protocol" - case ProtocolNameIGMP: - return "Internet Group Management Protocol" - case ProtocolNameIPv4: - return "IPv4 encapsulation" - case ProtocolNameTCP: - return "Transmission Control Protocol" - case ProtocolNameEGP: - return "Exterior Gateway Protocol" - case ProtocolNameIGP: - return "Interior Gateway Protocol" - case ProtocolNameUDP: - return "User Datagram Protocol" - case ProtocolNameGRE: - return "Generic Routing Encapsulation" - case ProtocolNameESP: - return "Encapsulating Security Payload" - case ProtocolNameAH: - return "Authentication Header" - case ProtocolNameIPv6ICMP: - return "Internet Control Message Protocol for IPv6" - case ProtocolNameSCTP: - return "Stream Control Transmission Protocol" - case ProtocolNameFC: - return "Fibre Channel" - case ProtocolNameIPInIP: - return "IP-in-IP Encapsulation" - case ProtocolNameWildcard: - return "Wildcard (not supported - use specific protocol)" - default: - return "Unknown Protocol" - } -} - // toIANAProtocolNumbers converts a [Protocol] to its IANA protocol numbers. // Since validation happens during [Protocol.UnmarshalJSON], this method should not fail for valid [Protocol] values. func (p *Protocol) toIANAProtocolNumbers() []int { @@ -2380,6 +2271,10 @@ var tailscaleCapAllowlist = map[tailcfg.PeerCapability]bool{ tailcfg.PeerCapabilityWebUI: true, // tailscale.com/cap/webui tailcfg.PeerCapabilityKubernetes: true, // tailscale.com/cap/kubernetes tailcfg.PeerCapabilityTsIDP: true, // tailscale.com/cap/tsidp + + // tailscale.com/cap/secrets is the capability used by setec + // (github.com/tailscale/setec); allow it so it can be granted via policy. + tailcfg.PeerCapability("tailscale.com/cap/secrets"): true, } // validateGrantSrcDstCombination validates [Grant]-specific source/destination @@ -3058,22 +2953,9 @@ func (a SSHDstAliases) MarshalJSON() ([]byte, error) { aliases := make([]string, len(a)) for i, alias := range a { - switch v := alias.(type) { - case *Username: - aliases[i] = string(*v) - case *Tag: - aliases[i] = string(*v) - case *AutoGroup: - aliases[i] = string(*v) - case *Host: - aliases[i] = string(*v) - case Asterix: - // Marshal wildcard as "*" so it gets rejected during unmarshal - // with a proper error message explaining alternatives - aliases[i] = "*" - default: - return nil, fmt.Errorf("%w: %T", ErrUnknownSSHDestAlias, v) - } + // A wildcard renders as "*" so it gets rejected during unmarshal + // with a proper error message explaining alternatives. + aliases[i] = alias.String() } return json.Marshal(aliases) @@ -3087,20 +2969,7 @@ func (a *SSHSrcAliases) MarshalJSON() ([]byte, error) { aliases := make([]string, len(*a)) for i, alias := range *a { - switch v := alias.(type) { - case *Username: - aliases[i] = string(*v) - case *Group: - aliases[i] = string(*v) - case *Tag: - aliases[i] = string(*v) - case *AutoGroup: - aliases[i] = string(*v) - case Asterix: - aliases[i] = "*" - default: - return nil, fmt.Errorf("%w: %T", ErrUnknownSSHSrcAlias, v) - } + aliases[i] = alias.String() } return json.Marshal(aliases) diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go index 1a046f126..19134e5b3 100644 --- a/hscontrol/policy/v2/types_test.go +++ b/hscontrol/policy/v2/types_test.go @@ -6244,3 +6244,27 @@ func TestUnmarshalPolicySSHTests(t *testing.T) { }) } } + +func TestValidateCapabilityName(t *testing.T) { + tests := []struct { + name string + cap string + wantErr error + }{ + {name: "custom domain allowed", cap: "example.com/cap/foo", wantErr: nil}, + {name: "allowlisted tailscale cap", cap: "tailscale.com/cap/drive", wantErr: nil}, + {name: "setec secrets cap allowed", cap: "tailscale.com/cap/secrets", wantErr: nil}, + {name: "non-allowlisted tailscale cap rejected", cap: "tailscale.com/cap/nope", wantErr: ErrCapNameTailscaleDomain}, + {name: "url scheme rejected", cap: "https://tailscale.com/cap/drive", wantErr: ErrCapNameInvalidForm}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateCapabilityName(tt.cap) + if tt.wantErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tt.wantErr) + } + }) + } +} diff --git a/hscontrol/policy/v2/utils_test.go b/hscontrol/policy/v2/utils_test.go index 393a3b1fa..3a02bc660 100644 --- a/hscontrol/policy/v2/utils_test.go +++ b/hscontrol/policy/v2/utils_test.go @@ -8,7 +8,7 @@ import ( "tailscale.com/tailcfg" ) -// TestParseDestinationAndPort tests the splitDestinationAndPort function using table-driven tests. +// TestSplitDestinationAndPort tests the splitDestinationAndPort function using table-driven tests. func TestSplitDestinationAndPort(t *testing.T) { testCases := []struct { input string diff --git a/hscontrol/poll.go b/hscontrol/poll.go index 96f782d44..35657026c 100644 --- a/hscontrol/poll.go +++ b/hscontrol/poll.go @@ -230,6 +230,11 @@ func (m *mapSession) serveLongPoll() { mapReqChange, err := m.h.state.UpdateNodeFromMapRequest(m.node.ID, m.req) if err != nil { m.log.Error().Caller().Err(err).Msg("failed to update node from initial MapRequest") + // Write an explicit error rather than returning silently: a bare + // return leaves net/http to send an empty 200, which the client + // reads as "unexpected EOF" and retries forever (issue #3346). + httpError(m.w, err) + return } @@ -252,6 +257,11 @@ func (m *mapSession) serveLongPoll() { // time between the node connecting and the batcher being ready. if err := m.h.mapBatcher.AddNode(m.node.ID, m.ch, m.capVer, m.stopFromBatcher); err != nil { //nolint:noinlineerr m.log.Error().Caller().Err(err).Msg("failed to add node to batcher") + // Write an explicit error rather than returning silently: a bare + // return leaves net/http to send an empty 200, which the client + // reads as "unexpected EOF" and retries forever (issue #3346). + httpError(m.w, err) + return } diff --git a/hscontrol/poll_test.go b/hscontrol/poll_test.go index e294d5e71..71ce08d9a 100644 --- a/hscontrol/poll_test.go +++ b/hscontrol/poll_test.go @@ -7,8 +7,10 @@ import ( "testing" "time" + "github.com/juanfont/headscale/hscontrol/db" "github.com/juanfont/headscale/hscontrol/mapper" "github.com/juanfont/headscale/hscontrol/state" + "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types/change" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -89,6 +91,131 @@ func (w *delayedSuccessResponseWriter) WriteCount() int { return w.writeCount } +// recordingResponseWriter records the status code and whether anything was +// written, so a test can tell an explicit error response apart from a handler +// that returned without writing (which net/http turns into an empty 200 the +// client reads as "unexpected EOF"). +type recordingResponseWriter struct { + mu sync.Mutex + header http.Header + status int + writes int +} + +func (w *recordingResponseWriter) Header() http.Header { + if w.header == nil { + w.header = make(http.Header) + } + + return w.header +} + +func (w *recordingResponseWriter) WriteHeader(code int) { + w.mu.Lock() + defer w.mu.Unlock() + + if w.status == 0 { + w.status = code + } +} + +func (w *recordingResponseWriter) Write(data []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + + if w.status == 0 { + w.status = http.StatusOK + } + + w.writes++ + + return len(data), nil +} + +func (w *recordingResponseWriter) Flush() {} + +func (w *recordingResponseWriter) statusCode() int { + w.mu.Lock() + defer w.mu.Unlock() + + return w.status +} + +// TestServeLongPollWritesErrorWhenInitialMapFails proves that when the initial +// map cannot be generated (here: the node's own GivenName is invalid, so +// WithSelfNode fails and AddNode errors), serveLongPoll writes an explicit HTTP +// error instead of returning with no body. Returning empty leaves net/http to +// send an empty 200, which the Tailscale client reports as +// "PollNetMap: ... unexpected EOF" and retries forever (issue #3346). +func TestServeLongPollWritesErrorWhenInitialMapFails(t *testing.T) { + app := createTestApp(t) + user := app.state.CreateUserForTest("self-bad-name-user") + createdNode := app.state.CreateRegisteredNodeForTest(user, "self-bad-name-node") + + // Corrupt the node's stored name to empty so GetFQDN fails for itself, + // then reload state so the bad row enters the NodeStore verbatim. + app.mapBatcher.Close() + require.NoError(t, app.state.Close()) + + database, err := db.NewHeadscaleDatabase(app.cfg) + require.NoError(t, err) + require.NoError(t, database.DB. + Model(&types.Node{}). + Where("id = ?", createdNode.ID). + Update("given_name", "").Error) + require.NoError(t, database.Close()) + + app.state, err = state.NewState(app.cfg) + require.NoError(t, err) + + app.mapBatcher = mapper.NewBatcherAndMapper(app.cfg, app.state) + app.mapBatcher.Start() + + t.Cleanup(func() { + app.mapBatcher.Close() + require.NoError(t, app.state.Close()) + }) + + nodeView, ok := app.state.GetNodeByID(createdNode.ID) + require.True(t, ok) + + node := nodeView.AsStruct() + + ctx, cancel := context.WithCancel(context.Background()) + writer := &recordingResponseWriter{} + session := app.newMapSession(ctx, tailcfg.MapRequest{ + Stream: true, + Version: tailcfg.CapabilityVersion(100), + }, writer, node) + + serveDone := make(chan struct{}) + + go func() { + session.serveLongPoll() + close(serveDone) + }() + + t.Cleanup(func() { + // Break the post-disconnect reconnect wait so the goroutine exits. + dummyCh := make(chan *tailcfg.MapResponse, 1) + _ = app.mapBatcher.AddNode(node.ID, dummyCh, tailcfg.CapabilityVersion(100), nil) + + cancel() + + select { + case <-serveDone: + case <-time.After(2 * time.Second): + } + + _ = app.mapBatcher.RemoveNode(node.ID, dummyCh) + }) + + assert.Eventually(t, func() bool { + return writer.statusCode() >= http.StatusInternalServerError + }, 2*time.Second, 10*time.Millisecond, + "serveLongPoll must write an HTTP error response when the initial map cannot be built, not an empty 200") +} + // TestGitHubIssue3129_TransientlyBlockedWriteDoesNotLeaveLiveStaleSession // tests the scenario reported in // https://github.com/juanfont/headscale/issues/3129. diff --git a/hscontrol/realip.go b/hscontrol/realip.go index 6cfd1a1a0..af41aa7bd 100644 --- a/hscontrol/realip.go +++ b/hscontrol/realip.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "net/netip" + "slices" realclientip "github.com/realclientip/realclientip-go" ) @@ -77,13 +78,9 @@ func peerTrusted(remoteAddr string, trusted []netip.Prefix) bool { return false } - for _, p := range trusted { - if p.Contains(addr) { - return true - } - } - - return false + return slices.ContainsFunc(trusted, func(p netip.Prefix) bool { + return p.Contains(addr) + }) } func parsePeerAddr(remoteAddr string) (netip.Addr, bool) { diff --git a/hscontrol/scope/scope.go b/hscontrol/scope/scope.go new file mode 100644 index 000000000..1a1a0a6d0 --- /dev/null +++ b/hscontrol/scope/scope.go @@ -0,0 +1,123 @@ +// Package scope models the OAuth capability scopes the Headscale v2 API enforces +// and the rule for whether a granted set of scopes satisfies a required one. +// +// The vocabulary is taken from Tailscale's OpenAPI spec (the same scope names +// the Terraform provider and Kubernetes operator request), so a client written +// against Tailscale's scopes works unchanged against Headscale. The grant +// predicate is kept here, separate from the HTTP/huma layer in +// hscontrol/api/v2, so it can be tested exhaustively on its own. +package scope + +import "strings" + +// Scope is an OAuth capability an operation requires and a token grants. The names +// mirror Tailscale's API scopes; a "...:read" scope is the read-only subset of its +// write scope. +type Scope string + +const ( + // All and AllRead are Tailscale's forward-compatible super-scopes: "all" + // grants every other scope, "all:read" grants every :read subset. + All Scope = "all" + AllRead Scope = "all:read" + + AuthKeys Scope = "auth_keys" + AuthKeysRead Scope = "auth_keys:read" + + // OAuthKeys gates managing OAuth clients (keyType:"client" on the keys + // resource). + OAuthKeys Scope = "oauth_keys" + OAuthKeysRead Scope = "oauth_keys:read" + + DevicesCore Scope = "devices:core" + DevicesCoreRead Scope = "devices:core:read" + + DevicesRoutes Scope = "devices:routes" + DevicesRoutesRead Scope = "devices:routes:read" + + PolicyFile Scope = "policy_file" + PolicyFileRead Scope = "policy_file:read" + + FeatureSettings Scope = "feature_settings" + FeatureSettingsRead Scope = "feature_settings:read" + + Users Scope = "users" + UsersRead Scope = "users:read" +) + +const readSuffix = ":read" + +// Known returns every scope in the vocabulary, in a stable order. Useful for +// exhaustive iteration in tests and documentation. +func Known() []Scope { + return []Scope{ + All, AllRead, + AuthKeys, AuthKeysRead, + OAuthKeys, OAuthKeysRead, + DevicesCore, DevicesCoreRead, + DevicesRoutes, DevicesRoutesRead, + PolicyFile, PolicyFileRead, + FeatureSettings, FeatureSettingsRead, + Users, UsersRead, + } +} + +// IsRead reports whether s is a read-only scope (its name ends with ":read"). +func (s Scope) IsRead() bool { + return strings.HasSuffix(string(s), readSuffix) +} + +// IsWrite reports whether s is a non-empty write scope. +func (s Scope) IsWrite() bool { + return s != "" && !s.IsRead() +} + +// Parse converts scope strings (as stored on a token or client) into Scope values. +// Unknown strings are kept as-is; they simply never satisfy any required scope. +func Parse(ss []string) []Scope { + out := make([]Scope, len(ss)) + for i, s := range ss { + out[i] = Scope(s) + } + + return out +} + +// Grants reports whether the granted scopes satisfy the required want scope. +func Grants(granted []Scope, want Scope) bool { + for _, g := range granted { + if satisfies(g, want) { + return true + } + } + + return false +} + +// satisfies reports whether a single held scope satisfies want: exact match; a +// write scope grants its own :read subset; "all" grants everything; "all:read" +// grants any :read scope. +func satisfies(have, want Scope) bool { + if have == want || have == All { + return true + } + + if have == AllRead { + return want.IsRead() + } + + // A write scope grants its own read subset, e.g. auth_keys ⊇ auth_keys:read. + return string(want) == string(have)+readSuffix +} + +// RequiresTags reports whether any scope obliges a credential to carry tags: +// devices:core and auth_keys mint tagged, tailnet-owned credentials. +func RequiresTags(scopes []Scope) bool { + for _, s := range scopes { + if s == DevicesCore || s == AuthKeys { + return true + } + } + + return false +} diff --git a/hscontrol/scope/scope_property_test.go b/hscontrol/scope/scope_property_test.go new file mode 100644 index 000000000..1132ba513 --- /dev/null +++ b/hscontrol/scope/scope_property_test.go @@ -0,0 +1,90 @@ +package scope + +import ( + "slices" + "testing" + + "pgregory.net/rapid" +) + +// scopeGen draws a scope: mostly from the real vocabulary, sometimes adversarial +// junk (including read-like junk such as "foo:read") so the rules are exercised +// against unknown input too. +func scopeGen() *rapid.Generator[Scope] { + known := Known() + + return rapid.Custom(func(t *rapid.T) Scope { + if rapid.Float64().Draw(t, "junkP") < 0.2 { + return Scope(rapid.StringMatching(`[a-z_]{1,12}(:read)?`).Draw(t, "junk")) + } + + return rapid.SampledFrom(known).Draw(t, "vocab") + }) +} + +// TestGrantsMatchesOracle fuzzes Grants against the independent oracle over random +// granted-sets and want-scopes (vocabulary + junk). +func TestGrantsMatchesOracle(t *testing.T) { + rapid.Check(t, func(rt *rapid.T) { + granted := rapid.SliceOfN(scopeGen(), 0, 6).Draw(rt, "granted") + want := scopeGen().Draw(rt, "want") + + if got, exp := Grants(granted, want), oracleGrants(granted, want); got != exp { + rt.Fatalf("Grants(%v, %q) = %v, oracle = %v", granted, want, got, exp) + } + }) +} + +// TestGrantsInvariants asserts the algebraic properties of the grant relation hold +// for arbitrary inputs. +func TestGrantsInvariants(t *testing.T) { + rapid.Check(t, func(rt *rapid.T) { + granted := rapid.SliceOfN(scopeGen(), 0, 6).Draw(rt, "granted") + want := scopeGen().Draw(rt, "want") + + // Reflexivity: a scope always grants itself. + if !Grants([]Scope{want}, want) { + rt.Fatalf("reflexivity: %q does not grant itself", want) + } + + // The empty set grants nothing. + if Grants(nil, want) { + rt.Fatalf("empty grant satisfied %q", want) + } + + // OR-semantics: a set grants iff some member does. + anyMember := slices.ContainsFunc(granted, func(g Scope) bool { + return Grants([]Scope{g}, want) + }) + if Grants(granted, want) != anyMember { + rt.Fatalf("OR-semantics broken for %v / %q", granted, want) + } + + // Monotonicity: adding a scope never withdraws a grant. + before := Grants(granted, want) + extra := scopeGen().Draw(rt, "extra") + after := Grants(append(slices.Clone(granted), extra), want) + + if before && !after { + rt.Fatalf("monotonicity broken: adding %q withdrew the grant of %q", extra, want) + } + }) +} + +// TestSuperScopeProperties fuzzes the super-scope rules. +func TestSuperScopeProperties(t *testing.T) { + rapid.Check(t, func(rt *rapid.T) { + want := scopeGen().Draw(rt, "want") + + // all grants everything. + if !Grants([]Scope{All}, want) { + rt.Fatalf("all did not grant %q", want) + } + + // all:read grants exactly the read scopes. + if Grants([]Scope{AllRead}, want) != want.IsRead() { + rt.Fatalf("all:read grant of %q = %v, want IsRead = %v", + want, Grants([]Scope{AllRead}, want), want.IsRead()) + } + }) +} diff --git a/hscontrol/scope/scope_test.go b/hscontrol/scope/scope_test.go new file mode 100644 index 000000000..0b59aa42a --- /dev/null +++ b/hscontrol/scope/scope_test.go @@ -0,0 +1,234 @@ +package scope + +import ( + "fmt" + "strings" + "testing" +) + +// classify decomposes a scope into (resource, super, read) WITHOUT reusing any of +// the production logic, so the oracle below is an independent second +// implementation of the grant rule: a divergence between it and Grants is a real +// bug in one of them, not a tautology. +type classified struct { + resource string // "" for super-scopes; otherwise the write-scope base (e.g. "auth_keys") + super bool // all / all:read + read bool // the :read variant +} + +func classify(s Scope) classified { + str := string(s) + read := strings.HasSuffix(str, ":read") + base := strings.TrimSuffix(str, ":read") + + if base == "all" { + return classified{super: true, read: read} + } + + return classified{resource: base, read: read} +} + +// oracle re-derives "does have satisfy want" from the classification, independent +// of satisfies/Grants. +func oracle(have, want Scope) bool { + h, w := classify(have), classify(want) + + if h.super { + // "all" grants everything; "all:read" grants only reads. + return !h.read || w.read + } + + if h.resource != w.resource { + return false + } + + // Same resource: a write scope grants both read and write; a read scope grants + // only read. + return !h.read || w.read +} + +func oracleGrants(granted []Scope, want Scope) bool { + for _, g := range granted { + if oracle(g, want) { + return true + } + } + + return false +} + +// TestGrantsHandPicked pins specific (granted, want) outcomes with literal +// expected values, independent of any oracle: the anchor for the rules. +func TestGrantsHandPicked(t *testing.T) { + tests := []struct { + granted []Scope + want Scope + ok bool + }{ + {granted: []Scope{AuthKeys}, want: AuthKeys, ok: true}, + {granted: []Scope{AuthKeys}, want: AuthKeysRead, ok: true}, + {granted: []Scope{AuthKeysRead}, want: AuthKeys, ok: false}, + {granted: []Scope{AuthKeysRead}, want: AuthKeysRead, ok: true}, + {granted: []Scope{DevicesCore}, want: AuthKeys, ok: false}, + {granted: []Scope{DevicesCoreRead}, want: AuthKeysRead, ok: false}, + {granted: []Scope{All}, want: AuthKeys, ok: true}, + {granted: []Scope{All}, want: FeatureSettingsRead, ok: true}, + {granted: []Scope{AllRead}, want: PolicyFileRead, ok: true}, + {granted: []Scope{AllRead}, want: PolicyFile, ok: false}, + {granted: []Scope{AllRead}, want: All, ok: false}, + {granted: []Scope{DevicesCore, OAuthKeys}, want: OAuthKeys, ok: true}, + {granted: nil, want: AuthKeysRead, ok: false}, + {granted: []Scope{"garbage"}, want: AuthKeys, ok: false}, + {granted: []Scope{"garbage"}, want: "garbage", ok: true}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v_%s", tt.granted, tt.want), func(t *testing.T) { + if got := Grants(tt.granted, tt.want); got != tt.ok { + t.Errorf("Grants(%v, %q) = %v, want %v", tt.granted, tt.want, got, tt.ok) + } + }) + } +} + +// TestGrantsExhaustive checks every single-grant pair in the vocabulary against +// the independent oracle, plus representative multi-grant cases. +func TestGrantsExhaustive(t *testing.T) { + known := Known() + + for _, g := range known { + for _, w := range known { + got := Grants([]Scope{g}, w) + exp := oracle(g, w) + + if got != exp { + t.Errorf("Grants([%q], %q) = %v, oracle = %v", g, w, got, exp) + } + } + } + + multi := [][]Scope{ + {All, AuthKeysRead}, + {AllRead, AuthKeys}, + {AuthKeys, OAuthKeysRead}, + {DevicesCore, DevicesRoutes, PolicyFile}, + {AuthKeys, AuthKeys}, // duplicates + } + + for _, granted := range multi { + for _, w := range known { + got := Grants(granted, w) + exp := oracleGrants(granted, w) + + if got != exp { + t.Errorf("Grants(%v, %q) = %v, oracle = %v", granted, w, got, exp) + } + } + } +} + +// TestWriteGrantsItsRead and friends assert the structural rules over the whole +// vocabulary, deterministically. +func TestWriteGrantsItsRead(t *testing.T) { + for _, s := range Known() { + if !s.IsWrite() { + continue + } + + read := Scope(string(s) + ":read") + if !Grants([]Scope{s}, read) { + t.Errorf("write scope %q does not grant its read subset %q", s, read) + } + } +} + +func TestReadNeverGrantsWrite(t *testing.T) { + for _, s := range Known() { + if !s.IsRead() { + continue + } + + write := Scope(strings.TrimSuffix(string(s), ":read")) + if Grants([]Scope{s}, write) { + t.Errorf("read scope %q must not grant write scope %q", s, write) + } + } +} + +func TestAllGrantsEverything(t *testing.T) { + for _, w := range Known() { + if !Grants([]Scope{All}, w) { + t.Errorf("all should grant %q", w) + } + } +} + +func TestAllReadGrantsReadsOnly(t *testing.T) { + for _, w := range Known() { + got := Grants([]Scope{AllRead}, w) + if got != w.IsRead() { + t.Errorf("all:read grants %q = %v, want %v (IsRead)", w, got, w.IsRead()) + } + } +} + +// TestResourceIsolation: a non-super scope never grants a scope of a different +// resource. +func TestResourceIsolation(t *testing.T) { + for _, a := range Known() { + if a == All || a == AllRead { + continue + } + + for _, b := range Known() { + if classify(a).resource == classify(b).resource { + continue + } + + if Grants([]Scope{a}, b) { + t.Errorf("scope %q (resource %q) must not grant %q (resource %q)", + a, classify(a).resource, b, classify(b).resource) + } + } + } +} + +func TestRequiresTags(t *testing.T) { + tests := []struct { + scopes []Scope + requires bool + }{ + {scopes: []Scope{DevicesCore}, requires: true}, + {scopes: []Scope{AuthKeys}, requires: true}, + {scopes: []Scope{OAuthKeys}, requires: false}, + {scopes: []Scope{PolicyFile, AuthKeys}, requires: true}, + {scopes: []Scope{DevicesCoreRead}, requires: false}, + {scopes: nil, requires: false}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v", tt.scopes), func(t *testing.T) { + if got := RequiresTags(tt.scopes); got != tt.requires { + t.Errorf("RequiresTags(%v) = %v, want %v", tt.scopes, got, tt.requires) + } + }) + } +} + +func TestKnownIsComplete(t *testing.T) { + known := Known() + + seen := make(map[Scope]bool, len(known)) + for _, s := range known { + if seen[s] { + t.Errorf("Known() contains duplicate %q", s) + } + + seen[s] = true + } + + // 7 resources × 2 (write+read) + 2 super-scopes = 16. + if len(known) != 16 { + t.Errorf("Known() has %d scopes, want 16", len(known)) + } +} diff --git a/hscontrol/servertest/apiv2_devices_test.go b/hscontrol/servertest/apiv2_devices_test.go new file mode 100644 index 000000000..5d85a46f9 --- /dev/null +++ b/hscontrol/servertest/apiv2_devices_test.go @@ -0,0 +1,457 @@ +package servertest_test + +import ( + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/servertest" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tsclient "tailscale.com/client/tailscale/v2" +) + +// baselinePolicy declares tag:ci so device SetTags is permitted. Every policy +// the device/acl tests write keeps tag:ci, so subtest order does not matter. +const baselinePolicy = `{"tagOwners":{"tag:ci":["apiv2@"]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + +// setBaselinePolicy installs baselinePolicy into both the policy manager and the +// database so device tagging and the ACL reads work. +func setBaselinePolicy(t *testing.T, srv *servertest.TestServer) { + t.Helper() + + st := srv.State() + + _, err := st.SetPolicy([]byte(baselinePolicy)) + require.NoError(t, err) + + _, err = st.SetPolicyInDB(baselinePolicy) + require.NoError(t, err) + + _, err = st.ReloadPolicy() + require.NoError(t, err) +} + +func goClient(t *testing.T, baseURL, apiKey string) *tsclient.Client { + t.Helper() + + base, err := url.Parse(baseURL) + require.NoError(t, err) + + return &tsclient.Client{BaseURL: base, APIKey: apiKey, Tailnet: "-"} +} + +// srvNodeView reads the node straight from the server's NodeStore — the +// authoritative state between client steps. Handlers mutate the NodeStore +// synchronously before responding, so a read right after a 2xx is consistent. +func srvNodeView(t *testing.T, srv *servertest.TestServer, id types.NodeID) types.NodeView { + t.Helper() + + v, ok := srv.State().GetNodeByID(id) + require.Truef(t, ok, "node %d must exist server-side", id) + require.True(t, v.Valid()) + + return v +} + +func approvedRoutesOf(nv types.NodeView) []string { + return util.PrefixesToString(nv.ApprovedRoutes().AsSlice()) +} + +func nodeListed(srv *servertest.TestServer, id types.NodeID) bool { + for _, n := range srv.State().ListNodes().All() { + if n.ID() == id { + return true + } + } + + return false +} + +// apiv2DevicesGoClient drives the device lifecycle through the official SDK, +// validating each mutation three ways: the tool's own get-after-set, the +// server-side NodeStore, and (where relevant) permutations. Tagging is done late +// because it clears user ownership; delete is last. +func apiv2DevicesGoClient(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, id types.NodeID) { + t.Helper() + + ctx := t.Context() + dr := goClient(t, baseURL, apiKey).Devices() + deviceID := strconv.FormatUint(uint64(id), 10) + + // Get — tool and server agree on identity and addresses. + dev, err := dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.Equal(t, deviceID, dev.NodeID) + assert.NotEmpty(t, dev.Addresses) + assert.True(t, dev.Authorized) + assert.Equal(t, srvNodeView(t, srv, id).IPsAsString(), dev.Addresses) + assert.Equal(t, srvNodeView(t, srv, id).GivenName(), dev.Name) + + // List — present in both the tool list and the server's node list. + devs, err := dr.List(ctx) + require.NoError(t, err) + assert.True(t, containsDevice(devs, deviceID), "created device present in list") + assert.True(t, nodeListed(srv, id)) + + // SetName — get-after-set + server-side, then a second rename. + require.NoError(t, dr.SetName(ctx, deviceID, "renamed-go")) + dev, err = dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.Equal(t, "renamed-go", dev.Name) + assert.Equal(t, "renamed-go", srvNodeView(t, srv, id).GivenName()) + + require.NoError(t, dr.SetName(ctx, deviceID, "renamed-go-2")) + assert.Equal(t, "renamed-go-2", srvNodeView(t, srv, id).GivenName()) + + // SetSubnetRoutes — one, two, then exit (expands to both families). + require.NoError(t, dr.SetSubnetRoutes(ctx, deviceID, []string{"10.0.0.0/24"})) + routes, err := dr.SubnetRoutes(ctx, deviceID) + require.NoError(t, err) + assert.Contains(t, routes.Enabled, "10.0.0.0/24") + assert.Contains(t, approvedRoutesOf(srvNodeView(t, srv, id)), "10.0.0.0/24") + assert.Empty(t, srvNodeView(t, srv, id).AnnouncedRoutes(), "route enabled without being announced") + + require.NoError(t, dr.SetSubnetRoutes(ctx, deviceID, []string{"10.0.0.0/24", "192.168.0.0/24"})) + approved := approvedRoutesOf(srvNodeView(t, srv, id)) + assert.Contains(t, approved, "10.0.0.0/24") + assert.Contains(t, approved, "192.168.0.0/24") + + // A single exit prefix expands to both families on the server. + require.NoError(t, dr.SetSubnetRoutes(ctx, deviceID, []string{"0.0.0.0/0"})) + approved = approvedRoutesOf(srvNodeView(t, srv, id)) + assert.Contains(t, approved, "0.0.0.0/0") + assert.Contains(t, approved, "::/0") + + // SetKey — seed a real expiry first so disabling it is a state transition. + future := time.Now().Add(24 * time.Hour) + _, _, err = srv.State().SetNodeExpiry(id, &future) + require.NoError(t, err) + require.True(t, srvNodeView(t, srv, id).Expiry().Valid()) + + require.NoError(t, dr.SetKey(ctx, deviceID, tsclient.DeviceKey{KeyExpiryDisabled: true})) + dev, err = dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.True(t, dev.KeyExpiryDisabled) + assert.False(t, srvNodeView(t, srv, id).Expiry().Valid()) + + // Re-enable is a no-op; expiry stays cleared. + require.NoError(t, dr.SetKey(ctx, deviceID, tsclient.DeviceKey{KeyExpiryDisabled: false})) + assert.False(t, srvNodeView(t, srv, id).Expiry().Valid()) + + // SetTags — flips ownership to the tags; the user is dropped. + require.NoError(t, dr.SetTags(ctx, deviceID, []string{"tag:ci"})) + dev, err = dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.Equal(t, []string{"tag:ci"}, dev.Tags) + assert.Equal(t, types.TaggedDevices.Username(), dev.User) + n := srvNodeView(t, srv, id) + assert.True(t, n.IsTagged()) + assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice()) + assert.False(t, n.User().Valid()) + + // Re-tagging with the same tag is idempotent. + require.NoError(t, dr.SetTags(ctx, deviceID, []string{"tag:ci"})) + assert.Equal(t, []string{"tag:ci"}, srvNodeView(t, srv, id).Tags().AsSlice()) + + // SetAuthorized(true) is a no-op success; de-auth is rejected and inert. + require.NoError(t, dr.SetAuthorized(ctx, deviceID, true)) + dev, err = dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.True(t, dev.Authorized) + + require.Error(t, dr.SetAuthorized(ctx, deviceID, false), "de-authorization is unsupported") + assert.True(t, srvNodeView(t, srv, id).Valid(), "rejected de-auth left the node present") + + // Delete — gone from the tool and the server. + require.NoError(t, dr.Delete(ctx, deviceID)) + _, err = dr.Get(ctx, deviceID) + assert.Truef(t, tsclient.IsNotFound(err), "get after delete should be 404, got %v", err) + + _, ok := srv.State().GetNodeByID(id) + assert.False(t, ok, "deleted node is gone server-side") +} + +func containsDevice(devs []tsclient.Device, id string) bool { + for _, d := range devs { + if d.NodeID == id || d.ID == id { + return true + } + } + + return false +} + +// apiv2ACLGoClient round-trips the policy file: read, raw-read, conditional set. +func apiv2ACLGoClient(t *testing.T, baseURL, apiKey string) { + t.Helper() + + ctx := t.Context() + pf := goClient(t, baseURL, apiKey).PolicyFile() + + acl, err := pf.Get(ctx) + require.NoError(t, err) + assert.NotEmpty(t, acl.ETag, "GET /acl carries an ETag") + + raw, err := pf.Raw(ctx) + require.NoError(t, err) + assert.NotEmpty(t, raw.HuJSON) + + // Set with the current etag as If-Match; keep tag:ci. + require.NoError(t, pf.Set(ctx, baselinePolicy, raw.ETag)) + + updated, err := pf.Raw(ctx) + require.NoError(t, err) + assert.Contains(t, updated.HuJSON, "tag:ci") +} + +// apiv2SettingsGoClient reads the tailnet settings (write is unsupported). +func apiv2SettingsGoClient(t *testing.T, baseURL, apiKey string) { + t.Helper() + + settings, err := goClient(t, baseURL, apiKey).TailnetSettings().Get(t.Context()) + require.NoError(t, err) + assert.Equal(t, "none", string(settings.UsersRoleAllowedToJoinExternalTailnets)) +} + +// tscliRun runs tscli and fails the test on a non-zero exit. +type tscliRun func(args ...string) string + +// tscliRunner returns runners for tscli against the local server: one that +// requires success, and one that tolerates a non-zero exit (for the expected +// 404 after a delete). +func tscliRunner(t *testing.T, baseURL, apiKey string) (tscliRun, func(args ...string) error) { + t.Helper() + + bin, err := exec.LookPath("tscli") + require.NoErrorf(t, err, "tscli is required for TestAPIv2 (provided by the nix dev shell)") + + env := append( + os.Environ(), + "TSCLI_BASE_URL="+baseURL, + "TAILSCALE_API_KEY="+apiKey, + "TAILSCALE_TAILNET=-", + ) + + cmd := func(args ...string) *exec.Cmd { + c := exec.CommandContext(t.Context(), bin, args...) + c.Env = env + + return c + } + + run := func(args ...string) string { + t.Helper() + + out, err := cmd(args...).CombinedOutput() + require.NoErrorf(t, err, "tscli %s\n%s", strings.Join(args, " "), out) + + return string(out) + } + + runAllowErr := func(args ...string) error { + t.Helper() + + return cmd(args...).Run() + } + + return run, runAllowErr +} + +// apiv2DevicesTSCLI drives the device verbs through tscli, asserting each +// mutation via get-after-set (tscli's own json output) and the server NodeStore. +func apiv2DevicesTSCLI(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, id types.NodeID) { + t.Helper() + + run, runAllowErr := tscliRunner(t, baseURL, apiKey) + deviceID := strconv.FormatUint(uint64(id), 10) + + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), deviceID) + assert.True(t, nodeListed(srv, id)) + + // Name. + run("set", "device", "name", "--device", deviceID, "--name", "renamed-tscli") + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), "renamed-tscli") + assert.Equal(t, "renamed-tscli", srvNodeView(t, srv, id).GivenName()) + + // Routes — one, two, then exit (both families). + run("set", "device", "routes", "--device", deviceID, "--route", "10.0.0.0/24") + assert.Contains(t, run("list", "routes", "--device", deviceID, "-o", "json"), "10.0.0.0/24") + assert.Contains(t, approvedRoutesOf(srvNodeView(t, srv, id)), "10.0.0.0/24") + + run("set", "device", "routes", "--device", deviceID, "--route", "10.0.0.0/24", "--route", "192.168.0.0/24") + + approved := approvedRoutesOf(srvNodeView(t, srv, id)) + assert.Contains(t, approved, "10.0.0.0/24") + assert.Contains(t, approved, "192.168.0.0/24") + + run("set", "device", "routes", "--device", deviceID, "--route", "0.0.0.0/0") + + exitApproved := approvedRoutesOf(srvNodeView(t, srv, id)) + assert.Contains(t, exitApproved, "0.0.0.0/0") + assert.Contains(t, exitApproved, "::/0") + + // Key — seed a real expiry first so disabling it is a transition. + future := time.Now().Add(24 * time.Hour) + _, _, err := srv.State().SetNodeExpiry(id, &future) + require.NoError(t, err) + + run("set", "device", "key", "--device", deviceID, "--disable-expiry") + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), `"keyExpiryDisabled": true`) + assert.False(t, srvNodeView(t, srv, id).Expiry().Valid()) + + // Tags — flips to tag ownership. + run("set", "device", "tags", "--device", deviceID, "--tag", "tag:ci") + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), "tag:ci") + n := srvNodeView(t, srv, id) + assert.True(t, n.IsTagged()) + assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice()) + assert.False(t, n.User().Valid()) + + // Authorization — approve is a no-op success. + run("set", "device", "authorization", "--device", deviceID, "--approve") + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), `"authorized": true`) + + // Delete — gone from tscli and the server. + run("delete", "device", "--device", deviceID) + require.Error(t, runAllowErr("get", "device", "--device", deviceID, "-o", "json"), "get after delete should fail") + + _, ok := srv.State().GetNodeByID(id) + assert.False(t, ok) +} + +// apiv2ACLTSCLI reads and writes the policy file through tscli. +func apiv2ACLTSCLI(t *testing.T, baseURL, apiKey string) { + t.Helper() + + run, _ := tscliRunner(t, baseURL, apiKey) + + assert.Contains(t, run("get", "policy", "--json"), "acls") + + dir := t.TempDir() + polFile := filepath.Join(dir, "policy.hujson") + require.NoError(t, os.WriteFile(polFile, []byte(baselinePolicy), 0o600)) + run("set", "policy", "--file", polFile) +} + +// apiv2SettingsTSCLI reads the tailnet settings through tscli. +func apiv2SettingsTSCLI(t *testing.T, baseURL, apiKey string) { + t.Helper() + + run, _ := tscliRunner(t, baseURL, apiKey) + assert.Contains(t, run("get", "settings", "-o", "json"), "devicesKeyDurationDays") +} + +// devicesACLTFConfig exercises Terraform device + ACL data sources AND resources. +// %s is the test node's hostname (the tailscale_device data source key). +const devicesACLTFConfig = ` +terraform { + required_providers { + tailscale = { + source = "tailscale/tailscale" + version = "~> 0.21" + } + } +} + +provider "tailscale" {} + +resource "tailscale_acl" "policy" { + acl = jsonencode({ + tagOwners = { "tag:ci" = ["apiv2@"] } + acls = [{ action = "accept", src = ["*"], dst = ["*:*"] }] + }) + overwrite_existing_content = true +} + +data "tailscale_device" "dut" { + hostname = "%s" + wait_for = "30s" +} + +data "tailscale_devices" "all" {} + +data "tailscale_acl" "current" { + depends_on = [tailscale_acl.policy] +} + +resource "tailscale_device_authorization" "dut" { + device_id = data.tailscale_device.dut.node_id + authorized = true +} + +resource "tailscale_device_tags" "dut" { + device_id = data.tailscale_device.dut.node_id + tags = ["tag:ci"] + depends_on = [tailscale_acl.policy] +} + +resource "tailscale_device_key" "dut" { + device_id = data.tailscale_device.dut.node_id + key_expiry_disabled = true +} + +resource "tailscale_device_subnet_routes" "dut" { + device_id = data.tailscale_device.dut.node_id + routes = ["10.0.0.0/24"] +} + +output "dut_node_id" { value = data.tailscale_device.dut.node_id } +output "dut_addresses" { value = data.tailscale_device.dut.addresses } +output "dut_authorized" { value = data.tailscale_device.dut.authorized } +output "device_count" { value = length(data.tailscale_devices.all.devices) } +output "acl_hujson" { value = data.tailscale_acl.current.hujson } +output "enabled_routes" { value = tailscale_device_subnet_routes.dut.routes } +` + +// apiv2DevicesACLTerraform runs a tofu init/apply/destroy over the device and +// ACL data sources and resources, asserting no post-apply drift, the data-source +// outputs (read path) against the server truth, and the resulting server state +// (write path). parallelism=1 avoids racing concurrent mutations on the one +// shared node. +func apiv2DevicesACLTerraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey, hostname string, id types.NodeID) { + t.Helper() + + tf := newTofu(t, baseURL, apiKey, fmt.Sprintf(devicesACLTFConfig, hostname)) + + tf.run("init", "-no-color", "-input=false") + tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + + // Data sources resolved to real values that match the server. + outputs := tf.outputs() + assert.Equal(t, strconv.FormatUint(uint64(id), 10), outputs.str(t, "dut_node_id")) + assert.ElementsMatch(t, srvNodeView(t, srv, id).IPsAsString(), outputs.strSlice(t, "dut_addresses")) + outputs.jsonEq(t, "dut_authorized", true) + assert.Equal(t, srv.State().ListNodes().Len(), int(outputs.num(t, "device_count"))) + assert.Contains(t, outputs.str(t, "acl_hujson"), "tag:ci") + assert.Contains(t, outputs.strSlice(t, "enabled_routes"), "10.0.0.0/24") + + // Server-side: the resources actually applied (write path). + n := srvNodeView(t, srv, id) + assert.True(t, n.IsTagged()) + assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice()) + assert.Contains(t, approvedRoutesOf(n), "10.0.0.0/24") + assert.False(t, n.Expiry().Valid()) + + pol, err := srv.State().GetPolicy() + require.NoError(t, err) + assert.Contains(t, pol.Data, "tag:ci", "tailscale_acl wrote the policy") + + // A converged config must produce an empty plan — drift is a read/write bug. + tf.assertNoDrift() + + // destroy resets the policy; the node is a data source, so it persists. + // Tags/expiry teardown are no-ops on Headscale, so they are not reverted. + tf.run("destroy", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + + _, ok := srv.State().GetNodeByID(id) + assert.True(t, ok, "data-source node persists across destroy") +} diff --git a/hscontrol/servertest/apiv2_oauth_scopes_test.go b/hscontrol/servertest/apiv2_oauth_scopes_test.go new file mode 100644 index 000000000..5d3cff67c --- /dev/null +++ b/hscontrol/servertest/apiv2_oauth_scopes_test.go @@ -0,0 +1,391 @@ +package servertest_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/juanfont/headscale/hscontrol/servertest" + "github.com/stretchr/testify/require" +) + +// TestAPIv2OAuthScopes proves the v2 OAuth scope and tag enforcement through the +// REAL Tailscale Terraform provider's client-credentials flow: the provider mints +// a scoped access token at /api/v2/oauth/token, then runs one operation whose +// allow/deny outcome must match the token's scope and tag grant. +// +// Each client is created with exactly the scopes/tags under test, and the +// provider requests no scope narrowing, so the minted token carries the client's +// full grant. The matrix covers right-scope-allowed, wrong-scope-denied, read vs +// write, the "all" super-scope, and policy tag ownership (owned-by delegation). +// +// The oauth_keys rows drive the real provider's tailscale_oauth_client resource, +// which omits the "capabilities" body field, so the request schema must make it +// optional (it is). Drift is not asserted on the auth-key rows: the provider +// defaults preauthorized=true on create but the server reads tagged keys back as +// false, so a converged plan is never empty. That is orthogonal to scope +// enforcement, so a clean apply is the allow proof. +// +// tofu is required, not optional: it ships in the nix dev shell, so a missing +// binary means a broken environment and the test fails rather than skipping. +func TestAPIv2OAuthScopes(t *testing.T) { + srv := servertest.NewServer(t, servertest.WithRealListener()) + owner := srv.CreateUser(t, "apiv2-oauth") + creator := owner.ID + + // scopeMatrixPolicy declares every tag the matrix touches: tag:ci for the + // auth-key rows, and the tag:k8s/tag:k8s-operator delegation for the + // owned-by rows. + setScopeMatrixPolicy(t, srv) + + // A registered node is the target for the device-scope rows. Its decimal id + // is the device id the v2 API and the provider's device resources address. + // A devices:core write token also grants devices:core:read, so whatever + // get/post sequence the provider runs within the core family is satisfied; + // the read-scope row then fails on the write, proving read vs write end to + // end. devices:routes and feature_settings are proven exhaustively by the Go + // matrix (apiv2_oauth_matrix_test.go), not here: the provider's routes + // resource issues a cross-family device read a routes-only token would lack, + // and the server's settings endpoint is read-only. + deviceID := srv.CreateRegisteredNode(t, owner).StringID() + deviceAuthorizeConfig := ` +resource "tailscale_device_authorization" "d" { + device_id = "` + deviceID + `" + authorized = true +} +` + + // The configs each exercise exactly one operation so a row's allow/deny + // outcome is unambiguous. + const ( + authKeyConfig = ` +resource "tailscale_tailnet_key" "k" { + reusable = true + ephemeral = false + expiry = 3600 + description = "oauth-scope-matrix" + tags = ["tag:ci"] +} +` + aclConfig = ` +resource "tailscale_acl" "policy" { + acl = jsonencode({ + tagOwners = { + "tag:ci" = ["apiv2-oauth@"] + "tag:k8s-operator" = [] + "tag:k8s" = ["tag:k8s-operator"] + "tag:other" = [] + } + acls = [{ action = "accept", src = ["*"], dst = ["*:*"] }] + }) + overwrite_existing_content = true +} +` + devicesReadConfig = ` +data "tailscale_devices" "all" {} +output "device_count" { value = length(data.tailscale_devices.all.devices) } +` + // oauthClientConfig creates an OAuth client whose scope (oauth_keys:read) + // is within an oauth_keys grant, so the only variable under test is whether + // the caller may manage clients at all. + oauthClientConfig = ` +resource "tailscale_oauth_client" "c" { + description = "oauth-scope-matrix-client" + scopes = ["oauth_keys:read"] +} +` + // oauthClientEscalateConfig requests a scope (devices:core:read) that an + // oauth_keys-only caller does not itself hold, so the escalation guard must + // reject minting a broader client. + oauthClientEscalateConfig = ` +resource "tailscale_oauth_client" "c" { + description = "oauth-scope-matrix-escalate" + scopes = ["devices:core:read"] +} +` + ) + + // k8sAuthKeyConfig templates the auth-key tag for the owned-by rows. + k8sAuthKeyConfig := func(tag string) string { + return ` +resource "tailscale_tailnet_key" "k" { + reusable = true + ephemeral = false + expiry = 3600 + description = "oauth-scope-matrix" + tags = ["` + tag + `"] +} +` + } + + tests := []struct { + name string + // scopes/tags the OAuth client (and thus its minted token) holds. + scopes []string + tags []string + // config is the single-operation HCL applied through the OAuth provider. + config string + // deny asserts apply fails; denyContains lists substrings, any of which + // the failure output must contain. allow asserts a clean apply. + deny bool + denyContains []string + }{ + // auth_keys: write scope mints an auth key; read/wrong/all-read do not. + { + name: "auth_keys allows tailnet_key", + scopes: []string{"auth_keys"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + }, + { + name: "auth_keys:read denies tailnet_key", + scopes: []string{"auth_keys:read"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "devices:core denies tailnet_key", + scopes: []string{"devices:core"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "all allows tailnet_key", + scopes: []string{"all"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + }, + { + name: "all:read denies tailnet_key", + scopes: []string{"all:read"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + + // oauth_keys: managing OAuth clients. Read and wrong scopes are denied, and + // a caller cannot mint a client carrying authority it lacks (escalation). + { + name: "oauth_keys allows oauth_client", + scopes: []string{"oauth_keys"}, + config: oauthClientConfig, + }, + { + name: "oauth_keys:read denies oauth_client", + scopes: []string{"oauth_keys:read"}, + config: oauthClientConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "auth_keys denies oauth_client", + scopes: []string{"auth_keys"}, + tags: []string{"tag:ci"}, + config: oauthClientConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "oauth_keys cannot escalate client scope", + scopes: []string{"oauth_keys"}, + config: oauthClientEscalateConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "beyond the creating token"}, + }, + + // policy_file: write scope sets the ACL; read does not. + { + name: "policy_file allows acl", + scopes: []string{"policy_file"}, + config: aclConfig, + }, + { + name: "policy_file:read denies acl", + scopes: []string{"policy_file:read"}, + config: aclConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + + // devices:core:read reads the devices data source. + { + name: "devices:core:read allows devices read", + scopes: []string{"devices:core:read"}, + config: devicesReadConfig, + }, + { + name: "policy_file:read denies devices read", + scopes: []string{"policy_file:read"}, + config: devicesReadConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + + // devices:core: a write-scoped token authorizes a device; the read scope + // and a wrong-family scope are denied (read vs write through the real + // provider's tailscale_device_authorization resource). + { + name: "devices:core allows device authorize", + scopes: []string{"devices:core"}, + tags: []string{"tag:ci"}, + config: deviceAuthorizeConfig, + }, + { + name: "devices:core:read denies device authorize", + scopes: []string{"devices:core:read"}, + config: deviceAuthorizeConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "policy_file denies device authorize", + scopes: []string{"policy_file"}, + config: deviceAuthorizeConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + + // Tag owned-by: a tag:k8s-operator client may use its own tag and the + // tag:k8s it owns, but not an unowned tag. + { + name: "owned-by exact tag allowed", + scopes: []string{"auth_keys"}, + tags: []string{"tag:k8s-operator"}, + config: k8sAuthKeyConfig("tag:k8s-operator"), + }, + { + name: "owned-by delegated tag allowed", + scopes: []string{"auth_keys"}, + tags: []string{"tag:k8s-operator"}, + config: k8sAuthKeyConfig("tag:k8s"), + }, + { + name: "owned-by unowned tag denied", + scopes: []string{"auth_keys"}, + tags: []string{"tag:k8s-operator"}, + config: k8sAuthKeyConfig("tag:other"), + deny: true, + denyContains: []string{"403", "Forbidden", "is not owned"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + secret, client, err := srv.State().CreateOAuthClient(tt.scopes, tt.tags, "scope-matrix", &creator) + require.NoError(t, err) + + tf := newTofuOAuth(t, srv.URL, client.ClientID, secret, oauthHCL(tt.config)) + tf.run("init", "-no-color", "-input=false") + + if tt.deny { + tf.runExpectError(t, tt.denyContains...) + return + } + + tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + }) + } +} + +// setScopeMatrixPolicy installs the policy the scope matrix relies on: tag:ci for +// the auth-key rows, and the tag:k8s-operator → tag:k8s delegation for the +// owned-by rows. +func setScopeMatrixPolicy(t *testing.T, srv *servertest.TestServer) { + t.Helper() + + // tag:other exists but is owned by no one, so the owned-by denial row tests a + // grant denial (403) rather than a tag-not-in-policy rejection (400). + const policy = `{"tagOwners":{"tag:ci":["apiv2-oauth@"],"tag:k8s-operator":[],"tag:k8s":["tag:k8s-operator"],"tag:other":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + + st := srv.State() + + _, err := st.SetPolicy([]byte(policy)) + require.NoError(t, err) + + _, err = st.SetPolicyInDB(policy) + require.NoError(t, err) + + _, err = st.ReloadPolicy() + require.NoError(t, err) +} + +// oauthHCL wraps a single-operation body with the provider block. The provider +// authenticates via the OAuth env vars newTofuOAuth sets, so the block is empty. +func oauthHCL(body string) string { + return ` +terraform { + required_providers { + tailscale = { + source = "tailscale/tailscale" + version = "~> 0.21" + } + } +} + +provider "tailscale" {} +` + body +} + +// newTofuOAuth is a newTofu variant whose provider authenticates with OAuth +// client credentials instead of an API key: it sets TAILSCALE_OAUTH_CLIENT_ID / +// TAILSCALE_OAUTH_CLIENT_SECRET (the env vars the tailscale/tailscale provider +// honors), so the real provider runs the client-credentials grant against +// baseURL/api/v2/oauth/token. The client id is embedded in the secret, so the +// secret embeds the client id (the provider sends both; the server derives the +// client from the secret alone). +func newTofuOAuth(t *testing.T, baseURL, clientID, clientSecret, config string) *tofu { + t.Helper() + + bin, err := exec.LookPath("tofu") + require.NoErrorf(t, err, "tofu is required for TestAPIv2OAuthScopes (provided by the nix dev shell)") + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(config), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "plugin-cache"), 0o755)) + + env := append( + os.Environ(), + "TAILSCALE_BASE_URL="+baseURL, + "TAILSCALE_OAUTH_CLIENT_ID="+clientID, + "TAILSCALE_OAUTH_CLIENT_SECRET="+clientSecret, + "TAILSCALE_TAILNET=-", + // Keep provider plugins inside the temp dir so the run is self-contained. + "TF_PLUGIN_CACHE_DIR="+filepath.Join(dir, "plugin-cache"), + ) + + cmd := func(args ...string) *exec.Cmd { + c := exec.CommandContext(t.Context(), bin, args...) + c.Dir = dir + c.Env = env + + return c + } + + return &tofu{t: t, cmd: cmd} +} + +// runExpectError runs apply expecting FAILURE, asserting the combined output +// contains at least one of mustContain. This is the deny half of every matrix +// row: a scoped token attempting an operation it lacks the scope (or tag) for. +func (tf *tofu) runExpectError(t *testing.T, mustContain ...string) { + t.Helper() + + out, err := tf.cmd("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1").CombinedOutput() + require.Errorf(t, err, "expected apply to fail, but it succeeded:\n%s", out) + + combined := string(out) + for _, want := range mustContain { + if strings.Contains(combined, want) { + return + } + } + + t.Fatalf("apply failed but output contained none of %v:\n%s", mustContain, combined) +} diff --git a/hscontrol/servertest/apiv2_test.go b/hscontrol/servertest/apiv2_test.go new file mode 100644 index 000000000..4fb98d10c --- /dev/null +++ b/hscontrol/servertest/apiv2_test.go @@ -0,0 +1,578 @@ +package servertest_test + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/juanfont/headscale/hscontrol/servertest" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tsclient "tailscale.com/client/tailscale/v2" +) + +// TestAPIv2 proves the v2 API's Tailscale-compatible (ported) endpoints against the three real +// clients it exists to support: the official Go SDK, tscli, and the Tailscale +// Terraform provider (via OpenTofu). All three run against one Headscale bound +// to a real loopback port, authenticating with a user-owned API key. +// +// Every mutation is validated three ways — the tool's own get-after-set, the +// server-side NodeStore/state, and (for Terraform) a no-change plan proving no +// drift — so a server/provider read-write mismatch fails loudly. +// +// tscli and tofu are required, not optional: they ship in the nix dev shell, so +// a missing binary means a broken environment and the test fails rather than +// silently skipping. +func TestAPIv2(t *testing.T) { + srv := servertest.NewServer(t, servertest.WithRealListener()) + owner := srv.CreateUser(t, "apiv2") + apiKey := srv.CreateAPIKey(t, owner) + + // tag:ci must exist in policy for device SetTags; every policy the tests + // write keeps it, so subtest order is irrelevant. Terraform runs last so its + // ACL teardown does not strand the others. + setBaselinePolicy(t, srv) + + t.Run("GoClient", func(t *testing.T) { + apiv2GoClient(t, srv, srv.URL, apiKey, owner) + apiv2UsersGoClient(t, srv, srv.URL, apiKey, owner) + + node := srv.CreateRegisteredNode(t, owner, "dut-go") + apiv2DevicesGoClient(t, srv, srv.URL, apiKey, node.ID()) + apiv2ACLGoClient(t, srv.URL, apiKey) + apiv2SettingsGoClient(t, srv.URL, apiKey) + }) + + t.Run("TSCLI", func(t *testing.T) { + apiv2TSCLI(t, srv, srv.URL, apiKey) + apiv2UsersTSCLI(t, srv.URL, apiKey, owner) + + node := srv.CreateRegisteredNode(t, owner, "dut-tscli") + apiv2DevicesTSCLI(t, srv, srv.URL, apiKey, node.ID()) + apiv2ACLTSCLI(t, srv.URL, apiKey) + apiv2SettingsTSCLI(t, srv.URL, apiKey) + }) + + t.Run("Terraform", func(t *testing.T) { + apiv2Terraform(t, srv, srv.URL, apiKey, owner) + apiv2UsersTerraform(t, srv, srv.URL, apiKey, owner) + + node := srv.CreateRegisteredNode(t, owner, "dut-tf") + apiv2DevicesACLTerraform(t, srv, srv.URL, apiKey, node.Hostname(), node.ID()) + }) +} + +// apiv2GoClient exercises the official SDK with untagged (user-owned) keys — the +// default Terraform/tscli path — validating each operation against the server's +// stored PreAuthKey, plus ephemeral and default-expiry permutations. +func apiv2GoClient(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) { + t.Helper() + + ctx := t.Context() + keys := goClient(t, baseURL, apiKey).Keys() + wantOwner := strconv.FormatUint(uint64(owner.ID), 10) + + var req tsclient.CreateKeyRequest + + req.Description = "go-client" + req.ExpirySeconds = 3600 + req.Capabilities.Devices.Create.Reusable = true + + created, err := keys.CreateAuthKey(ctx, req) + require.NoError(t, err) + assert.NotEmpty(t, created.ID) + assert.NotEmpty(t, created.Key, "secret returned on create") + assert.Equal(t, "go-client", created.Description) + assert.Equal(t, wantOwner, created.UserID, "user-owned key reports its owner") + + // Server-side: the stored key matches the request and is owned by the user. + pak := srvPreAuthKey(t, srv, created.ID) + assert.True(t, pak.Reusable) + assert.False(t, pak.Ephemeral) + assert.Empty(t, pak.Tags, "no tags -> user-owned") + require.NotNil(t, pak.User) + assert.Equal(t, owner.ID, pak.User.ID) + assert.Equal(t, "go-client", pak.Description) + require.NotNil(t, pak.CreatedAt) + require.NotNil(t, pak.Expiration) + assert.InDelta(t, 3600, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 5) + + got, err := keys.Get(ctx, created.ID) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + assert.Empty(t, got.Key, "secret omitted on get") + assert.Equal(t, "go-client", got.Description) + assert.False(t, got.Invalid) + // The SDK decodes our integer expirySeconds into a Duration of nanoseconds, + // so never assert it numerically; the lifetime rides on Expires-Created. + assert.InDelta(t, 3600, got.Expires.Sub(got.Created).Seconds(), 5) + + list, err := keys.List(ctx, true) + require.NoError(t, err) + assert.True(t, containsKeyID(list, created.ID), "created key present in list") + + // DELETE soft-revokes (Tailscale-faithful): the key stays retrievable, now + // invalid, until the collector reaps it. + require.NoError(t, keys.Delete(ctx, created.ID)) + revoked, err := keys.Get(ctx, created.ID) + require.NoError(t, err, "revoked key stays retrievable") + assert.True(t, revoked.Invalid, "revoked key reports invalid") + require.NotNil(t, srvPreAuthKey(t, srv, created.ID).Revoked, "key soft-revoked server-side") + + // Permutation — ephemeral key. + var ephReq tsclient.CreateKeyRequest + + ephReq.Capabilities.Devices.Create.Ephemeral = true + eph, err := keys.CreateAuthKey(ctx, ephReq) + require.NoError(t, err) + assert.True(t, srvPreAuthKey(t, srv, eph.ID).Ephemeral) + require.NoError(t, keys.Delete(ctx, eph.ID)) + + // Permutation — default expiry (omit ExpirySeconds -> 90 days). + def, err := keys.CreateAuthKey(ctx, tsclient.CreateKeyRequest{}) + require.NoError(t, err) + defKey := srvPreAuthKey(t, srv, def.ID) + require.NotNil(t, defKey.CreatedAt) + require.NotNil(t, defKey.Expiration) + assert.InDelta(t, 7776000, defKey.Expiration.Sub(*defKey.CreatedAt).Seconds(), 5) + require.NoError(t, keys.Delete(ctx, def.ID)) +} + +func containsKeyID(keys []tsclient.Key, id string) bool { + for _, k := range keys { + if k.ID == id { + return true + } + } + + return false +} + +func containsUserID(users []tsclient.User, id string) bool { + for _, u := range users { + if u.ID == id { + return true + } + } + + return false +} + +// srvUserCount is the server-side ground truth for the number of users. +func srvUserCount(t *testing.T, srv *servertest.TestServer) int { + t.Helper() + + users, err := srv.State().ListAllUsers() + require.NoError(t, err) + + return len(users) +} + +// apiv2UsersGoClient exercises the Users data sources through the official SDK: +// get-by-id, list, the type/role filters (member matches all, anything else +// matches nothing), and a typed 404 — each cross-checked against server truth. +func apiv2UsersGoClient(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) { + t.Helper() + + ctx := t.Context() + ur := goClient(t, baseURL, apiKey).Users() + ownerID := strconv.FormatUint(uint64(owner.ID), 10) + + got, err := ur.Get(ctx, ownerID) + require.NoError(t, err) + assert.Equal(t, ownerID, got.ID) + assert.Equal(t, owner.Username(), got.LoginName) + assert.Equal(t, tsclient.UserTypeMember, got.Type) + assert.Equal(t, tsclient.UserStatusActive, got.Status) + assert.Equal(t, srv.State().ListNodesByUser(types.UserID(owner.ID)).Len(), got.DeviceCount, + "deviceCount matches the server's node count for the user") + + all, err := ur.List(ctx, nil, nil) + require.NoError(t, err) + assert.True(t, containsUserID(all, ownerID), "owner present in user list") + assert.Len(t, all, srvUserCount(t, srv)) + + // member matches every Headscale user; shared/admin match nothing. + members, err := ur.List(ctx, new(tsclient.UserTypeMember), nil) + require.NoError(t, err) + assert.Len(t, members, len(all)) + + shared, err := ur.List(ctx, new(tsclient.UserTypeShared), nil) + require.NoError(t, err) + assert.Empty(t, shared, "Headscale has no shared users") + + admins, err := ur.List(ctx, nil, new(tsclient.UserRoleAdmin)) + require.NoError(t, err) + assert.Empty(t, admins, "Headscale has no admin-role users") + + _, err = ur.Get(ctx, "999999") + require.Error(t, err) + assert.True(t, tsclient.IsNotFound(err), "unknown user id is a typed 404") +} + +// apiv2UsersTSCLI exercises the user verbs through tscli, asserting the owner is +// present in the list and retrievable by id. +func apiv2UsersTSCLI(t *testing.T, baseURL, apiKey string, owner *types.User) { + t.Helper() + + run, _ := tscliRunner(t, baseURL, apiKey) + ownerID := strconv.FormatUint(uint64(owner.ID), 10) + + listOut := run("list", "users", "-o", "json") + assert.Contains(t, listOut, ownerID) + assert.Contains(t, listOut, `"member"`) + + getOut := run("get", "user", "--user", ownerID, "-o", "json") + assert.Contains(t, getOut, ownerID) + assert.Contains(t, getOut, owner.Username()) +} + +// srvPreAuthKey is the server-side ground truth for a key id; it fails the test +// if the key is absent. +func srvPreAuthKey(t *testing.T, srv *servertest.TestServer, id string) types.PreAuthKey { + t.Helper() + + pak := findPAKByID(t, srv, id) + require.NotNilf(t, pak, "pre-auth key %s not found server-side", id) + + return *pak +} + +// findPAKByID returns the stored key with the given stringified id, or nil. +func findPAKByID(t *testing.T, srv *servertest.TestServer, id string) *types.PreAuthKey { + t.Helper() + + want, err := strconv.ParseUint(id, 10, 64) + require.NoError(t, err) + + keys, err := srv.State().ListPreAuthKeys() + require.NoError(t, err) + + for i := range keys { + if keys[i].ID == want { + return &keys[i] + } + } + + return nil +} + +// apiv2TSCLI exercises tscli with a tagged key, validating server-side that the +// stored key carries the requested tags and metadata. +func apiv2TSCLI(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string) { + t.Helper() + + run, _ := tscliRunner(t, baseURL, apiKey) + + out := run( + "create", "key", + "--type", "authkey", + "--description", "tscli", + "--expiry", "1h", + "--reusable", + "--tags", "tag:ci", + "-o", "json", + ) + + var created struct { + ID string `json:"id"` + Key string `json:"key"` + } + require.NoErrorf(t, json.Unmarshal([]byte(out), &created), "tscli create output: %s", out) + assert.NotEmpty(t, created.ID) + assert.NotEmpty(t, created.Key, "secret returned on create") + + // Server-side: tagged, reusable, described, no owner. + pak := srvPreAuthKey(t, srv, created.ID) + assert.Equal(t, []string{"tag:ci"}, pak.Tags) + assert.True(t, pak.Reusable) + assert.Equal(t, "tscli", pak.Description) + assert.Nil(t, pak.User, "tagged key has no owning user") + + getOut := run("get", "key", "--key", created.ID, "-o", "json") + + var got struct { + Key string `json:"key"` + } + require.NoError(t, json.Unmarshal([]byte(getOut), &got)) + assert.Empty(t, got.Key, "secret omitted on get") + + assert.Contains(t, run("list", "keys", "--all", "-o", "json"), created.ID) + + // DELETE soft-revokes: the key stays retrievable (invalid) server-side until + // the collector reaps it. + run("delete", "key", "--key", created.ID) + assert.Contains(t, run("get", "key", "--key", created.ID, "-o", "json"), `"invalid": true`) + require.NotNil(t, srvPreAuthKey(t, srv, created.ID).Revoked, "key soft-revoked server-side") +} + +// terraformConfig drives the tailscale_tailnet_key resource against the local +// server. No tags, so the key is owned by the API key's user — the default +// Terraform path. Outputs expose the provider's read-back for value + drift +// checks. +const terraformConfig = ` +terraform { + required_providers { + tailscale = { + source = "tailscale/tailscale" + version = "~> 0.21" + } + } +} + +provider "tailscale" {} + +resource "tailscale_tailnet_key" "test" { + reusable = true + ephemeral = false + preauthorized = true + expiry = 3600 + description = "tofu-roundtrip" +} + +output "key_id" { value = tailscale_tailnet_key.test.id } +output "key_reusable" { value = tailscale_tailnet_key.test.reusable } +output "key_ephemeral" { value = tailscale_tailnet_key.test.ephemeral } +output "key_description" { value = tailscale_tailnet_key.test.description } +` + +// apiv2Terraform runs a tofu init→apply→(no-drift)→destroy roundtrip on a +// tailnet key, cross-checking the provider outputs and the server's stored key. +func apiv2Terraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) { + t.Helper() + + tf := newTofu(t, baseURL, apiKey, terraformConfig) + + tf.run("init", "-no-color", "-input=false") + tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + + outputs := tf.outputs() + keyID := outputs.str(t, "key_id") + require.NotEmpty(t, keyID) + outputs.jsonEq(t, "key_reusable", true) + outputs.jsonEq(t, "key_ephemeral", false) + outputs.jsonEq(t, "key_description", "tofu-roundtrip") + + // Server-side: the key exists, user-owned, with the requested attributes. + pak := srvPreAuthKey(t, srv, keyID) + assert.Equal(t, "tofu-roundtrip", pak.Description) + assert.True(t, pak.Reusable) + assert.False(t, pak.Ephemeral) + assert.Empty(t, pak.Tags, "no tags -> user-owned key") + require.NotNil(t, pak.User) + assert.Equal(t, owner.ID, pak.User.ID) + require.NotNil(t, pak.CreatedAt) + require.NotNil(t, pak.Expiration) + assert.InDelta(t, 3600, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 5) + + // A converged config must produce an empty plan — drift is a read/write bug. + tf.assertNoDrift() + + // destroy DELETEs the key, which soft-revokes it: the row is kept (revoked) + // until the collector reaps it. + tf.run("destroy", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + require.NotNil(t, srvPreAuthKey(t, srv, keyID).Revoked, "key revoked after destroy") +} + +// usersTFConfig drives the tailscale_user (by login name) and tailscale_users +// data sources, plus tailscale_4via6 (provider-local compute, no server call) to +// prove that data source resolves against Headscale unchanged. %s is the owner's +// login name. Data sources create nothing, so the assertions are value +// correctness plus no drift on re-read. +const usersTFConfig = ` +terraform { + required_providers { + tailscale = { + source = "tailscale/tailscale" + version = "~> 0.21" + } + } +} + +provider "tailscale" {} + +data "tailscale_user" "owner" { + login_name = "%s" +} + +data "tailscale_users" "all" {} + +data "tailscale_4via6" "site" { + site = 7 + cidr = "10.1.1.0/24" +} + +output "user_id" { value = data.tailscale_user.owner.id } +output "user_login_name" { value = data.tailscale_user.owner.login_name } +output "user_type" { value = data.tailscale_user.owner.type } +output "user_device_count" { value = data.tailscale_user.owner.device_count } +output "users_count" { value = length(data.tailscale_users.all.users) } +output "via6" { value = data.tailscale_4via6.site.ipv6 } +` + +// apiv2UsersTerraform runs a tofu init/apply/(no-drift)/destroy over the +// tailscale_user + tailscale_users data sources (and the provider-local +// tailscale_4via6), cross-checking the data-source outputs against the server's +// stored users. +func apiv2UsersTerraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) { + t.Helper() + + tf := newTofu(t, baseURL, apiKey, fmt.Sprintf(usersTFConfig, owner.Username())) + + tf.run("init", "-no-color", "-input=false") + tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + + outputs := tf.outputs() + assert.Equal(t, strconv.FormatUint(uint64(owner.ID), 10), outputs.str(t, "user_id")) + assert.Equal(t, owner.Username(), outputs.str(t, "user_login_name")) + assert.Equal(t, "member", outputs.str(t, "user_type")) + assert.Equal(t, srv.State().ListNodesByUser(types.UserID(owner.ID)).Len(), + int(outputs.num(t, "user_device_count"))) + assert.Equal(t, srvUserCount(t, srv), int(outputs.num(t, "users_count"))) + + // tailscale_4via6 is computed by the provider with no server call; assert it + // resolved to a Tailscale 4via6 address. + assert.Contains(t, outputs.str(t, "via6"), "fd7a:115c:a1e0", "4via6 mapped address") + + // A converged data-source read must produce an empty plan — drift is a read bug. + tf.assertNoDrift() + + // destroy removes only TF state; the users persist (they are data sources). + tf.run("destroy", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + assert.GreaterOrEqual(t, srvUserCount(t, srv), 1, "users persist across data-source destroy") +} + +// tofu binds a tofu binary, working dir, and env for a single workspace. cmd is +// a closure capturing the looked-up binary so subprocess construction stays in +// one place. +type tofu struct { + t *testing.T + cmd func(args ...string) *exec.Cmd +} + +func newTofu(t *testing.T, baseURL, apiKey, config string) *tofu { + t.Helper() + + bin, err := exec.LookPath("tofu") + require.NoErrorf(t, err, "tofu is required for TestAPIv2 (provided by the nix dev shell)") + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(config), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "plugin-cache"), 0o755)) + + env := append( + os.Environ(), + "TAILSCALE_BASE_URL="+baseURL, + "TAILSCALE_API_KEY="+apiKey, + "TAILSCALE_TAILNET=-", + // Keep provider plugins inside the temp dir so the run is self-contained. + "TF_PLUGIN_CACHE_DIR="+filepath.Join(dir, "plugin-cache"), + ) + + cmd := func(args ...string) *exec.Cmd { + c := exec.CommandContext(t.Context(), bin, args...) + c.Dir = dir + c.Env = env + + return c + } + + return &tofu{t: t, cmd: cmd} +} + +func (tf *tofu) run(args ...string) string { + tf.t.Helper() + + out, err := tf.cmd(args...).CombinedOutput() + require.NoErrorf(tf.t, err, "tofu %s\n%s", strings.Join(args, " "), out) + + return string(out) +} + +// assertNoDrift fails if a no-change plan reports changes. plan +// -detailed-exitcode returns 0 = no changes, 1 = error, 2 = drift. +func (tf *tofu) assertNoDrift() { + tf.t.Helper() + + out, err := tf.cmd("plan", "-detailed-exitcode", "-no-color", "-input=false", "-parallelism=1").CombinedOutput() + if err == nil { + return + } + + var exit *exec.ExitError + require.ErrorAsf(tf.t, err, &exit, "tofu plan\n%s", out) + require.Equalf(tf.t, 0, exit.ExitCode(), + "no-change plan after apply must be empty; drift means a provider read disagrees with desired state:\n%s", out) +} + +func (tf *tofu) outputs() tofuOutputs { + tf.t.Helper() + + out := tf.run("output", "-json", "-no-color") + + var raw map[string]struct { + Value json.RawMessage `json:"value"` + } + require.NoError(tf.t, json.Unmarshal([]byte(out), &raw)) + + o := make(tofuOutputs, len(raw)) + for k, v := range raw { + o[k] = v.Value + } + + return o +} + +// tofuOutputs is the decoded `tofu output -json`, keyed by output name. +type tofuOutputs map[string]json.RawMessage + +func (o tofuOutputs) raw(t *testing.T, key string) json.RawMessage { + t.Helper() + + v, ok := o[key] + require.Truef(t, ok, "output %q missing", key) + + return v +} + +func (o tofuOutputs) str(t *testing.T, key string) string { + t.Helper() + + var s string + require.NoError(t, json.Unmarshal(o.raw(t, key), &s)) + + return s +} + +func (o tofuOutputs) num(t *testing.T, key string) float64 { + t.Helper() + + var n float64 + require.NoError(t, json.Unmarshal(o.raw(t, key), &n)) + + return n +} + +func (o tofuOutputs) strSlice(t *testing.T, key string) []string { + t.Helper() + + var s []string + require.NoError(t, json.Unmarshal(o.raw(t, key), &s)) + + return s +} + +// jsonEq asserts the output decodes equal to want (handles bools/strings/numbers). +func (o tofuOutputs) jsonEq(t *testing.T, key string, want any) { + t.Helper() + + wantJSON, err := json.Marshal(want) + require.NoError(t, err) + require.JSONEq(t, string(wantJSON), string(o.raw(t, key))) +} diff --git a/hscontrol/servertest/assertions.go b/hscontrol/servertest/assertions.go index 351659cb5..54da6473d 100644 --- a/hscontrol/servertest/assertions.go +++ b/hscontrol/servertest/assertions.go @@ -1,9 +1,7 @@ package servertest import ( - "net/netip" "testing" - "time" ) // AssertMeshComplete verifies that every client in the slice sees @@ -67,72 +65,6 @@ func AssertPeerOnline(tb testing.TB, observer *TestClient, peerName string) { } } -// AssertPeerOffline checks that the observer sees peerName as offline. -func AssertPeerOffline(tb testing.TB, observer *TestClient, peerName string) { - tb.Helper() - - peer, ok := observer.PeerByName(peerName) - if !ok { - // Peer gone entirely counts as "offline" for this assertion. - return - } - - isOnline, known := peer.Online().GetOk() - if known && isOnline { - tb.Errorf("AssertPeerOffline: %s sees peer %s as online, want offline", - observer.Name, peerName) - } -} - -// AssertPeerGone checks that the observer does NOT have peerName in -// its peer list at all. -func AssertPeerGone(tb testing.TB, observer *TestClient, peerName string) { - tb.Helper() - - _, ok := observer.PeerByName(peerName) - if ok { - tb.Errorf("AssertPeerGone: %s still sees peer %s", observer.Name, peerName) - } -} - -// AssertPeerHasAllowedIPs checks that a peer has the expected -// [tailcfg.Node.AllowedIPs] prefixes. -func AssertPeerHasAllowedIPs(tb testing.TB, observer *TestClient, peerName string, want []netip.Prefix) { - tb.Helper() - - peer, ok := observer.PeerByName(peerName) - if !ok { - tb.Errorf("AssertPeerHasAllowedIPs: %s does not see peer %s", observer.Name, peerName) - - return - } - - got := make([]netip.Prefix, 0, peer.AllowedIPs().Len()) - for i := range peer.AllowedIPs().Len() { - got = append(got, peer.AllowedIPs().At(i)) - } - - if len(got) != len(want) { - tb.Errorf("AssertPeerHasAllowedIPs: %s sees %s with AllowedIPs %v, want %v", - observer.Name, peerName, got, want) - - return - } - - // Build a set for comparison. - wantSet := make(map[netip.Prefix]bool, len(want)) - for _, p := range want { - wantSet[p] = true - } - - for _, p := range got { - if !wantSet[p] { - tb.Errorf("AssertPeerHasAllowedIPs: %s sees %s with unexpected AllowedIP %v (want %v)", - observer.Name, peerName, p, want) - } - } -} - // AssertConsistentState checks that all clients agree on peer // properties: every connected client should see the same set of // peer hostnames. @@ -210,54 +142,3 @@ func AssertSelfHasAddresses(tb testing.TB, client *TestClient) { tb.Errorf("AssertSelfHasAddresses: %s self node has no addresses", client.Name) } } - -// EventuallyAssertMeshComplete retries [AssertMeshComplete] up to -// timeout, useful when waiting for state to propagate. -func EventuallyAssertMeshComplete(tb testing.TB, clients []*TestClient, timeout time.Duration) { - tb.Helper() - - expected := len(clients) - 1 - deadline := time.After(timeout) - - for { - allGood := true - - for _, c := range clients { - nm := c.Netmap() - if nm == nil || len(nm.Peers) < expected { - allGood = false - - break - } - } - - if allGood { - // Final strict check. - AssertMeshComplete(tb, clients) - - return - } - - select { - case <-deadline: - // Report the failure with details. - for _, c := range clients { - nm := c.Netmap() - - got := 0 - if nm != nil { - got = len(nm.Peers) - } - - if got != expected { - tb.Errorf("EventuallyAssertMeshComplete: %s has %d peers, want %d (timeout %v)", - c.Name, got, expected, timeout) - } - } - - return - case <-time.After(100 * time.Millisecond): - // Poll again. - } - } -} diff --git a/hscontrol/servertest/client.go b/hscontrol/servertest/client.go index 5a9ff5a45..67232dd33 100644 --- a/hscontrol/servertest/client.go +++ b/hscontrol/servertest/client.go @@ -192,17 +192,40 @@ func (c *TestClient) register(tb testing.TB) { func (c *TestClient) startPoll(tb testing.TB) { tb.Helper() + c.startPollLoop() +} + +// startPollLoop creates a fresh poll context and launches the background +// [controlclient.Direct.PollNetMap] goroutine, which blocks until the +// context is cancelled or the server closes the connection. +func (c *TestClient) startPollLoop() { c.pollCtx, c.pollCancel = context.WithCancel(context.Background()) c.pollDone = make(chan struct{}) go func() { defer close(c.pollDone) - // [controlclient.Direct.PollNetMap] blocks until ctx is cancelled or the server closes - // the connection. + _ = c.direct.PollNetMap(c.pollCtx, c) }() } +// resetNetmapState clears the cached netmap and drains any pending +// updates from a previous session so that convergence waits observe +// only the new session's maps. +func (c *TestClient) resetNetmapState() { + c.mu.Lock() + c.netmap = nil + c.mu.Unlock() + + for { + select { + case <-c.updates: + default: + return + } + } +} + // UpdateFullNetmap implements [controlclient.NetmapUpdater]. // Called by [controlclient.Direct] when a new [netmap.NetworkMap] is received. func (c *TestClient) UpdateFullNetmap(nm *netmap.NetworkMap) { @@ -274,24 +297,11 @@ func (c *TestClient) Reconnect(tb testing.TB) { } } - // Clear stale netmap data so that callers like [TestClient.WaitForPeers] - // actually wait for the new session's map instead of returning - // immediately based on the old session's cached state. - c.mu.Lock() - c.netmap = nil - c.mu.Unlock() - - // Drain any pending updates from the old session so they - // don't satisfy a subsequent [TestClient.WaitForPeers]/[TestClient.WaitForUpdate]. - for { - select { - case <-c.updates: - default: - goto drained - } - } - -drained: + // Clear stale netmap data and drain pending updates so that callers + // like [TestClient.WaitForPeers] actually wait for the new session's + // map instead of returning immediately based on the old session's + // cached state. + c.resetNetmapState() // Re-register and start polling again. c.register(tb) @@ -343,30 +353,8 @@ func (c *TestClient) ReloginAndPoll(ctx context.Context) error { return fmt.Errorf("servertest: TryLogin(%s): unexpected auth URL %q (expected auto-auth with preauth key)", c.Name, url) //nolint:err113 } - // Clear stale netmap state from the previous session so that - // convergence waits observe only the new session's maps. - c.mu.Lock() - c.netmap = nil - c.mu.Unlock() - - for { - select { - case <-c.updates: - continue - default: - } - - break - } - - c.pollCtx, c.pollCancel = context.WithCancel(context.Background()) - c.pollDone = make(chan struct{}) - - go func() { - defer close(c.pollDone) - - _ = c.direct.PollNetMap(c.pollCtx, c) - }() + c.resetNetmapState() + c.startPollLoop() return nil } @@ -389,14 +377,7 @@ func (c *TestClient) RestartPoll(ctx context.Context) error { } } - c.pollCtx, c.pollCancel = context.WithCancel(context.Background()) - c.pollDone = make(chan struct{}) - - go func() { - defer close(c.pollDone) - - _ = c.direct.PollNetMap(c.pollCtx, c) - }() + c.startPollLoop() return nil } @@ -430,10 +411,25 @@ func (c *TestClient) Netmap() *netmap.NetworkMap { func (c *TestClient) WaitForPeers(tb testing.TB, n int, timeout time.Duration) { tb.Helper() + c.waitForPeers(tb, n, timeout, "WaitForPeers", func(got int) bool { return got >= n }) +} + +// waitForPeers blocks until match reports the current peer count +// satisfies the caller's predicate, or until timeout expires. op +// names the caller for the timeout failure message. +func (c *TestClient) waitForPeers( + tb testing.TB, + n int, + timeout time.Duration, + op string, + match func(got int) bool, +) { + tb.Helper() + deadline := time.After(timeout) for { - if nm := c.Netmap(); nm != nil && len(nm.Peers) >= n { + if nm := c.Netmap(); nm != nil && match(len(nm.Peers)) { return } @@ -448,7 +444,7 @@ func (c *TestClient) WaitForPeers(tb testing.TB, n int, timeout time.Duration) { got = len(nm.Peers) } - tb.Fatalf("servertest: WaitForPeers(%s, %d): timeout after %v (got %d peers)", c.Name, n, timeout, got) + tb.Fatalf("servertest: %s(%s, %d): timeout after %v (got %d peers)", op, c.Name, n, timeout, got) } } } @@ -552,27 +548,7 @@ func (c *TestClient) SelfName() string { func (c *TestClient) WaitForPeerCount(tb testing.TB, n int, timeout time.Duration) { tb.Helper() - deadline := time.After(timeout) - - for { - if nm := c.Netmap(); nm != nil && len(nm.Peers) == n { - return - } - - select { - case <-c.updates: - // Check again. - case <-deadline: - nm := c.Netmap() - - got := 0 - if nm != nil { - got = len(nm.Peers) - } - - tb.Fatalf("servertest: WaitForPeerCount(%s, %d): timeout after %v (got %d peers)", c.Name, n, timeout, got) - } - } + c.waitForPeers(tb, n, timeout, "WaitForPeerCount", func(got int) bool { return got == n }) } // WaitForCondition blocks until condFn returns true on the latest diff --git a/hscontrol/servertest/harness.go b/hscontrol/servertest/harness.go index 212c5412f..7461b1cb7 100644 --- a/hscontrol/servertest/harness.go +++ b/hscontrol/servertest/harness.go @@ -76,8 +76,7 @@ func NewHarness(tb testing.TB, numClients int, opts ...HarnessOption) *TestHarne for i := range numClients { name := clientName(i) - copts := append([]ClientOption{WithUser(user)}, hc.clientOpts...) - c := NewClient(tb, server, name, copts...) + c := h.newClient(tb, name, hc.clientOpts...) h.clients = append(h.clients, c) } @@ -124,13 +123,22 @@ func (h *TestHarness) AddClient(tb testing.TB, opts ...ClientOption) *TestClient tb.Helper() name := clientName(len(h.clients)) - copts := append([]ClientOption{WithUser(h.defaultUser)}, opts...) - c := NewClient(tb, h.Server, name, copts...) + c := h.newClient(tb, name, opts...) h.clients = append(h.clients, c) return c } +// newClient creates a [TestClient] on the harness server, prepending the +// shared default user so caller options can override it. +func (h *TestHarness) newClient(tb testing.TB, name string, opts ...ClientOption) *TestClient { + tb.Helper() + + copts := append([]ClientOption{WithUser(h.defaultUser)}, opts...) + + return NewClient(tb, h.Server, name, copts...) +} + // WaitForMeshComplete blocks until every connected client sees // (connectedCount - 1) peers. func (h *TestHarness) WaitForMeshComplete(tb testing.TB, timeout time.Duration) { @@ -145,13 +153,6 @@ func (h *TestHarness) WaitForMeshComplete(tb testing.TB, timeout time.Duration) } } -// WaitForConvergence waits until all connected clients have a -// non-nil [netmap.NetworkMap] and their peer counts have stabilised. -func (h *TestHarness) WaitForConvergence(tb testing.TB, timeout time.Duration) { - tb.Helper() - h.WaitForMeshComplete(tb, timeout) -} - // ChangePolicy sets an ACL policy on the server and propagates changes // to all connected nodes. The policy should be a valid HuJSON policy document. func (h *TestHarness) ChangePolicy(tb testing.TB, policy []byte) { diff --git a/hscontrol/servertest/server.go b/hscontrol/servertest/server.go index fd20c519e..9601694cc 100644 --- a/hscontrol/servertest/server.go +++ b/hscontrol/servertest/server.go @@ -5,6 +5,7 @@ package servertest import ( + "context" "net" "net/http" "net/netip" @@ -43,6 +44,7 @@ type serverConfig struct { nodeExpiry time.Duration batcherWorkers int taildropEnabled bool + realListener bool } func defaultServerConfig() *serverConfig { @@ -82,6 +84,14 @@ func WithNodeExpiry(d time.Duration) ServerOption { return func(c *serverConfig) { c.nodeExpiry = d } } +// WithRealListener binds the HTTP API to a real loopback TCP port instead of +// the in-process memnet, so external processes — the Tailscale SDK over a real +// socket, tscli, OpenTofu — can reach the API. The Noise/[TestClient] control +// path still uses memnet, so this is for REST/API tests only. +func WithRealListener() ServerOption { + return func(c *serverConfig) { c.realListener = true } +} + // WithTaildropEnabled toggles the Taildrop file-sharing feature. // Defaults to true to match production. Pass false to verify // behaviour when an operator has switched the toggle off — e.g. @@ -164,13 +174,24 @@ func NewServer(tb testing.TB, opts ...ServerOption) *TestServer { app.StartBatcherForTest(tb) app.StartEphemeralGCForTest(tb) - // Start the HTTP server over an in-memory network so that all - // TCP connections stay in-process. - var memNetwork memnet.Network + // Start the HTTP server. By default it binds an in-memory network so all + // TCP connections stay in-process; WithRealListener swaps in a real loopback + // port so external binaries can connect. + var ( + memNetwork memnet.Network + ln net.Listener + ) + + if sc.realListener { + var lc net.ListenConfig + + ln, err = lc.Listen(context.Background(), "tcp", "127.0.0.1:0") + } else { + ln, err = memNetwork.Listen("tcp", "127.0.0.1:443") + } - ln, err := memNetwork.Listen("tcp", "127.0.0.1:443") if err != nil { - tb.Fatalf("servertest: memnet Listen: %v", err) + tb.Fatalf("servertest: Listen: %v", err) } httpServer := &http.Server{ @@ -233,43 +254,71 @@ func (s *TestServer) CreateUser(tb testing.TB, name string) *types.User { return u } +// CreateAPIKey mints an API key string (the Bearer/Basic credential). When +// owner is non-nil the key is owned by that user, so the v2 API mints +// user-owned (untagged) auth keys on its behalf. +func (s *TestServer) CreateAPIKey(tb testing.TB, owner *types.User) string { + tb.Helper() + + keyStr, key, err := s.st.CreateAPIKey(nil) + if err != nil { + tb.Fatalf("servertest: CreateAPIKey: %v", err) + } + + if owner != nil { + err := s.st.SetAPIKeyUser(key.ID, types.UserID(owner.ID)) + if err != nil { + tb.Fatalf("servertest: SetAPIKeyUser: %v", err) + } + } + + return keyStr +} + +// CreateRegisteredNode mints a registered node (allocated IPs, registered +// method) in BOTH the database and the in-memory NodeStore, then returns its +// view. The v2 device endpoints resolve nodes via State.GetNodeByID, which reads +// the NodeStore, so a DB-only node would be invisible to them. +func (s *TestServer) CreateRegisteredNode(tb testing.TB, owner *types.User, hostname ...string) types.NodeView { + tb.Helper() + + node := s.st.CreateRegisteredNodeForTest(owner, hostname...) + // CreateRegisteredNodeForTest sets UserID but leaves the User association + // unloaded; the device response renders the owner login, so attach it. + node.User = owner + + return s.st.PutNodeInStoreForTest(*node) +} + // CreatePreAuthKey creates a reusable pre-auth key for the given user. func (s *TestServer) CreatePreAuthKey(tb testing.TB, userID types.UserID) string { tb.Helper() - - uid := userID - - pak, err := s.st.CreatePreAuthKey(&uid, true, false, nil, nil) - if err != nil { - tb.Fatalf("servertest: CreatePreAuthKey: %v", err) - } - - return pak.Key + return s.createPreAuthKey(tb, userID, true, false, nil) } // CreateTaggedPreAuthKey creates a reusable pre-auth key with ACL tags. func (s *TestServer) CreateTaggedPreAuthKey(tb testing.TB, userID types.UserID, tags []string) string { tb.Helper() - - uid := userID - - pak, err := s.st.CreatePreAuthKey(&uid, true, false, nil, tags) - if err != nil { - tb.Fatalf("servertest: CreateTaggedPreAuthKey: %v", err) - } - - return pak.Key + return s.createPreAuthKey(tb, userID, true, false, tags) } // CreateEphemeralPreAuthKey creates an ephemeral pre-auth key. func (s *TestServer) CreateEphemeralPreAuthKey(tb testing.TB, userID types.UserID) string { tb.Helper() + return s.createPreAuthKey(tb, userID, false, true, nil) +} - uid := userID +func (s *TestServer) createPreAuthKey( + tb testing.TB, + userID types.UserID, + reusable, ephemeral bool, + tags []string, +) string { + tb.Helper() - pak, err := s.st.CreatePreAuthKey(&uid, false, true, nil, nil) + pak, err := s.st.CreatePreAuthKey(&userID, reusable, ephemeral, nil, tags) if err != nil { - tb.Fatalf("servertest: CreateEphemeralPreAuthKey: %v", err) + tb.Fatalf("servertest: createPreAuthKey: %v", err) } return pak.Key diff --git a/hscontrol/state/auth_nodekey_binding_test.go b/hscontrol/state/auth_nodekey_binding_test.go new file mode 100644 index 000000000..8f826df7c --- /dev/null +++ b/hscontrol/state/auth_nodekey_binding_test.go @@ -0,0 +1,74 @@ +package state + +import ( + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/db" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" +) + +// TestPreAuthKeyReauthRejectsVictimNodeKey ensures the PAK re-registration +// rotation path enforces the same 1:1 NodeKey<->MachineKey binding the other +// registration paths do. NodeKeys are public (peers learn them from the +// netmap), so an authenticated attacker must not be able to rotate their own +// node's NodeKey onto a victim's NodeKey: doing so poisons the NodeStore +// NodeKey index and denies the victim service. +func TestPreAuthKeyReauthRejectsVictimNodeKey(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + database, err := db.NewHeadscaleDatabase(cfg) + require.NoError(t, err) + + // Attacker owns N_a under U_a with machine key M_a. + attacker := database.CreateUserForTest("attacker") + attackerNode := database.CreateRegisteredNodeForTest(attacker, "attacker-node") + attackerMachineKey := attackerNode.MachineKey + + // Victim owns N_v under U_v with a distinct, public NodeKey K_v. + victim := database.CreateUserForTest("victim") + victimNode := database.CreateRegisteredNodeForTest(victim, "victim-node") + victimNodeKey := victimNode.NodeKey + + require.NotEqual(t, attackerNode.NodeKey, victimNodeKey, + "precondition: attacker and victim must have distinct NodeKeys") + require.NotEqual(t, attackerNode.MachineKey, victimNode.MachineKey, + "precondition: attacker and victim must have distinct MachineKeys") + + require.NoError(t, database.Close()) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + // Attacker mints their own valid pre-auth key for their own user. + attackerUserID := types.UserID(attacker.ID) + pak, err := s.CreatePreAuthKey(&attackerUserID, true, false, nil, nil) + require.NoError(t, err) + + // Attacker re-registers over their own noise session (machine key M_a) + // with a valid PAK but carries the victim's NodeKey K_v and a non-past + // expiry (skipping the logout-first branch). + clientExpiry := time.Now().Add(180 * 24 * time.Hour) + regReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: victimNodeKey, + Expiry: clientExpiry, + Hostinfo: &tailcfg.Hostinfo{ + Hostname: "attacker-node", + }, + } + + _, _, err = s.HandleNodeFromPreAuthKey(regReq, attackerMachineKey) + require.ErrorIs(t, err, ErrNodeKeyInUse, + "attacker must not be able to claim the victim's NodeKey via PAK re-registration") + + // The victim's NodeKey index must still resolve to the victim's node. + resolved, ok := s.nodeStore.GetNodeByNodeKey(victimNodeKey) + require.True(t, ok, "victim NodeKey must still be present in the index") + require.Equal(t, victimNode.MachineKey, resolved.MachineKey(), + "victim NodeKey must remain bound to the victim's MachineKey") +} diff --git a/hscontrol/state/debug.go b/hscontrol/state/debug.go index 3fbd0902e..91a9c4493 100644 --- a/hscontrol/state/debug.go +++ b/hscontrol/state/debug.go @@ -62,51 +62,23 @@ type DebugStringInfo struct { // DebugOverview returns a comprehensive overview of the current state for debugging. func (s *State) DebugOverview() string { - allNodes := s.nodeStore.ListNodes() - users, _ := s.ListAllUsers() + info := s.DebugOverviewJSON() var sb strings.Builder sb.WriteString("=== Headscale State Overview ===\n\n") // Node statistics - fmt.Fprintf(&sb, "Nodes: %d total\n", allNodes.Len()) - - userNodeCounts := make(map[string]int) - onlineCount := 0 - expiredCount := 0 - ephemeralCount := 0 - - now := time.Now() - - for _, node := range allNodes.All() { - if node.Valid() { - userName := node.Owner().Name() - userNodeCounts[userName]++ - - if node.IsOnline().Valid() && node.IsOnline().Get() { - onlineCount++ - } - - if node.Expiry().Valid() && node.Expiry().Get().Before(now) { - expiredCount++ - } - - if node.AuthKey().Valid() && node.AuthKey().Ephemeral() { - ephemeralCount++ - } - } - } - - fmt.Fprintf(&sb, " - Online: %d\n", onlineCount) - fmt.Fprintf(&sb, " - Expired: %d\n", expiredCount) - fmt.Fprintf(&sb, " - Ephemeral: %d\n", ephemeralCount) + fmt.Fprintf(&sb, "Nodes: %d total\n", info.Nodes.Total) + fmt.Fprintf(&sb, " - Online: %d\n", info.Nodes.Online) + fmt.Fprintf(&sb, " - Expired: %d\n", info.Nodes.Expired) + fmt.Fprintf(&sb, " - Ephemeral: %d\n", info.Nodes.Ephemeral) sb.WriteString("\n") // User statistics - fmt.Fprintf(&sb, "Users: %d total\n", len(users)) + fmt.Fprintf(&sb, "Users: %d total\n", info.TotalUsers) - for userName, nodeCount := range userNodeCounts { + for userName, nodeCount := range info.Users { fmt.Fprintf(&sb, " - %s: %d nodes\n", userName, nodeCount) } @@ -114,18 +86,17 @@ func (s *State) DebugOverview() string { // Policy information sb.WriteString("Policy:\n") - fmt.Fprintf(&sb, " - Mode: %s\n", s.cfg.Policy.Mode) + fmt.Fprintf(&sb, " - Mode: %s\n", info.Policy.Mode) - if s.cfg.Policy.Mode == types.PolicyModeFile { - fmt.Fprintf(&sb, " - Path: %s\n", s.cfg.Policy.Path) + if info.Policy.Mode == string(types.PolicyModeFile) { + fmt.Fprintf(&sb, " - Path: %s\n", info.Policy.Path) } sb.WriteString("\n") // DERP information - derpMap := s.derpMap.Load() - if derpMap != nil { - fmt.Fprintf(&sb, "DERP: %d regions configured\n", len(derpMap.Regions)) + if info.DERP.Configured { + fmt.Fprintf(&sb, "DERP: %d regions configured\n", info.DERP.Regions) } else { sb.WriteString("DERP: not configured\n") } @@ -133,14 +104,7 @@ func (s *State) DebugOverview() string { sb.WriteString("\n") // Route information - primaryStr := s.PrimaryRoutesString() - - routeCount := len(strings.Split(strings.TrimSpace(primaryStr), "\n")) - if primaryStr == "" { - routeCount = 0 - } - - fmt.Fprintf(&sb, "Primary Routes: %d active\n", routeCount) + fmt.Fprintf(&sb, "Primary Routes: %d active\n", info.PrimaryRoutes) sb.WriteString("\n") // Registration cache @@ -371,14 +335,7 @@ func (s *State) DebugOverviewJSON() DebugOverviewInfo { } // Route information - primaryStr := s.PrimaryRoutesString() - - routeCount := len(strings.Split(strings.TrimSpace(primaryStr), "\n")) - if primaryStr == "" { - routeCount = 0 - } - - info.PrimaryRoutes = routeCount + info.PrimaryRoutes = len(s.nodeStore.PrimaryRoutes()) return info } diff --git a/hscontrol/state/ha_health.go b/hscontrol/state/ha_health.go index e6482b52c..4e76e9a35 100644 --- a/hscontrol/state/ha_health.go +++ b/hscontrol/state/ha_health.go @@ -74,16 +74,24 @@ func (p *HAHealthProber) ProbeOnce( ) { haNodes := p.state.nodeStore.HANodes() - // Drop stable-session entries for nodes that are no longer HA - // candidates so a future reappearance starts fresh. + // Build the deduplicated node-ID set and slice in one pass. + var nodeIDs []types.NodeID + seen := make(set.Set[types.NodeID]) for _, nodes := range haNodes { for _, id := range nodes { + if seen.Contains(id) { + continue + } + seen.Add(id) + nodeIDs = append(nodeIDs, id) } } + // Drop stable-session entries for nodes that are no longer HA + // candidates so a future reappearance starts fresh. p.lastStableSession.Range(func(id types.NodeID, _ uint64) bool { if !seen.Contains(id) { p.lastStableSession.Delete(id) @@ -96,20 +104,6 @@ func (p *HAHealthProber) ProbeOnce( return } - // Deduplicate node IDs across prefixes. - var nodeIDs []types.NodeID - - dedup := make(set.Set[types.NodeID]) - - for _, nodes := range haNodes { - for _, id := range nodes { - if !dedup.Contains(id) { - dedup.Add(id) - nodeIDs = append(nodeIDs, id) - } - } - } - log.Debug(). Int("haNodes", len(nodeIDs)). Msg("HA health prober starting probe cycle") diff --git a/hscontrol/state/node_health.go b/hscontrol/state/node_health.go new file mode 100644 index 000000000..4edaf3986 --- /dev/null +++ b/hscontrol/state/node_health.go @@ -0,0 +1,92 @@ +package state + +import ( + "fmt" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util/zlog/zf" + "github.com/rs/zerolog/log" +) + +// nodeHealthCheck names a class of stored-node-data defect that breaks normal +// operation and explains how to fix it. ok == true means the node passes the +// check. This is the extension point for node-data validation: add a check +// here as new corrupt-data classes surface (nil hostinfo, invalid IPs, +// tags-XOR-user violations, ...) and both the boot scan and any future caller +// run the whole set. +type nodeHealthCheck struct { + name string + check func(nv types.NodeView, cfg *types.Config) (problem, fixHint string, ok bool) +} + +// nodeHealthChecks is the registry of node-data health checks. Today it carries +// the one issue #3346 needs; append to it rather than reshaping callers. +var nodeHealthChecks = []nodeHealthCheck{givenNameMapsToValidFQDN} + +// givenNameMapsToValidFQDN flags a node whose stored GivenName cannot produce a +// valid FQDN (empty, or longer than MaxHostnameLength once base_domain is +// applied). Such a node cannot be rendered into a netmap — neither its own nor +// any peer's — so it must be renamed to recover. +var givenNameMapsToValidFQDN = nodeHealthCheck{ + name: "given-name-maps-to-valid-fqdn", + check: func(nv types.NodeView, cfg *types.Config) (string, string, bool) { + err := types.ValidateGivenName(nv.GivenName(), cfg.BaseDomain) + if err != nil { + return err.Error(), fmt.Sprintf("headscale nodes rename %d ", nv.ID()), false + } + + return "", "", true + }, +} + +// nodeHealthFinding is a single failed check for a single node. +type nodeHealthFinding struct { + nodeID types.NodeID + hostname string + check string + problem string + fixHint string +} + +// scanNodeHealth runs every registered check against every node in the store +// and returns one finding per failure. It only reports — it never mutates a +// node — so an operator can repair the underlying data without the server +// silently rewriting a user-visible name. +func (s *State) scanNodeHealth() []nodeHealthFinding { + var findings []nodeHealthFinding + + for _, nv := range s.nodeStore.ListNodes().All() { + for _, c := range nodeHealthChecks { + problem, fixHint, ok := c.check(nv, s.cfg) + if ok { + continue + } + + findings = append(findings, nodeHealthFinding{ + nodeID: nv.ID(), + hostname: nv.Hostname(), + check: c.name, + problem: problem, + fixHint: fixHint, + }) + } + } + + return findings +} + +// logNodeHealth scans the store once and logs an actionable warning per +// finding. Called at startup so an operator learns — by node id and fix +// command — about stored data that will break map generation, without the +// server changing anything itself. +func (s *State) logNodeHealth() { + for _, f := range s.scanNodeHealth() { + log.Warn(). + Uint64(zf.NodeID, f.nodeID.Uint64()). + Str(zf.NodeHostname, f.hostname). + Str("check", f.check). + Str("problem", f.problem). + Str("fix", f.fixHint). + Msg("node has invalid data that breaks map generation; rename it to restore connectivity") + } +} diff --git a/hscontrol/state/node_health_test.go b/hscontrol/state/node_health_test.go new file mode 100644 index 000000000..cd61a25b8 --- /dev/null +++ b/hscontrol/state/node_health_test.go @@ -0,0 +1,67 @@ +package state + +import ( + "testing" + + "github.com/juanfont/headscale/hscontrol/db" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/require" +) + +func TestGivenNameMapsToValidFQDNCheck(t *testing.T) { + cfg := &types.Config{BaseDomain: "example.com"} + + _, _, ok := givenNameMapsToValidFQDN.check((&types.Node{ID: 1, GivenName: "valid"}).View(), cfg) + require.True(t, ok, "a valid given name must pass the check") + + problem, fixHint, ok := givenNameMapsToValidFQDN.check((&types.Node{ID: 7, GivenName: ""}).View(), cfg) + require.False(t, ok, "an empty given name must fail the check") + require.NotEmpty(t, problem) + require.Contains(t, fixHint, "rename 7", "fix hint must name the offending node") +} + +// TestScanNodeHealthReportsInvalidNameWithoutMutating proves the boot scan +// reports a node whose stored name would break map generation (issue #3346) +// with an actionable fix, and that it never rewrites the stored name — the +// maintainer's decision is log-only, no silent mutation. +func TestScanNodeHealthReportsInvalidNameWithoutMutating(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + database, err := db.NewHeadscaleDatabase(cfg) + require.NoError(t, err) + + user := database.CreateUserForTest("scan-user") + bad := database.CreateRegisteredNodeForTest(user, "scan-bad") + good := database.CreateRegisteredNodeForTest(user, "scan-good") + + require.NoError(t, database.DB. + Model(&types.Node{}). + Where("id = ?", bad.ID). + Update("given_name", "").Error) + require.NoError(t, database.Close()) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + findings := s.scanNodeHealth() + + var badFinding *nodeHealthFinding + + for i := range findings { + require.NotEqual(t, good.ID, findings[i].nodeID, "a valid node must not be reported") + + if findings[i].nodeID == bad.ID { + badFinding = &findings[i] + } + } + + require.NotNil(t, badFinding, "a node with an invalid name must be reported") + require.Contains(t, badFinding.fixHint, "rename", "finding must carry an actionable fix") + + // Log-only: neither the scan nor boot may rewrite the stored name. + nv, ok := s.GetNodeByID(bad.ID) + require.True(t, ok) + require.Empty(t, nv.GivenName(), "boot scan must not mutate the stored name") +} diff --git a/hscontrol/state/node_store.go b/hscontrol/state/node_store.go index e1e17a89e..cece54edd 100644 --- a/hscontrol/state/node_store.go +++ b/hscontrol/state/node_store.go @@ -527,33 +527,24 @@ func (s *NodeStore) applyBatch(batch []work) { // Update node count gauge nodeStoreNodesCount.Set(float64(len(nodes))) - // Send the resulting nodes to all work items that requested them + // Send the resulting nodes to all work items that requested them. + // A zero-value NodeView{} reports Valid()==false, matching node.View() + // for a node that was deleted or never existed. for nodeID, workItems := range nodeResultRequests { + var nodeView types.NodeView if node, exists := nodes[nodeID]; exists { - nodeView := node.View() - for _, w := range workItems { - w.nodeResult <- nodeView + nodeView = node.View() + } - close(w.nodeResult) + for _, w := range workItems { + w.nodeResult <- nodeView - if w.errResult != nil { - w.errResult <- setErrResults[w] + close(w.nodeResult) - close(w.errResult) - } - } - } else { - // Node was deleted or doesn't exist - for _, w := range workItems { - w.nodeResult <- types.NodeView{} // Send invalid view + if w.errResult != nil { + w.errResult <- setErrResults[w] - close(w.nodeResult) - - if w.errResult != nil { - w.errResult <- setErrResults[w] - - close(w.errResult) - } + close(w.errResult) } } } @@ -669,26 +660,13 @@ func snapshotFromNodes( return newSnap } -// electPrimaryRoutes picks the primary advertiser for each non-exit -// prefix. Inputs are restricted to online nodes that advertise the -// prefix. The previous primary is preserved when it is still online -// and healthy (anti-flap); otherwise the lowest-NodeID healthy -// advertiser wins. When every advertiser is unhealthy the previous -// primary is preserved only if still a candidate — falling back to -// any other candidate would point peers at a node the prober has -// already declared unreachable, so leaving the prefix unmapped is -// preferred until a probe cycle finds one that responds. -func electPrimaryRoutes( +// onlineAdvertisers maps each non-exit prefix to the IDs of online nodes +// that approve it. Nodes are visited in the order of ids, so the per-prefix +// slices preserve that order. +func onlineAdvertisers( nodes map[types.NodeID]types.Node, - prev map[netip.Prefix]types.NodeID, -) (map[netip.Prefix]types.NodeID, map[types.NodeID]bool) { - ids := make([]types.NodeID, 0, len(nodes)) - for id := range nodes { - ids = append(ids, id) - } - - slices.Sort(ids) - + ids []types.NodeID, +) map[netip.Prefix][]types.NodeID { advertisers := make(map[netip.Prefix][]types.NodeID) for _, id := range ids { @@ -706,6 +684,24 @@ func electPrimaryRoutes( } } + return advertisers +} + +// electPrimaryRoutes picks the primary advertiser for each non-exit +// prefix. Inputs are restricted to online nodes that advertise the +// prefix. The previous primary is preserved when it is still online +// and healthy (anti-flap); otherwise the lowest-NodeID healthy +// advertiser wins. When every advertiser is unhealthy the previous +// primary is preserved only if still a candidate — falling back to +// any other candidate would point peers at a node the prober has +// already declared unreachable, so leaving the prefix unmapped is +// preferred until a probe cycle finds one that responds. +func electPrimaryRoutes( + nodes map[types.NodeID]types.Node, + prev map[netip.Prefix]types.NodeID, +) (map[netip.Prefix]types.NodeID, map[types.NodeID]bool) { + advertisers := onlineAdvertisers(nodes, slices.Sorted(maps.Keys(nodes))) + routes := make(map[netip.Prefix]types.NodeID, len(advertisers)) for prefix, candidates := range advertisers { if cur, ok := prev[prefix]; ok && @@ -734,7 +730,7 @@ func electPrimaryRoutes( // would point peers at a node the prober has already declared // unreachable; leaving the prefix unmapped is honest until a // probe cycle picks one that responds. - if !found && len(candidates) >= 1 { + if !found { if cur, ok := prev[prefix]; ok && slices.Contains(candidates, cur) { selected = cur found = true @@ -919,21 +915,10 @@ func (s *NodeStore) PrimaryRoutesForNode(id types.NodeID) []netip.Prefix { func (s *NodeStore) HANodes() map[netip.Prefix][]types.NodeID { snap := s.data.Load() - advertisers := make(map[netip.Prefix][]types.NodeID) - - for id, n := range snap.nodesByID { - if n.IsOnline == nil || !*n.IsOnline { - continue - } - - for _, p := range n.AllApprovedRoutes() { - if tsaddr.IsExitRoute(p) { - continue - } - - advertisers[p] = append(advertisers[p], id) - } - } + advertisers := onlineAdvertisers( + snap.nodesByID, + slices.Sorted(maps.Keys(snap.nodesByID)), + ) out := make(map[netip.Prefix][]types.NodeID) @@ -942,7 +927,6 @@ func (s *NodeStore) HANodes() map[netip.Prefix][]types.NodeID { continue } - slices.Sort(ids) out[p] = ids } diff --git a/hscontrol/state/ping.go b/hscontrol/state/ping.go index 9406e0e1a..2f688b946 100644 --- a/hscontrol/state/ping.go +++ b/hscontrol/state/ping.go @@ -5,7 +5,7 @@ import ( "time" "github.com/juanfont/headscale/hscontrol/types" - "github.com/juanfont/headscale/hscontrol/util" + "tailscale.com/util/rands" ) const pingIDLength = 16 @@ -35,7 +35,7 @@ func newPingTracker() *pingTracker { // channel that receives the round-trip latency once the response // arrives. func (pt *pingTracker) register(nodeID types.NodeID) (string, <-chan time.Duration) { - pingID, _ := util.GenerateRandomStringDNSSafe(pingIDLength) + pingID := rands.HexString(pingIDLength) ch := make(chan time.Duration, 1) pt.mu.Lock() diff --git a/hscontrol/state/rename_test.go b/hscontrol/state/rename_test.go new file mode 100644 index 000000000..b141147be --- /dev/null +++ b/hscontrol/state/rename_test.go @@ -0,0 +1,40 @@ +package state + +import ( + "strings" + "testing" + + "github.com/juanfont/headscale/hscontrol/db" + "github.com/stretchr/testify/require" +) + +// TestRenameNodeRejectsNameExceedingFQDNLimit proves RenameNode rejects a name +// that is a valid DNS label but whose FQDN, under the configured base_domain, +// exceeds MaxHostnameLength. Without the FQDN-length gate such a name persists +// and then breaks map generation for the node and its peers (issue #3346): +// admin-facing writes must not be able to introduce an unmappable name. +func TestRenameNodeRejectsNameExceedingFQDNLimit(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + // A long base domain so a 63-char label overflows the 255-char FQDN bound. + cfg.BaseDomain = strings.Repeat("b", 200) + ".example.com" + + database, err := db.NewHeadscaleDatabase(cfg) + require.NoError(t, err) + + user := database.CreateUserForTest("rename-user") + node := database.CreateRegisteredNodeForTest(user, "rename-node") + require.NoError(t, database.Close()) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + // Valid 63-char DNS label, but the resulting FQDN exceeds 255 chars. + _, _, err = s.RenameNode(node.ID, strings.Repeat("a", 63)) + require.Error(t, err, "rename to a name whose FQDN exceeds the limit must be rejected") + + // A short, valid name is still accepted. + _, _, err = s.RenameNode(node.ID, "short") + require.NoError(t, err) +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index edbed2e5d..ddb410eb7 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -208,10 +208,7 @@ func (s *State) lockRegistration(machineKey key.MachinePublic) func() { // NewState creates and initializes a new [State] instance, setting up the database, // IP allocator, DERP map, policy manager, and loading existing users and nodes. func NewState(cfg *types.Config) (*State, error) { - cacheExpiration := registerCacheExpiration - if cfg.Tuning.RegisterCacheExpiration != 0 { - cacheExpiration = cfg.Tuning.RegisterCacheExpiration - } + cacheExpiration := cmp.Or(cfg.Tuning.RegisterCacheExpiration, registerCacheExpiration) cacheMaxEntries := defaultRegisterCacheMaxEntries if cfg.Tuning.RegisterCacheMaxEntries > 0 { @@ -247,7 +244,7 @@ func NewState(cfg *types.Config) (*State, error) { node.IsOnline = new(false) } - users, err := db.ListUsers() + users, err := db.ListUsers(nil) if err != nil { return nil, fmt.Errorf("loading users: %w", err) } @@ -264,15 +261,9 @@ func NewState(cfg *types.Config) (*State, error) { // Apply defaults for [NodeStore] batch configuration if not set. // This ensures tests that create Config directly (without viper) still work. - batchSize := cfg.Tuning.NodeStoreBatchSize - if batchSize == 0 { - batchSize = defaultNodeStoreBatchSize - } + batchSize := cmp.Or(cfg.Tuning.NodeStoreBatchSize, defaultNodeStoreBatchSize) - batchTimeout := cfg.Tuning.NodeStoreBatchTimeout - if batchTimeout == 0 { - batchTimeout = defaultNodeStoreBatchTimeout - } + batchTimeout := cmp.Or(cfg.Tuning.NodeStoreBatchTimeout, defaultNodeStoreBatchTimeout) // [policy.PolicyManager.BuildPeerMap] handles both global and per-node filter complexity. // This moves the complex peer relationship logic into the policy package where it belongs. @@ -286,7 +277,7 @@ func NewState(cfg *types.Config) (*State, error) { ) nodeStore.Start() - return &State{ + s := &State{ cfg: cfg, db: db, @@ -298,7 +289,14 @@ func NewState(cfg *types.Config) (*State, error) { sshCheckAuth: make(map[sshCheckPair]time.Time), registerLocks: xsync.NewMap[key.MachinePublic, *sync.Mutex](), - }, nil + } + + // Surface nodes whose stored data would break map generation (e.g. an + // invalid given name from a legacy row) so an operator can fix them. This + // only logs; it never mutates a node's stored name at boot. + s.logNodeHealth() + + return s, nil } // Close gracefully shuts down the [State] instance and releases all resources. @@ -508,7 +506,7 @@ func (s *State) ListUsersWithFilter(filter *types.User) ([]types.User, error) { // ListAllUsers retrieves all users in the system. func (s *State) ListAllUsers() ([]types.User, error) { - return s.db.ListUsers() + return s.db.ListUsers(nil) } // persistNodeRowToDB writes the node's database row, re-reading the @@ -644,7 +642,10 @@ func (s *State) Connect(id types.NodeID) ([]change.Change, uint64) { return nil, 0 } - c := []change.Change{change.NodeOnlineFor(node)} + // A node coming online sends a lightweight online peer patch. Subnet + // routers, relay targets, and via targets get their full peer recompute + // from the gated PolicyChange below, so no full update is needed here. + c := []change.Change{change.NodeOnline(node.ID())} log.Info().EmbedObject(node).Msg("node connected") @@ -727,7 +728,10 @@ func (s *State) Disconnect(id types.NodeID, epoch uint64) ([]change.Change, erro // An ordinary node going offline just sends the lightweight offline // patch; emitting a PolicyChange for it would force every peer to // rebuild its netmap on every disconnect. - cs := []change.Change{change.NodeOfflineFor(node), c} + // A node going offline sends a lightweight offline peer patch. Subnet + // routers and other recompute-forcing nodes rely on the gated + // PolicyChange below for the peer recompute, so no full update here. + cs := []change.Change{change.NodeOffline(node.ID()), c} if s.polMan.NodeNeedsPeerRecompute(node) { cs = append(cs, change.PolicyChange()) } @@ -771,6 +775,16 @@ func (s *State) ResolveNode(query string) (types.NodeView, bool) { return s.GetNodeByID(id) } + // keepLowest returns whichever node has the lower ID, so repeated + // calls resolve deterministically across snapshot iterations. + keepLowest := func(cur, cand types.NodeView) types.NodeView { + if !cur.Valid() || cand.ID() < cur.ID() { + return cand + } + + return cur + } + // Try IP address. addr, addrErr := netip.ParseAddr(query) if addrErr == nil { @@ -781,9 +795,7 @@ func (s *State) ResolveNode(query string) (types.NodeView, bool) { continue } - if !match.Valid() || n.ID() < match.ID() { - match = n - } + match = keepLowest(match, n) } return match, match.Valid() @@ -795,13 +807,9 @@ func (s *State) ResolveNode(query string) (types.NodeView, bool) { for _, n := range s.ListNodes().All() { if n.GivenName() == query { - if !givenMatch.Valid() || n.ID() < givenMatch.ID() { - givenMatch = n - } + givenMatch = keepLowest(givenMatch, n) } else if n.Hostname() == query { - if !hostMatch.Valid() || n.ID() < hostMatch.ID() { - hostMatch = n - } + hostMatch = keepLowest(hostMatch, n) } } @@ -1031,9 +1039,12 @@ func (s *State) SetApprovedRoutes(nodeID types.NodeID, routes []netip.Prefix) (t // auto-sanitisation) and collisions error out rather than silently // bumping a user-facing label. See HOSTNAME.md for the CLI contract. func (s *State) RenameNode(nodeID types.NodeID, newName string) (types.NodeView, change.Change, error) { - err := dnsname.ValidLabel(newName) + // Validate the label AND that the resulting FQDN fits MaxHostnameLength: + // a valid 63-char label can still overflow under a long base_domain, and + // an unmappable name would break this node and its peers (issue #3346). + err := types.ValidateGivenName(newName, s.cfg.BaseDomain) if err != nil { - return types.NodeView{}, change.Change{}, fmt.Errorf("renaming node: %w", err) + return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %w", ErrGivenNameInvalid, err) } view, err := s.nodeStore.SetGivenName(nodeID, newName) @@ -1372,6 +1383,17 @@ func (s *State) ValidateAPIKey(keyStr string) (bool, error) { return s.db.ValidateAPIKey(keyStr) } +// AuthenticateAPIKey validates an API key and returns it (with its owning +// user), so callers like the v2 API can act as the key's owner. +func (s *State) AuthenticateAPIKey(keyStr string) (*types.APIKey, error) { + return s.db.AuthenticateAPIKey(keyStr) +} + +// SetAPIKeyUser sets the owning user of an API key by its database ID. +func (s *State) SetAPIKeyUser(keyID uint64, userID types.UserID) error { + return s.db.SetAPIKeyUser(keyID, userID) +} + // CreateAPIKey generates a new API key with optional expiration. func (s *State) CreateAPIKey(expiration *time.Time) (string, *types.APIKey, error) { return s.db.CreateAPIKey(expiration) @@ -1456,9 +1478,27 @@ func (s *State) DB() *hsdb.HSDatabase { return s.db } -// GetPreAuthKey retrieves a pre-authentication key by ID. -func (s *State) GetPreAuthKey(id string) (*types.PreAuthKey, error) { - return s.db.GetPreAuthKey(id) +// GetPreAuthKey retrieves a pre-authentication key by its secret. The caller is +// responsible for checking whether the key is usable (expired or used). +func (s *State) GetPreAuthKey(keyStr string) (*types.PreAuthKey, error) { + return s.db.GetPreAuthKey(keyStr) +} + +// GetPreAuthKeyByID retrieves a pre-authentication key by its database id. +func (s *State) GetPreAuthKeyByID(id uint64) (*types.PreAuthKey, error) { + return s.db.GetPreAuthKeyByID(id) +} + +// RevokePreAuthKey soft-revokes a pre-authentication key: it is kept and stays +// retrievable (invalid) until the collector reaps it after the retention window. +func (s *State) RevokePreAuthKey(id uint64) error { + return s.db.RevokePreAuthKey(id) +} + +// DestroyRevokedPreAuthKeysBefore hard-deletes pre-auth keys revoked before +// cutoff, returning how many were removed. +func (s *State) DestroyRevokedPreAuthKeysBefore(cutoff time.Time) (int, error) { + return s.db.DestroyRevokedPreAuthKeysBefore(cutoff) } // ListPreAuthKeys returns all pre-authentication keys for a user. @@ -1466,6 +1506,11 @@ func (s *State) ListPreAuthKeys() ([]types.PreAuthKey, error) { return s.db.ListPreAuthKeys() } +// SetPreAuthKeyDescription sets the free-text description on a pre-auth key. +func (s *State) SetPreAuthKeyDescription(id uint64, description string) error { + return s.db.SetPreAuthKeyDescription(id, description) +} + // ExpirePreAuthKey marks a pre-authentication key as expired. func (s *State) ExpirePreAuthKey(id uint64) error { return s.db.ExpirePreAuthKey(id) @@ -1476,6 +1521,63 @@ func (s *State) DeletePreAuthKey(id uint64) error { return s.db.DeletePreAuthKey(id) } +// CreateOAuthClient creates a new OAuth client-credentials client, returning the +// plaintext secret (shown once) and the stored client. +func (s *State) CreateOAuthClient(scopes, tags []string, description string, creatorUserID *uint) (string, *types.OAuthClient, error) { + return s.db.CreateOAuthClient(scopes, tags, description, creatorUserID) +} + +// AuthenticateOAuthClient validates a client secret and returns the client. +func (s *State) AuthenticateOAuthClient(secret string) (*types.OAuthClient, error) { + return s.db.AuthenticateOAuthClient(secret) +} + +// GetOAuthClientByClientID returns an OAuth client by its public client id. +func (s *State) GetOAuthClientByClientID(clientID string) (*types.OAuthClient, error) { + return s.db.GetOAuthClientByClientID(clientID) +} + +// ListOAuthClients returns every OAuth client. +func (s *State) ListOAuthClients() ([]types.OAuthClient, error) { + return s.db.ListOAuthClients() +} + +// RevokeOAuthClient deletes a client and the access tokens it issued. +func (s *State) RevokeOAuthClient(clientID string) error { + return s.db.RevokeOAuthClient(clientID) +} + +// MintAccessToken stores a new scoped access token for an OAuth client. +func (s *State) MintAccessToken(clientID string, scopes, tags []string, expiration *time.Time) (string, *types.OAuthAccessToken, error) { + return s.db.MintAccessToken(clientID, scopes, tags, expiration) +} + +// AuthenticateAccessToken validates a bearer access token and returns it with +// its granted scopes and tags. +func (s *State) AuthenticateAccessToken(token string) (*types.OAuthAccessToken, error) { + return s.db.AuthenticateAccessToken(token) +} + +// TagOwnedByTags reports whether a credential holding ownerTags may apply tag, +// per the policy's tag-to-tag ownership. Used to authorise the tags an OAuth +// access token sets on the auth keys it mints. +func (s *State) TagOwnedByTags(tag string, ownerTags []string) bool { + return s.polMan.TagOwnedByTags(tag, ownerTags) +} + +// TagExists reports whether tag is defined in the policy's tagOwners. Used to +// reject OAuth clients and auth keys carrying tags that no policy authorises, +// matching SetNodeTags. +func (s *State) TagExists(tag string) bool { + return s.polMan.TagExists(tag) +} + +// DeleteExpiredAccessTokens hard-deletes OAuth access tokens that expired before +// cutoff, returning how many were removed. +func (s *State) DeleteExpiredAccessTokens(cutoff time.Time) (int64, error) { + return s.db.DeleteExpiredAccessTokens(cutoff) +} + // GetAuthCacheEntry retrieves a pending auth request from the cache. func (s *State) GetAuthCacheEntry(id types.AuthID) (*types.AuthRequest, bool) { return s.authCache.Get(id) @@ -1792,7 +1894,7 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro if params.PreAuthKey.IsTagged() { // Tagged nodes are owned by their tags, not a user. // UserID is intentionally left nil. - nodeToRegister.Tags = params.PreAuthKey.Proto().GetAclTags() + nodeToRegister.Tags = params.PreAuthKey.Tags // Tagged nodes have key expiry disabled. nodeToRegister.Expiry = nil @@ -2426,11 +2528,12 @@ func (s *State) HandleNodeFromPreAuthKey( // user-less and never expire). Only update AuthKey reference // otherwise. if pak.IsTagged() && !node.IsTagged() { - node.Tags = pak.Proto().GetAclTags() + node.Tags = pak.Tags node.UserID = nil node.User = nil node.Expiry = nil } + node.AuthKey = pak node.AuthKeyID = &pak.ID // Do NOT reset IsOnline here. Online status is managed exclusively by @@ -2578,7 +2681,7 @@ func (s *State) HandleNodeFromPreAuthKey( Expiry: reqExpiry, RegisterMethod: util.RegisterMethodAuthKey, PreAuthKey: pak, - ExistingNodeForNetinfo: cmp.Or(differentUserNode, types.NodeView{}), + ExistingNodeForNetinfo: differentUserNode, }) if err != nil { return types.NodeView{}, change.Change{}, fmt.Errorf("creating new node: %w", err) @@ -2792,7 +2895,7 @@ func isAutoDerivedGivenName(given, hostname string) bool { // - node.PeerChangeFromMapRequest // - node.ApplyPeerChange // - logTracePeerChange in poll.go. -func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest) (change.Change, error) { +func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest) (change.Change, error) { //nolint:gocyclo // central map-request reconciliation; the sequential branch flow reads clearer as one function than split across helpers log.Trace(). Caller(). Uint64(zf.NodeID, id.Uint64()). diff --git a/hscontrol/tailsql.go b/hscontrol/tailsql.go index 7f2a52ba1..25c6869a8 100644 --- a/hscontrol/tailsql.go +++ b/hscontrol/tailsql.go @@ -38,9 +38,6 @@ func runTailSQLService(ctx context.Context, logf logger.Logf, stateDir, dbPath s Hostname: opts.Hostname, Logf: logger.Discard, } - // if *doDebugLog { - // tsNode.Logf = logf - // } defer tsNode.Close() logf("Starting tailscale (hostname=%q)", opts.Hostname) @@ -87,7 +84,6 @@ func runTailSQLService(ctx context.Context, logf logger.Logf, stateDir, dbPath s http.Redirect(w, r, target, http.StatusPermanentRedirect) //nolint:gosec // G710: target prefixed by trusted base URL })) }() - // log.Printf("Redirecting HTTP to HTTPS at %q", base) // For the real service, start a separate listener. // Note: Replaces the port 80 listener. diff --git a/hscontrol/templates/apple.go b/hscontrol/templates/apple.go index 3b1200696..8376b19a5 100644 --- a/hscontrol/templates/apple.go +++ b/hscontrol/templates/apple.go @@ -1,191 +1,196 @@ package templates import ( - "fmt" - "github.com/chasefleming/elem-go" "github.com/chasefleming/elem-go/attrs" "github.com/chasefleming/elem-go/styles" ) func Apple(url string) *elem.Element { - return HtmlStructure( - elem.Title(nil, - elem.Text("headscale - Apple")), - mdTypesetBody( - headscaleLogo(), - H1(elem.Text("iOS configuration")), - H2(elem.Text("GUI")), - Ol( - elem.Li( - nil, - elem.Text("Install the official Tailscale iOS client from the "), - externalLink("https://apps.apple.com/app/tailscale/id1470499037", "App Store"), - ), - elem.Li( - nil, - elem.Text("Open the "), - elem.Strong(nil, elem.Text("Tailscale")), - elem.Text(" app"), - ), - elem.Li( - nil, - elem.Text("Click the account icon in the top-right corner and select "), - elem.Strong(nil, elem.Text("Log in…")), - ), - elem.Li( - nil, - elem.Text("Tap the top-right options menu button and select "), - elem.Strong(nil, elem.Text("Use custom coordination server")), - ), - elem.Li( - nil, - elem.Text("Enter your instance URL: "), - Code(elem.Text(url)), - ), - elem.Li( - nil, - elem.Text( - "Enter your credentials and log in. Headscale should now be working on your iOS device", - ), - ), + return page( + "headscale - Apple", + H1(elem.Text("iOS configuration")), + H2(elem.Text("GUI")), + Ol( + elem.Li( + nil, + elem.Text("Install the official Tailscale iOS client from the "), + externalLink("https://apps.apple.com/app/tailscale/id1470499037", "App Store"), ), - H1(elem.Text("macOS configuration")), - H2(elem.Text("Command line")), - P( - elem.Text("Use Tailscale's login command to add your profile:"), + elem.Li( + nil, + elem.Text("Open the "), + elem.Strong(nil, elem.Text("Tailscale")), + elem.Text(" app"), ), - Pre(PreCode("tailscale login --login-server "+url)), - H2(elem.Text("GUI")), - Ol( - elem.Li( - nil, - elem.Text("Option + Click the "), - elem.Strong(nil, elem.Text("Tailscale")), - elem.Text(" icon in the menu and hover over the "), - elem.Strong(nil, elem.Text("Debug")), - elem.Text(" menu"), - ), - elem.Li(nil, - elem.Text("Under "), - elem.Strong(nil, elem.Text("Custom Login Server")), - elem.Text(", select "), - elem.Strong(nil, elem.Text("Add Account...")), - ), - elem.Li( - nil, - elem.Text("Enter "), - Code(elem.Text(url)), - elem.Text(" of the headscale instance and press "), - elem.Strong(nil, elem.Text("Add Account")), - ), - elem.Li(nil, - elem.Text("Follow the login procedure in the browser"), - ), + elem.Li( + nil, + elem.Text("Click the account icon in the top-right corner and select "), + elem.Strong(nil, elem.Text("Log in…")), ), - H2(elem.Text("Profiles")), - P( + elem.Li( + nil, + elem.Text("Tap the top-right options menu button and select "), + elem.Strong(nil, elem.Text("Use custom coordination server")), + ), + elem.Li( + nil, + elem.Text("Enter your instance URL: "), + Code(elem.Text(url)), + ), + elem.Li( + nil, elem.Text( - "Headscale can be set to the default server by installing a Headscale configuration profile:", + "Enter your credentials and log in. Headscale should now be working on your iOS device", ), ), - elem.Div(attrs.Props{attrs.Style: styles.Props{styles.MarginTop: spaceL, styles.MarginBottom: spaceL}.ToInline()}, - downloadButton("/apple/macos-app-store", "macOS AppStore profile"), - downloadButton("/apple/macos-standalone", "macOS Standalone profile"), + ), + H1(elem.Text("macOS configuration")), + H2(elem.Text("Command line")), + P( + elem.Text("Use Tailscale's login command to add your profile:"), + ), + codeBlockText("tailscale login --login-server "+url), + H2(elem.Text("GUI")), + Ol( + elem.Li( + nil, + elem.Text("Option + Click the "), + elem.Strong(nil, elem.Text("Tailscale")), + elem.Text(" icon in the menu and hover over the "), + elem.Strong(nil, elem.Text("Debug")), + elem.Text(" menu"), ), - Ol( - elem.Li( - nil, - elem.Text( - "Download the profile, then open it. When it has been opened, there should be a notification that a profile can be installed", - ), - ), - elem.Li(nil, - elem.Text("Open "), - elem.Strong(nil, elem.Text("System Preferences")), - elem.Text(" and go to "), - elem.Strong(nil, elem.Text("Profiles")), - ), - elem.Li(nil, - elem.Text("Find and install the "), - elem.Strong(nil, elem.Text("Headscale")), - elem.Text(" profile"), - ), - elem.Li(nil, - elem.Text("Restart "), - elem.Strong(nil, elem.Text("Tailscale.app")), - elem.Text(" and log in"), - ), + elem.Li( + nil, + elem.Text("Under "), + elem.Strong(nil, elem.Text("Custom Login Server")), + elem.Text(", select "), + elem.Strong(nil, elem.Text("Add Account...")), ), - orDivider(), - P( + elem.Li( + nil, + elem.Text("Enter "), + Code(elem.Text(url)), + elem.Text(" of the headscale instance and press "), + elem.Strong(nil, elem.Text("Add Account")), + ), + elem.Li( + nil, + elem.Text("Follow the login procedure in the browser"), + ), + ), + H2(elem.Text("Profiles")), + P( + elem.Text( + "Headscale can be set to the default server by installing a Headscale configuration profile:", + ), + ), + elem.Div( + attrs.Props{attrs.Style: styles.Props{styles.MarginTop: spaceL, styles.MarginBottom: spaceL}.ToInline()}, + downloadButton("/apple/macos-app-store", "macOS AppStore profile"), + downloadButton("/apple/macos-standalone", "macOS Standalone profile"), + ), + Ol( + elem.Li( + nil, elem.Text( - "Use your terminal to configure the default setting for Tailscale by issuing one of the following commands:", + "Download the profile, then open it. When it has been opened, there should be a notification that a profile can be installed", ), ), - P(elem.Text("For app store client:")), - Pre(PreCode("defaults write io.tailscale.ipn.macos ControlURL "+url)), - P(elem.Text("For standalone client:")), - Pre(PreCode("defaults write io.tailscale.ipn.macsys ControlURL "+url)), - P( + elem.Li( + nil, + elem.Text("Open "), + elem.Strong(nil, elem.Text("System Preferences")), + elem.Text(" and go to "), + elem.Strong(nil, elem.Text("Profiles")), + ), + elem.Li( + nil, + elem.Text("Find and install the "), + elem.Strong(nil, elem.Text("Headscale")), + elem.Text(" profile"), + ), + elem.Li( + nil, elem.Text("Restart "), elem.Strong(nil, elem.Text("Tailscale.app")), - elem.Text(" and log in."), + elem.Text(" and log in"), ), - warningBox("Caution", "You should always download and inspect the profile before installing it."), - P(elem.Text("For app store client:")), - Pre(PreCode(fmt.Sprintf(`curl %s/apple/macos-app-store`, url))), - P(elem.Text("For standalone client:")), - Pre(PreCode(fmt.Sprintf(`curl %s/apple/macos-standalone`, url))), - H1(elem.Text("tvOS configuration")), - H2(elem.Text("GUI")), - Ol( - elem.Li( - nil, - elem.Text("Install the official Tailscale tvOS client from the "), - externalLink("https://apps.apple.com/app/tailscale/id1470499037", "App Store"), - ), - elem.Li( - nil, - elem.Text("Open "), - elem.Strong(nil, elem.Text("Settings")), - elem.Text(" (the Apple tvOS settings) > "), - elem.Strong(nil, elem.Text("Apps")), - elem.Text(" > "), - elem.Strong(nil, elem.Text("Tailscale")), - ), - elem.Li( - nil, - elem.Text("Enter "), - Code(elem.Text(url)), - elem.Text(" under "), - elem.Strong(nil, elem.Text("ALTERNATE COORDINATION SERVER URL")), - ), - elem.Li(nil, - elem.Text("Return to the tvOS "), - elem.Strong(nil, elem.Text("Home")), - elem.Text(" screen"), - ), - elem.Li(nil, - elem.Text("Open "), - elem.Strong(nil, elem.Text("Tailscale")), - ), - elem.Li(nil, - elem.Text("Select "), - elem.Strong(nil, elem.Text("Install VPN configuration")), - ), - elem.Li(nil, - elem.Text("Select "), - elem.Strong(nil, elem.Text("Allow")), - ), - elem.Li(nil, - elem.Text("Scan the QR code and follow the login procedure"), - ), - elem.Li(nil, - elem.Text("Headscale should now be working on your tvOS device"), - ), + ), + orDivider(), + P( + elem.Text( + "Use your terminal to configure the default setting for Tailscale by issuing one of the following commands:", + ), + ), + P(elem.Text("For app store client:")), + codeBlockText("defaults write io.tailscale.ipn.macos ControlURL "+url), + P(elem.Text("For standalone client:")), + codeBlockText("defaults write io.tailscale.ipn.macsys ControlURL "+url), + P( + elem.Text("Restart "), + elem.Strong(nil, elem.Text("Tailscale.app")), + elem.Text(" and log in."), + ), + warningBox("Caution", "You should always download and inspect the profile before installing it."), + P(elem.Text("For app store client:")), + codeBlockText("curl "+url+"/apple/macos-app-store"), + P(elem.Text("For standalone client:")), + codeBlockText("curl "+url+"/apple/macos-standalone"), + H1(elem.Text("tvOS configuration")), + H2(elem.Text("GUI")), + Ol( + elem.Li( + nil, + elem.Text("Install the official Tailscale tvOS client from the "), + externalLink("https://apps.apple.com/app/tailscale/id1470499037", "App Store"), + ), + elem.Li( + nil, + elem.Text("Open "), + elem.Strong(nil, elem.Text("Settings")), + elem.Text(" (the Apple tvOS settings) > "), + elem.Strong(nil, elem.Text("Apps")), + elem.Text(" > "), + elem.Strong(nil, elem.Text("Tailscale")), + ), + elem.Li( + nil, + elem.Text("Enter "), + Code(elem.Text(url)), + elem.Text(" under "), + elem.Strong(nil, elem.Text("ALTERNATE COORDINATION SERVER URL")), + ), + elem.Li( + nil, + elem.Text("Return to the tvOS "), + elem.Strong(nil, elem.Text("Home")), + elem.Text(" screen"), + ), + elem.Li( + nil, + elem.Text("Open "), + elem.Strong(nil, elem.Text("Tailscale")), + ), + elem.Li( + nil, + elem.Text("Select "), + elem.Strong(nil, elem.Text("Install VPN configuration")), + ), + elem.Li( + nil, + elem.Text("Select "), + elem.Strong(nil, elem.Text("Allow")), + ), + elem.Li( + nil, + elem.Text("Scan the QR code and follow the login procedure"), + ), + elem.Li( + nil, + elem.Text("Headscale should now be working on your tvOS device"), ), - pageFooter(), ), ) } diff --git a/hscontrol/templates/auth_error.go b/hscontrol/templates/auth_error.go index 7b48974c1..8bcaa8063 100644 --- a/hscontrol/templates/auth_error.go +++ b/hscontrol/templates/auth_error.go @@ -30,12 +30,8 @@ func AuthError(result AuthErrorResult) *elem.Element { elem.Text(result.Message), ) - return HtmlStructure( - elem.Title(nil, elem.Text(result.Title)), - mdTypesetBody( - headscaleLogo(), - box, - pageFooter(), - ), + return page( + result.Title, + box, ) } diff --git a/hscontrol/templates/auth_success.go b/hscontrol/templates/auth_success.go index c0f275e54..631de3b14 100644 --- a/hscontrol/templates/auth_success.go +++ b/hscontrol/templates/auth_success.go @@ -41,22 +41,20 @@ func AuthSuccess(result AuthSuccessResult) *elem.Element { elem.Text(". "+result.Message), ) - return HtmlStructure( - elem.Title(nil, elem.Text(result.Title)), - mdTypesetBody( - headscaleLogo(), - box, - H2(elem.Text("Getting started")), - P(elem.Text("Check out the documentation to learn more about headscale and Tailscale:")), - Ul( - elem.Li(nil, - externalLink("https://headscale.net/stable/", "Headscale documentation"), - ), - elem.Li(nil, - externalLink("https://tailscale.com/docs", "Tailscale docs"), - ), + return page( + result.Title, + box, + H2(elem.Text("Getting started")), + P(elem.Text("Check out the documentation to learn more about headscale and Tailscale:")), + Ul( + elem.Li( + nil, + externalLink("https://headscale.net/stable/", "Headscale documentation"), + ), + elem.Li( + nil, + externalLink("https://tailscale.com/docs", "Tailscale docs"), ), - pageFooter(), ), ) } diff --git a/hscontrol/templates/auth_web.go b/hscontrol/templates/auth_web.go index 8b6d6f971..ff97250da 100644 --- a/hscontrol/templates/auth_web.go +++ b/hscontrol/templates/auth_web.go @@ -8,14 +8,10 @@ import ( // to complete an authentication or registration flow. // It is used by both the registration and auth-approve web handlers. func AuthWeb(title, description, command string) *elem.Element { - return HtmlStructure( - elem.Title(nil, elem.Text(title+" - Headscale")), - mdTypesetBody( - headscaleLogo(), - H1(elem.Text(title)), - P(elem.Text(description)), - Pre(PreCode(command)), - pageFooter(), - ), + return page( + title+" - Headscale", + H1(elem.Text(title)), + P(elem.Text(description)), + codeBlockText(command), ) } diff --git a/hscontrol/templates/design.go b/hscontrol/templates/design.go index da9cda51f..422d85db7 100644 --- a/hscontrol/templates/design.go +++ b/hscontrol/templates/design.go @@ -10,41 +10,6 @@ import ( // These constants define the visual language for all Headscale HTML templates. // They ensure consistency across all pages and make it easy to maintain and update the design. -// Color System -// EXTRACTED FROM: https://headscale.net/stable/assets/stylesheets/main.342714a4.min.css -// Material for MkDocs design system - exact values from official docs. -const ( - // Text colors - from --md-default-fg-color CSS variables. - colorTextPrimary = "#000000de" //nolint:unused // rgba(0,0,0,0.87) - Body text - colorTextSecondary = "#0000008a" //nolint:unused // rgba(0,0,0,0.54) - Headings (--md-default-fg-color--light) - colorTextTertiary = "#00000052" //nolint:unused // rgba(0,0,0,0.32) - Lighter text - colorTextLightest = "#00000012" //nolint:unused // rgba(0,0,0,0.07) - Lightest text - - // Code colors - from --md-code-* CSS variables. - colorCodeFg = "#36464e" //nolint:unused // Code text color (--md-code-fg-color) - colorCodeBg = "#f5f5f5" //nolint:unused // Code background (--md-code-bg-color) - - // Border colors. - colorBorderLight = "#e5e7eb" //nolint:unused // Light borders - colorBorderMedium = "#d1d5db" //nolint:unused // Medium borders - - // Background colors. - colorBackgroundPage = "#ffffff" //nolint:unused // Page background - colorBackgroundCard = "#ffffff" //nolint:unused // Card/content background - - // Accent colors - from --md-primary/accent-fg-color. - colorPrimaryAccent = "#4051b5" //nolint:unused // Primary accent (links) - colorAccent = "#526cfe" //nolint:unused // Secondary accent - - // Success colors. - colorSuccess = "#059669" //nolint:unused // Success states - colorSuccessLight = "#d1fae5" //nolint:unused // Success backgrounds - - // Error colors. - colorError = "#dc2626" //nolint:unused // Error states (red-600) - colorErrorLight = "#fee2e2" //nolint:unused // Error backgrounds (red-100) -) - // Spacing System // Based on 4px/8px base unit for consistent rhythm. // Uses rem units for scalability with user font size preferences. @@ -60,278 +25,23 @@ const ( // Shared CSS value constants used across templates. const ( - cssBorderHS = "1px solid var(--hs-border)" //nolint:unused // Shared HS border - cssBreakWord = "break-word" //nolint:unused // Word wrapping - cssCenter = "center" //nolint:unused // Center alignment - cssOverflowWrap = "overflow-wrap" //nolint:unused // CSS property key + cssBorderHS = "1px solid var(--hs-border)" //nolint:unused // Shared HS border + cssCenter = "center" //nolint:unused // Center alignment ) // Typography System // EXTRACTED FROM: https://headscale.net/stable/assets/stylesheets/main.342714a4.min.css // Material for MkDocs typography - exact values from .md-typeset CSS. const ( - // Font families - from CSS custom properties. - fontFamilySystem = `"Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif` //nolint:unused - fontFamilyCode = `"Roboto Mono", "SF Mono", Monaco, "Cascadia Code", Consolas, "Courier New", monospace` //nolint:unused - // Font sizes - from .md-typeset CSS rules. - fontSizeBase = "0.8rem" //nolint:unused // 12.8px - Base text (.md-typeset) - fontSizeH1 = "2em" //nolint:unused // 2x base - Main headings - fontSizeH2 = "1.5625em" //nolint:unused // 1.5625x base - Section headings - fontSizeH3 = "1.25em" //nolint:unused // 1.25x base - Subsection headings - fontSizeSmall = "0.8em" //nolint:unused // 0.8x base - Small text - fontSizeCode = "0.85em" //nolint:unused // 0.85x base - Inline code + fontSizeBase = "0.8rem" //nolint:unused // 12.8px - Base text (.md-typeset) + fontSizeH3 = "1.25em" //nolint:unused // 1.25x base - Subsection headings + fontSizeSmall = "0.8em" //nolint:unused // 0.8x base - Small text // Line heights - from .md-typeset CSS rules. lineHeightBase = "1.6" //nolint:unused // Body text (.md-typeset) - lineHeightH1 = "1.3" //nolint:unused // H1 headings - lineHeightH2 = "1.4" //nolint:unused // H2 headings - lineHeightH3 = "1.5" //nolint:unused // H3 headings - lineHeightCode = "1.4" //nolint:unused // Code blocks (pre) ) -// Responsive Container Component -// Creates a centered container with responsive padding and max-width. -// Mobile-first approach: starts at 100% width with padding, constrains on larger screens. -// -//nolint:unused // Reserved for future use in Phase 4. -func responsiveContainer(children ...elem.Node) *elem.Element { - return elem.Div(attrs.Props{ - attrs.Style: styles.Props{ - styles.Width: "100%", - styles.MaxWidth: "min(800px, 90vw)", // Responsive: 90% of viewport or 800px max - styles.Margin: "0 auto", // Center horizontally - styles.Padding: "clamp(1rem, 5vw, 2.5rem)", // Fluid padding: 16px to 40px - }.ToInline(), - }, children...) -} - -// Card Component -// Reusable card for grouping related content with visual separation. -// Parameters: -// - title: Optional title for the card (empty string for no title) -// - children: Content elements to display in the card -// -//nolint:unused // Reserved for future use in Phase 4. -func card(title string, children ...elem.Node) *elem.Element { - cardContent := children - if title != "" { - cardContent = append([]elem.Node{ - elem.H3(attrs.Props{ - attrs.Style: styles.Props{ - styles.MarginTop: "0", - styles.MarginBottom: spaceM, - }.ToInline(), - }, elem.Text(title)), - }, children...) - } - - return elem.Div(attrs.Props{ - attrs.Style: styles.Props{ - styles.Background: "var(--hs-bg)", - styles.Border: cssBorderHS, - styles.BorderRadius: spaceS, - styles.Padding: "clamp(1rem, 3vw, 1.5rem)", - styles.MarginBottom: spaceL, - styles.BoxShadow: "0 1px 3px rgba(0,0,0,0.1)", - }.ToInline(), - }, cardContent...) -} - -// Code Block Component -// EXTRACTED FROM: .md-typeset pre CSS rules -// Exact styling from Material for MkDocs documentation. -// -//nolint:unused // Used across apple.go, windows.go, register_web.go templates. -func codeBlock(code string) *elem.Element { - return elem.Pre(attrs.Props{ - attrs.Style: styles.Props{ - styles.Display: "block", - styles.Padding: "0.77em 1.18em", // From .md-typeset pre - styles.Border: "none", // No border in original - styles.BorderRadius: "0.1rem", // From .md-typeset code - styles.BackgroundColor: colorCodeBg, // #f5f5f5 - styles.FontFamily: fontFamilyCode, // Roboto Mono - styles.FontSize: fontSizeCode, // 0.85em - styles.LineHeight: lineHeightCode, // 1.4 - styles.OverflowX: "auto", // Horizontal scroll - cssOverflowWrap: cssBreakWord, // Word wrapping - "word-wrap": cssBreakWord, // Legacy support - styles.WhiteSpace: "pre-wrap", // Preserve whitespace - styles.MarginTop: spaceM, // 1em - styles.MarginBottom: spaceM, // 1em - styles.Color: colorCodeFg, // #36464e - styles.BoxShadow: "none", // No shadow in original - }.ToInline(), - }, - elem.Code(nil, elem.Text(code)), - ) -} - -// Base Typeset Styles -// Returns inline styles for the main content container that matches .md-typeset. -// EXTRACTED FROM: .md-typeset CSS rule from Material for MkDocs. -// -//nolint:unused // Used in general.go for mdTypesetBody. -func baseTypesetStyles() styles.Props { - return styles.Props{ - styles.FontSize: fontSizeBase, // 0.8rem - styles.LineHeight: lineHeightBase, // 1.6 - styles.Color: colorTextPrimary, - styles.FontFamily: fontFamilySystem, - cssOverflowWrap: cssBreakWord, - styles.TextAlign: "left", - } -} - -// H1 Styles -// Returns inline styles for H1 headings that match .md-typeset h1. -// EXTRACTED FROM: .md-typeset h1 CSS rule from Material for MkDocs. -// -//nolint:unused // Used across templates for main headings. -func h1Styles() styles.Props { - return styles.Props{ - styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54) - styles.FontSize: fontSizeH1, // 2em - styles.LineHeight: lineHeightH1, // 1.3 - styles.Margin: "0 0 1.25em", - styles.FontWeight: "300", - "letter-spacing": "-0.01em", - styles.FontFamily: fontFamilySystem, // Roboto - cssOverflowWrap: cssBreakWord, - } -} - -// H2 Styles -// Returns inline styles for H2 headings that match .md-typeset h2. -// EXTRACTED FROM: .md-typeset h2 CSS rule from Material for MkDocs. -// -//nolint:unused // Used across templates for section headings. -func h2Styles() styles.Props { - return styles.Props{ - styles.FontSize: fontSizeH2, // 1.5625em - styles.LineHeight: lineHeightH2, // 1.4 - styles.Margin: "1.6em 0 0.64em", - styles.FontWeight: "300", - "letter-spacing": "-0.01em", - styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54) - styles.FontFamily: fontFamilySystem, // Roboto - cssOverflowWrap: cssBreakWord, - } -} - -// H3 Styles -// Returns inline styles for H3 headings that match .md-typeset h3. -// EXTRACTED FROM: .md-typeset h3 CSS rule from Material for MkDocs. -// -//nolint:unused // Used across templates for subsection headings. -func h3Styles() styles.Props { - return styles.Props{ - styles.FontSize: fontSizeH3, // 1.25em - styles.LineHeight: lineHeightH3, // 1.5 - styles.Margin: "1.6em 0 0.8em", - styles.FontWeight: "400", - "letter-spacing": "-0.01em", - styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54) - styles.FontFamily: fontFamilySystem, // Roboto - cssOverflowWrap: cssBreakWord, - } -} - -// Paragraph Styles -// Returns inline styles for paragraphs that match .md-typeset p. -// EXTRACTED FROM: .md-typeset p CSS rule from Material for MkDocs. -// -//nolint:unused // Used for consistent paragraph spacing. -func paragraphStyles() styles.Props { - return styles.Props{ - styles.Margin: "1em 0", - styles.FontFamily: fontFamilySystem, // Roboto - styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset - styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset - styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) - cssOverflowWrap: cssBreakWord, - } -} - -// Ordered List Styles -// Returns inline styles for ordered lists that match .md-typeset ol. -// EXTRACTED FROM: .md-typeset ol CSS rule from Material for MkDocs. -// -//nolint:unused // Used for numbered instruction lists. -func orderedListStyles() styles.Props { - return styles.Props{ - styles.MarginBottom: "1em", - styles.MarginTop: "1em", - styles.PaddingLeft: "2em", - styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset - styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset - styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset - styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) - inherited from .md-typeset - cssOverflowWrap: cssBreakWord, - } -} - -// Unordered List Styles -// Returns inline styles for unordered lists that match .md-typeset ul. -// EXTRACTED FROM: .md-typeset ul CSS rule from Material for MkDocs. -// -//nolint:unused // Used for bullet point lists. -func unorderedListStyles() styles.Props { - return styles.Props{ - styles.MarginBottom: "1em", - styles.MarginTop: "1em", - styles.PaddingLeft: "2em", - styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset - styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset - styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset - styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) - inherited from .md-typeset - cssOverflowWrap: cssBreakWord, - } -} - -// Link Styles -// Returns inline styles for links that match .md-typeset a. -// EXTRACTED FROM: .md-typeset a CSS rule from Material for MkDocs. -// Note: Hover states cannot be implemented with inline styles. -// -//nolint:unused // Used for text links. -func linkStyles() styles.Props { - return styles.Props{ - styles.Color: colorPrimaryAccent, // #4051b5 - var(--md-primary-fg-color) - styles.TextDecoration: "none", - "word-break": cssBreakWord, - styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset - } -} - -// Inline Code Styles (updated) -// Returns inline styles for inline code that matches .md-typeset code. -// EXTRACTED FROM: .md-typeset code CSS rule from Material for MkDocs. -// -//nolint:unused // Used for inline code snippets. -func inlineCodeStyles() styles.Props { - return styles.Props{ - styles.BackgroundColor: colorCodeBg, // #f5f5f5 - styles.Color: colorCodeFg, // #36464e - styles.BorderRadius: "0.1rem", - styles.FontSize: fontSizeCode, // 0.85em - styles.FontFamily: fontFamilyCode, // Roboto Mono - styles.Padding: "0 0.2941176471em", - "word-break": cssBreakWord, - } -} - -// Inline Code Component -// For inline code snippets within text. -// -//nolint:unused // Reserved for future inline code usage. -func inlineCode(code string) *elem.Element { - return elem.Code(attrs.Props{ - attrs.Style: inlineCodeStyles().ToInline(), - }, elem.Text(code)) -} - // orDivider creates a visual "or" divider between sections. // Styled with lines on either side for better visual separation. // @@ -343,16 +53,17 @@ func orDivider() *elem.Element { styles.BackgroundColor: "var(--hs-border)", }.ToInline() - return elem.Div(attrs.Props{ - attrs.Style: styles.Props{ - styles.Display: "flex", - styles.AlignItems: cssCenter, - styles.Gap: spaceM, - styles.MarginTop: space2XL, - styles.MarginBottom: space2XL, - styles.Width: "100%", - }.ToInline(), - }, + return elem.Div( + attrs.Props{ + attrs.Style: styles.Props{ + styles.Display: "flex", + styles.AlignItems: cssCenter, + styles.Gap: spaceM, + styles.MarginTop: space2XL, + styles.MarginBottom: space2XL, + styles.Width: "100%", + }.ToInline(), + }, elem.Div(attrs.Props{attrs.Style: lineStyle}), elem.Strong(attrs.Props{ attrs.Style: styles.Props{ @@ -367,12 +78,17 @@ func orDivider() *elem.Element { ) } -// successBox creates a green success feedback box with a checkmark icon. -// The heading is displayed as bold green text, and children are rendered below it. -// Pairs with warningBox for consistent feedback styling. +// feedbackBox creates a coloured feedback box with an icon and a bold heading. +// colorVar provides both the border and heading colour, bgVar the background; +// role and ariaLive set the accessibility attributes. Children render below the +// heading. // -//nolint:unused // Used in auth_success.go template. -func successBox(heading string, children ...elem.Node) *elem.Element { +//nolint:unused // Wrapped by successBox and errorBox. +func feedbackBox( + icon elem.Node, + colorVar, bgVar, role, ariaLive, heading string, + children ...elem.Node, +) *elem.Element { return elem.Div( attrs.Props{ attrs.Style: styles.Props{ @@ -380,21 +96,22 @@ func successBox(heading string, children ...elem.Node) *elem.Element { styles.AlignItems: cssCenter, styles.Gap: spaceM, styles.Padding: spaceL, - styles.BackgroundColor: "var(--hs-success-bg)", - styles.Border: "1px solid var(--hs-success)", + styles.BackgroundColor: bgVar, + styles.Border: "1px solid " + colorVar, styles.BorderRadius: spaceS, styles.MarginBottom: spaceXL, }.ToInline(), - attrs.Role: "status", - "aria-live": "polite", + attrs.Role: role, + "aria-live": ariaLive, }, - checkboxIcon(), - elem.Div(nil, + icon, + elem.Div( + nil, append([]elem.Node{ elem.Strong(attrs.Props{ attrs.Style: styles.Props{ styles.Display: "block", - styles.Color: "var(--hs-success)", + styles.Color: colorVar, styles.FontSize: fontSizeH3, styles.FontWeight: "700", styles.MarginBottom: spaceXS, @@ -405,6 +122,19 @@ func successBox(heading string, children ...elem.Node) *elem.Element { ) } +// successBox creates a green success feedback box with a checkmark icon. +// The heading is displayed as bold green text, and children are rendered below it. +// Pairs with warningBox for consistent feedback styling. +// +//nolint:unused // Used in auth_success.go template. +func successBox(heading string, children ...elem.Node) *elem.Element { + return feedbackBox( + checkboxIcon(), + "var(--hs-success)", "var(--hs-success-bg)", "status", "polite", + heading, children..., + ) +} + // checkboxIcon returns the success checkbox SVG icon as raw HTML. func checkboxIcon() elem.Node { return elem.Raw(` - - - - - - -
- - -`)) - - var payload bytes.Buffer - if err := swaggerTemplate.Execute(&payload, struct{}{}); err != nil { //nolint:noinlineerr - log.Error(). - Caller(). - Err(err). - Msg("Could not render Swagger") - - writer.Header().Set("Content-Type", "text/plain; charset=utf-8") - writer.WriteHeader(http.StatusInternalServerError) - - _, err := writer.Write([]byte("Could not render Swagger")) - if err != nil { - log.Error(). - Caller(). - Err(err). - Msg("Failed to write response") - } - - return - } - - writer.Header().Set("Content-Type", "text/html; charset=utf-8") - writer.WriteHeader(http.StatusOK) - - _, err := writer.Write(payload.Bytes()) - if err != nil { - log.Error(). - Caller(). - Err(err). - Msg("Failed to write response") - } -} - -func SwaggerAPIv1( - writer http.ResponseWriter, - req *http.Request, -) { - writer.Header().Set("Content-Type", "application/json; charset=utf-8") - writer.WriteHeader(http.StatusOK) - - if _, err := writer.Write(apiV1JSON); err != nil { //nolint:noinlineerr - log.Error(). - Caller(). - Err(err). - Msg("Failed to write response") - } -} diff --git a/tools/capver/main.go b/tools/capver/main.go index 1cb3c363d..368214c83 100644 --- a/tools/capver/main.go +++ b/tools/capver/main.go @@ -10,15 +10,14 @@ import ( "go/format" "io" "log" + "maps" "net/http" "os" "regexp" "slices" - "sort" "strconv" "strings" - xmaps "golang.org/x/exp/maps" "tailscale.com/tailcfg" ) @@ -246,8 +245,7 @@ func getCapabilityVersions(ctx context.Context) (map[string]tailcfg.CapabilityVe func calculateMinSupportedCapabilityVersion(versions map[string]tailcfg.CapabilityVersion) tailcfg.CapabilityVersion { // Since we now store minor versions directly, just sort and take the oldest of the latest N - minorVersions := xmaps.Keys(versions) - sort.Strings(minorVersions) + minorVersions := slices.Sorted(maps.Keys(versions)) supportedCount := min(len(minorVersions), supportedMajorMinorVersions) @@ -261,6 +259,23 @@ func calculateMinSupportedCapabilityVersion(versions map[string]tailcfg.Capabili return versions[oldestSupportedMinor] } +// firstTailscaleVerPerCapVer inverts versions into a map from each capability +// version to the first (lowest-sorted) Tailscale minor version reporting it. +func firstTailscaleVerPerCapVer(versions map[string]tailcfg.CapabilityVersion) map[tailcfg.CapabilityVersion]string { + sortedVersions := slices.Sorted(maps.Keys(versions)) + + capVerToTailscaleVer := make(map[tailcfg.CapabilityVersion]string) + + for _, v := range sortedVersions { + capabilityVersion := versions[v] + if _, ok := capVerToTailscaleVer[capabilityVersion]; !ok { + capVerToTailscaleVer[capabilityVersion] = v + } + } + + return capVerToTailscaleVer +} + func writeCapabilityVersionsToFile(versions map[string]tailcfg.CapabilityVersion, minSupportedCapVer tailcfg.CapabilityVersion) error { // Generate the Go code as a string var content strings.Builder @@ -270,8 +285,7 @@ func writeCapabilityVersionsToFile(versions map[string]tailcfg.CapabilityVersion content.WriteString("\n\n") content.WriteString("var tailscaleToCapVer = map[string]tailcfg.CapabilityVersion{\n") - sortedVersions := xmaps.Keys(versions) - sort.Strings(sortedVersions) + sortedVersions := slices.Sorted(maps.Keys(versions)) for _, version := range sortedVersions { fmt.Fprintf(&content, "\t\"%s\": %d,\n", version, versions[version]) @@ -282,26 +296,12 @@ func writeCapabilityVersionsToFile(versions map[string]tailcfg.CapabilityVersion content.WriteString("\n\n") content.WriteString("var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{\n") - capVarToTailscaleVer := make(map[tailcfg.CapabilityVersion]string) + capVerToTailscaleVer := firstTailscaleVerPerCapVer(versions) - for _, v := range sortedVersions { - capabilityVersion := versions[v] - - // If it is already set, skip and continue, - // we only want the first tailscale version per - // capability version. - if _, ok := capVarToTailscaleVer[capabilityVersion]; ok { - continue - } - - capVarToTailscaleVer[capabilityVersion] = v - } - - capsSorted := xmaps.Keys(capVarToTailscaleVer) - slices.Sort(capsSorted) + capsSorted := slices.Sorted(maps.Keys(capVerToTailscaleVer)) for _, capVer := range capsSorted { - fmt.Fprintf(&content, "\t%d:\t\t\"%s\",\n", capVer, capVarToTailscaleVer[capVer]) + fmt.Fprintf(&content, "\t%d:\t\t\"%s\",\n", capVer, capVerToTailscaleVer[capVer]) } content.WriteString("}\n\n") @@ -332,8 +332,7 @@ func writeCapabilityVersionsToFile(versions map[string]tailcfg.CapabilityVersion func writeTestDataFile(versions map[string]tailcfg.CapabilityVersion, minSupportedCapVer tailcfg.CapabilityVersion) error { // Sort minor versions - minorVersions := xmaps.Keys(versions) - sort.Strings(minorVersions) + minorVersions := slices.Sorted(maps.Keys(versions)) // Take latest N supportedCount := min(len(minorVersions), supportedMajorMinorVersions) @@ -398,16 +397,7 @@ func writeTestDataFile(versions map[string]tailcfg.CapabilityVersion, minSupport content.WriteString("}\n\n") // Build capVerToTailscaleVer for test data - capVerToTailscaleVer := make(map[tailcfg.CapabilityVersion]string) - sortedVersions := xmaps.Keys(versions) - sort.Strings(sortedVersions) - - for _, v := range sortedVersions { - capabilityVersion := versions[v] - if _, ok := capVerToTailscaleVer[capabilityVersion]; !ok { - capVerToTailscaleVer[capabilityVersion] = v - } - } + capVerToTailscaleVer := firstTailscaleVerPerCapVer(versions) // Generate complete test struct for [capver.CapVerMinimumTailscaleVersion] content.WriteString("var capVerMinimumTailscaleVersionTests = []struct {\n") @@ -420,8 +410,7 @@ func writeTestDataFile(versions map[string]tailcfg.CapabilityVersion, minSupport fmt.Fprintf(&content, "\t{%d, \"%s\"},\n", minSupportedCapVer, minVersionString) // Add a few more test cases - capsSorted := xmaps.Keys(capVerToTailscaleVer) - slices.Sort(capsSorted) + capsSorted := slices.Sorted(maps.Keys(capVerToTailscaleVer)) testCount := 0 for _, capVer := range capsSorted {