mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-30 03:40:45 +09:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3566399f05 | |||
| 9d51a45093 | |||
| 048308511c | |||
| a84945f134 | |||
| fef80f3eb0 | |||
| 01ef350d5f | |||
| f4fba32dc6 | |||
| fc6f216b61 | |||
| de9db9c811 | |||
| 4946d1c88d | |||
| c497612c99 | |||
| 4e4512c4b7 | |||
| 08956d51a4 | |||
| d528686f14 | |||
| 6f317c7576 | |||
| 518b497be9 | |||
| 8d91e42a9f | |||
| 99fd62477d | |||
| c7b162509c | |||
| a4ec76605e | |||
| 1867213b69 | |||
| 622e08f5e6 | |||
| 79bf201ccb | |||
| af46fe8867 | |||
| b05f8875c3 | |||
| 84715e976c | |||
| ee95cf58d9 | |||
| 7b1776a87e | |||
| f6865fb40c | |||
| 6e0814a845 | |||
| 6e6ea3758d | |||
| 3846c3f148 | |||
| fe536dd799 | |||
| 5aeeff98d0 | |||
| 66937040f2 | |||
| 036760df02 | |||
| 83d3828ba1 | |||
| dd9f0a3f4f | |||
| 961b15313f | |||
| 475a5ead0c | |||
| ec0cb6f63a | |||
| a066a06bef | |||
| 33a65052c9 | |||
| 13ffd958ed | |||
| d5555c6b8c | |||
| 8898c69d0a | |||
| e2e9fe3081 | |||
| 3cddc07026 | |||
| b3472bb8dd | |||
| 6b413fe237 | |||
| ff7dd807fe | |||
| fe08d43978 | |||
| 2ff342764f | |||
| 3d811f29a6 | |||
| 4fa6af0102 | |||
| 96d736ec23 | |||
| 339cb392a9 | |||
| 24dfcf178f | |||
| 8f314797ce | |||
| 2632006f5f | |||
| 07b3a8ebd3 | |||
| 3c504af2be | |||
| 7af39a6798 | |||
| 49d6949639 | |||
| d7150755d4 | |||
| 88234d4046 | |||
| 15781ed6d8 | |||
| 72adc5ff2a | |||
| 560b6d81ad | |||
| 8efa5ad1fe | |||
| ba90048cfb | |||
| 1572ac551d | |||
| c9bbb5bdec | |||
| 97eff90ebe |
+1
-10
@@ -1,10 +1 @@
|
||||
* @juanfont @kradalby
|
||||
|
||||
*.md @ohdearaugustin @nblock
|
||||
*.yml @ohdearaugustin @nblock
|
||||
*.yaml @ohdearaugustin @nblock
|
||||
Dockerfile* @ohdearaugustin @nblock
|
||||
.goreleaser.yaml @ohdearaugustin @nblock
|
||||
/docs/ @ohdearaugustin @nblock
|
||||
/.github/workflows/ @ohdearaugustin @nblock
|
||||
/.github/renovate.json @ohdearaugustin @nblock
|
||||
* @juanfont @kradalby @nblock
|
||||
@@ -0,0 +1,119 @@
|
||||
# Vouched (or actively denounced) users for this repository.
|
||||
#
|
||||
# Headscale gates pull requests to vouched contributors only. Issues
|
||||
# remain open to everyone — see CONTRIBUTING.md for the rationale and
|
||||
# the process for getting vouched.
|
||||
#
|
||||
# A denounced user (prefix `-`) is explicitly blocked from contributing.
|
||||
#
|
||||
# Syntax:
|
||||
# - One handle per line (without @). Sorted alphabetically.
|
||||
# - Optionally specify platform: `platform:username` (e.g., `github:nblock`).
|
||||
# - To denounce, prefix with minus: `-username` or `-platform:username`.
|
||||
# - Optionally, add a comment after a space following the handle.
|
||||
#
|
||||
# Maintainers can vouch for new contributors by commenting `!vouch` (or
|
||||
# `!vouch @username`) on any issue or PR by the author. `!denounce` blocks.
|
||||
|
||||
aalmenar
|
||||
adoolaard
|
||||
adriangoransson
|
||||
Aluxima
|
||||
AlynxZhou
|
||||
apollo13
|
||||
aradng
|
||||
ArcticLampyrid
|
||||
bobelev
|
||||
ChibangLW
|
||||
christian-heusel
|
||||
ckiee
|
||||
codethief
|
||||
cure
|
||||
dmeremyanin
|
||||
dotlambda
|
||||
dragetd
|
||||
e-zk
|
||||
edvardvb
|
||||
enoperm
|
||||
EtaoinWu
|
||||
evenh
|
||||
eyJhb
|
||||
felixscheinost
|
||||
FlorinPeter
|
||||
fredrikekre
|
||||
griffer
|
||||
GrigoriyMikhalkin
|
||||
gymnae
|
||||
huskyii
|
||||
igrikus
|
||||
ImpostorKeanu
|
||||
isaac-mcfadyen
|
||||
iSchluff
|
||||
ItalyPaleAle
|
||||
ItsShadowCone
|
||||
jdewinne
|
||||
joachimtingvold
|
||||
jonathanspw
|
||||
juanfont
|
||||
kazauwa
|
||||
korfuri
|
||||
kradalby
|
||||
linsomniac
|
||||
lyc8503
|
||||
madjam002
|
||||
majst01
|
||||
maprambo
|
||||
marcelmindemann
|
||||
maxpain
|
||||
mevansam
|
||||
mike-lloyd03
|
||||
motiejus
|
||||
mpldr
|
||||
nblock
|
||||
negbie
|
||||
Nemo157
|
||||
nicka101
|
||||
Niek
|
||||
nielscil
|
||||
NoelGisler
|
||||
noseshimself
|
||||
ohdearaugustin
|
||||
oplik0
|
||||
Orhideous
|
||||
pallabpain
|
||||
philippderdiedas
|
||||
Polsaker
|
||||
PowershellScripter
|
||||
ptman
|
||||
puzpuzpuz
|
||||
pvinis
|
||||
qbit
|
||||
QEDeD
|
||||
qoke
|
||||
Qup42
|
||||
redstonekasi
|
||||
restanrm
|
||||
reynico
|
||||
Riezebos
|
||||
samson4649
|
||||
SlackingVeteran
|
||||
spymobilfon
|
||||
stavros-k
|
||||
stblassitude
|
||||
SuperSandro2000
|
||||
SysAdminSmith
|
||||
sysvinit
|
||||
teleclimber
|
||||
tgrushka
|
||||
thebigbone
|
||||
tianon
|
||||
TotoTheDragon
|
||||
tsujamin
|
||||
unreality
|
||||
vbrandl
|
||||
vdovhanych
|
||||
victorhooi
|
||||
vsychov
|
||||
yaneony
|
||||
yaroslavkasatikov
|
||||
Zottelchen
|
||||
@@ -0,0 +1,25 @@
|
||||
Hi @{author}, thanks for your interest in headscale.
|
||||
|
||||
Headscale gates pull requests to vouched contributors only. Your account
|
||||
isn't on the list yet, so this PR has been auto-closed. This is **not** a
|
||||
judgment of you or this change.
|
||||
|
||||
Headscale is an "Open Source, acknowledged contribution" project: every
|
||||
contribution starts with a discussion on the issue tracker, **before** any
|
||||
code is written. See [`CONTRIBUTING.md`](https://github.com/juanfont/headscale/blob/main/CONTRIBUTING.md)
|
||||
for the full policy and the rationale.
|
||||
|
||||
To get vouched:
|
||||
|
||||
1. Open an issue describing the change you'd like to make (use case,
|
||||
implementation sketch, who will maintain it). For bug fixes, a clear
|
||||
reproduction is enough.
|
||||
2. Engage with maintainers on the issue. Once we've agreed on scope and
|
||||
approach, a maintainer will `!vouch @{author}`.
|
||||
3. You can then reopen this PR (or open a new one) and it will pass the
|
||||
gate.
|
||||
|
||||
Issues remain open to everyone — no vouching needed there, and that's the
|
||||
intended starting point for any contribution.
|
||||
|
||||
The vouched list: [`.github/VOUCHED.td`](https://github.com/juanfont/headscale/blob/main/.github/VOUCHED.td).
|
||||
@@ -19,4 +19,4 @@ If you find mistakes in the documentation, please submit a fix to the documentat
|
||||
- [ ] updated documentation if needed
|
||||
- [ ] updated CHANGELOG.md
|
||||
|
||||
<!-- If applicable, please reference the issue using `Fixes #XXX` and add tests to cover your new code. -->
|
||||
<!-- If applicable, please reference the issue using `Fixes #XXX` and add tests to cover your new code. -->
|
||||
+10
-14
@@ -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 --fallback --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
|
||||
@@ -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 --fallback --command -- go build -o "headscale"
|
||||
run: env ${{ matrix.env }} go build -o "headscale"
|
||||
./cmd/headscale
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
|
||||
@@ -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,19 +32,15 @@ jobs:
|
||||
- '*.nix'
|
||||
- 'go.*'
|
||||
- '**/*.go'
|
||||
- 'openapi/**'
|
||||
- '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 --fallback --command -- make generate
|
||||
run: make generate
|
||||
|
||||
- name: Check for uncommitted changes
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
|
||||
@@ -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 --fallback --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
|
||||
|
||||
@@ -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 --fallback --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 --fallback --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 --fallback --command -- go build -o headscale ./cmd/headscale
|
||||
run: go build -o headscale ./cmd/headscale
|
||||
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,79 +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 --fallback --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 --fallback --command -- prettier --no-error-on-unmatched-pattern
|
||||
--ignore-unknown --check **/*.{ts,js,md,yaml,yml,sass,css,scss,html}
|
||||
@@ -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.<system>.<name>`; 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
|
||||
@@ -38,15 +38,11 @@ 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'
|
||||
|
||||
@@ -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'
|
||||
@@ -30,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 --fallback --command -- goreleaser release --clean
|
||||
run: goreleaser release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -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/...
|
||||
@@ -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 --fallback --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 --fallback --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,6 +241,8 @@ jobs:
|
||||
- TestACLDynamicUnknownUserRemoval
|
||||
- TestAPIAuthenticationBypass
|
||||
- TestAPIAuthenticationBypassCurl
|
||||
- TestRemoteCLIAuthenticationBypass
|
||||
- TestCLIWithConfigAuthenticationBypass
|
||||
- TestAuthKeyLogoutAndReloginSameUser
|
||||
- TestAuthKeyLogoutAndReloginNewUser
|
||||
- TestAuthKeyLogoutAndReloginSameUserExpiredKey
|
||||
@@ -276,6 +275,8 @@ jobs:
|
||||
- TestNodeTagCommand
|
||||
- TestNodeRouteCommands
|
||||
- TestNodeBackfillIPsCommand
|
||||
- TestOAuthClientCommand
|
||||
- TestOAuthClientCommandValidation
|
||||
- TestPolicyCheckCommand
|
||||
- TestSSHTestsRejectFailingPolicy
|
||||
- TestPolicyCommand
|
||||
@@ -310,6 +311,7 @@ jobs:
|
||||
- Test2118DeletingOnlineNodePanics
|
||||
- TestGrantCapRelay
|
||||
- TestGrantCapDrive
|
||||
- TestK8sOperator
|
||||
- TestEnablingRoutes
|
||||
- TestHASubnetRouterFailover
|
||||
- TestSubnetRouteACL
|
||||
@@ -377,6 +379,8 @@ jobs:
|
||||
- TestTagsAuthKeyWithoutUserInheritsTags
|
||||
- TestTagsAuthKeyWithoutUserRejectsAdvertisedTags
|
||||
- TestTagsAuthKeyConvertToUserViaCLIRegister
|
||||
- TestTS2021WebSocketGET
|
||||
- TestTS2021WASMClientUnderNode
|
||||
- TestTailscaleRustAxum
|
||||
uses: ./.github/workflows/integration-test-template.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -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 --fallback --command -- gotestsum
|
||||
@@ -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"
|
||||
@@ -0,0 +1,30 @@
|
||||
name: "Vouch - Check PR"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.VOUCH_APP_ID }}
|
||||
private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
sparse-checkout: .github/pr-unvouched-message
|
||||
|
||||
- uses: mitchellh/vouch/action/check-pr@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
auto-close: true
|
||||
template-file: .github/pr-unvouched-message
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
@@ -0,0 +1,36 @@
|
||||
name: "Vouch - Manage by Issue"
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: vouch-manage
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
manage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.VOUCH_APP_ID }}
|
||||
private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: mitchellh/vouch/action/manage-by-issue@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0
|
||||
with:
|
||||
repo: ${{ github.repository }}
|
||||
issue-id: ${{ github.event.issue.number }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
vouch-keyword: "!vouch"
|
||||
denounce-keyword: "!denounce"
|
||||
unvouch-keyword: "!unvouch"
|
||||
pull-request: "true"
|
||||
merge-immediately: "true"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
@@ -0,0 +1,32 @@
|
||||
name: "Vouch - Sync CODEOWNERS"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: vouch-manage
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.VOUCH_APP_ID }}
|
||||
private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: mitchellh/vouch/action/sync-codeowners@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0
|
||||
with:
|
||||
repo: ${{ github.repository }}
|
||||
pull-request: "true"
|
||||
merge-immediately: "true"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,9 +75,9 @@ make dev
|
||||
# Individual targets
|
||||
make build # build the headscale binary
|
||||
make test # go test ./...
|
||||
make fmt # format Go and docs
|
||||
make lint # lint Go
|
||||
make generate # regenerate ogen code (after changes to openapi/)
|
||||
make fmt # format Go, docs, proto
|
||||
make lint # lint Go, proto
|
||||
make generate # regenerate protobuf code (after changes to proto/)
|
||||
make clean # remove build artefacts
|
||||
|
||||
# Direct go test invocations
|
||||
@@ -133,11 +133,9 @@ headscale/
|
||||
|
||||
### `hscontrol/` packages
|
||||
|
||||
- `app.go`, `handlers.go`, `noise.go`, `auth.go`, `oidc.go`,
|
||||
- `app.go`, `handlers.go`, `grpcv1.go`, `noise.go`, `auth.go`, `oidc.go`,
|
||||
`poll.go`, `metrics.go`, `debug.go`, `tailsql.go`, `platform_config.go`
|
||||
— top-level server files
|
||||
- `api/v1/` — the ogen-backed v1 HTTP API handlers (`server.go`,
|
||||
`convert.go`, `errors.go`, per-resource handler files)
|
||||
- `state/` — central coordinator (`state.go`) and the copy-on-write
|
||||
`NodeStore` (`node_store.go`). All cross-subsystem operations go
|
||||
through `State`.
|
||||
@@ -211,7 +209,7 @@ both. This is a load-bearing architectural rule.
|
||||
- `SetTags` validation is enforced by `validateNodeOwnership()` in
|
||||
`hscontrol/state/tags.go`.
|
||||
- Examples and edge cases live in `hscontrol/types/node_tags_test.go`
|
||||
and the SetTags tests in `hscontrol/servertest/apiv1_nodes_test.go`.
|
||||
and `hscontrol/grpcv1_test.go` (`TestSetTags_*`).
|
||||
|
||||
**Don't do this**:
|
||||
|
||||
@@ -266,9 +264,9 @@ Key reminders:
|
||||
|
||||
Not Conventional Commits. No `feat:`/`chore:`/`docs:` prefixes.
|
||||
|
||||
- **Code generation**: changes under `openapi/` require `make generate`
|
||||
(which runs `ogen`) and should land in a **separate commit** from the
|
||||
callers that use the regenerated types.
|
||||
- **Protobuf regeneration**: changes under `proto/` require
|
||||
`make generate` (which runs `buf generate`) and should land in a
|
||||
**separate commit** from the callers that use the regenerated types.
|
||||
- **Formatting** is enforced by `golangci-lint` with `golines` (width 88)
|
||||
and `gofumpt`. Run `make fmt` or rely on the pre-commit hook.
|
||||
- **Logging** uses `zerolog`. Prefer single-line chains
|
||||
@@ -277,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
|
||||
|
||||
@@ -288,6 +291,6 @@ Key reminders:
|
||||
- **`.claude/agents/` is deprecated.** Do not create new agent files
|
||||
there. Put behavioural guidance in this file and procedural guidance
|
||||
in the nearest README.
|
||||
- **Do not edit `gen/`** — it is regenerated from `openapi/` by
|
||||
- **Do not edit `gen/`** — it is regenerated from `proto/` by
|
||||
`make generate`.
|
||||
- **Spec changes + code changes should be two commits**, not one.
|
||||
- **Proto changes + code changes should be two commits**, not one.
|
||||
|
||||
@@ -4,6 +4,57 @@
|
||||
|
||||
**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**
|
||||
|
||||
@@ -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 <control-url>
|
||||
# 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"]
|
||||
@@ -82,11 +82,35 @@ lint-go: check-deps $(GO_SOURCES) go.mod go.sum
|
||||
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
|
||||
@@ -106,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)"
|
||||
@echo " fmt - Format all code (Go, docs, markup)"
|
||||
@echo " lint - Lint all code (Go)"
|
||||
@echo " generate - Generate code from the OpenAPI spec"
|
||||
@echo " generate - Generate code (go generate + client)"
|
||||
@echo " dev - Full development workflow (fmt + lint + test + build)"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo ""
|
||||
|
||||
@@ -92,7 +92,8 @@ Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) file.
|
||||
|
||||
### Requirements
|
||||
|
||||
To contribute to headscale you would need the latest version of [Go](https://golang.org).
|
||||
To contribute to headscale you would need the latest version of [Go](https://golang.org)
|
||||
and [Buf](https://buf.build) (Protobuf generator).
|
||||
|
||||
We recommend using [Nix](https://nixos.org/) to setup a development environment. This can
|
||||
be done with `nix develop`, which will install the tools and give you a shell.
|
||||
@@ -109,6 +110,9 @@ formatted with [`golines`](https://github.com/segmentio/golines) (width 88) and
|
||||
Please configure your editor to run the tools while developing and make sure to
|
||||
run `make lint` and `make fmt` before committing any code.
|
||||
|
||||
The **Proto** code is linted with [`buf`](https://docs.buf.build/lint/overview) and
|
||||
formatted with [`clang-format`](https://clang.llvm.org/docs/ClangFormat.html).
|
||||
|
||||
The **docs** are formatted with [`mdformat`](https://mdformat.readthedocs.io).
|
||||
|
||||
The **rest** (Markdown, YAML, etc) is formatted with [`prettier`](https://prettier.io).
|
||||
@@ -118,6 +122,8 @@ Check out the `.golangci.yaml` and `Makefile` to see the specific configuration.
|
||||
### Install development tools
|
||||
|
||||
- Go
|
||||
- Buf
|
||||
- Protobuf tools
|
||||
|
||||
Install and activate:
|
||||
|
||||
@@ -127,8 +133,8 @@ nix develop
|
||||
|
||||
### Testing and building
|
||||
|
||||
Some parts of the project require generated Go code from the OpenAPI spec
|
||||
(if changes are made in `openapi/`) and it must be (re-)generated with:
|
||||
Some parts of the project require the generation of Go code from Protobuf
|
||||
(if changes are made in `proto/`) and it must be (re-)generated with:
|
||||
|
||||
```shell
|
||||
make generate
|
||||
|
||||
+2
-2
@@ -28,8 +28,8 @@ 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 the derived metrics port
|
||||
// (port+1010) inside the valid 1..65535 TCP range.
|
||||
// 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 = `---
|
||||
|
||||
@@ -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 <path> # write the v1 3.0.3 downgrade (for client gen)
|
||||
// go run ./cmd/gen-openapi -api v2 -downgrade <path> # 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)
|
||||
}
|
||||
@@ -2,11 +2,11 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/gen/api/v1"
|
||||
clientv1 "github.com/juanfont/headscale/gen/client/v1"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -16,8 +16,6 @@ const (
|
||||
DefaultAPIKeyExpiry = "90d"
|
||||
)
|
||||
|
||||
var errAPIKeyIDNotFound = errors.New("no api key with id")
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(apiKeysCmd)
|
||||
apiKeysCmd.AddCommand(listAPIKeys)
|
||||
@@ -46,26 +44,36 @@ var listAPIKeys = &cobra.Command{
|
||||
Use: cmdList,
|
||||
Short: "List the Api keys for headscale",
|
||||
Aliases: []string{"ls", cmdShow},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
|
||||
resp, err := client.ListApiKeys(ctx)
|
||||
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, resp.ApiKeys, func() error {
|
||||
rows := make([][]string, 0, len(resp.ApiKeys))
|
||||
for _, key := range resp.ApiKeys {
|
||||
expiration := "-"
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
if key.Expiration.Set {
|
||||
expiration = ColourTime(key.Expiration.Value)
|
||||
apiKeys := resp.JSON200.ApiKeys
|
||||
|
||||
return printListOutput(cmd, apiKeys, func() error {
|
||||
rows := make([][]string, 0, len(apiKeys))
|
||||
for _, key := range apiKeys {
|
||||
expiration := "-"
|
||||
if key.Expiration != nil {
|
||||
expiration = ColourTime(*key.Expiration)
|
||||
}
|
||||
|
||||
var created string
|
||||
if key.CreatedAt != nil {
|
||||
created = key.CreatedAt.Format(HeadscaleDateTimeFormat)
|
||||
}
|
||||
|
||||
rows = append(rows, []string{
|
||||
strconv.FormatUint(key.ID.Value, util.Base10),
|
||||
key.Prefix.Value,
|
||||
key.Id,
|
||||
key.Prefix,
|
||||
expiration,
|
||||
key.CreatedAt.Value.Format(HeadscaleDateTimeFormat),
|
||||
created,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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: apiRunE(func(ctx context.Context, client *apiv1.Client, 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
|
||||
}
|
||||
|
||||
resp, err := client.CreateApiKey(ctx, &apiv1.CreateApiKeyReq{
|
||||
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, resp.ApiKey.Value, resp.ApiKey.Value)
|
||||
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: apiRunE(func(ctx context.Context, client *apiv1.Client, 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
|
||||
}
|
||||
|
||||
err = client.ExpireApiKey(ctx, &apiv1.ExpireApiKeyReq{
|
||||
ID: optUint64(id),
|
||||
Prefix: optString(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, map[string]string{colResult: "Key expired"}, "Key expired")
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.JSON200, "Key expired")
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -141,42 +165,59 @@ var deleteAPIKeyCmd = &cobra.Command{
|
||||
Use: cmdDelete,
|
||||
Short: "Delete an ApiKey",
|
||||
Aliases: []string{"remove", aliasDel},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, 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
|
||||
}
|
||||
|
||||
// Delete is routed by prefix in the path, so resolve an --id to its
|
||||
// prefix first; an empty path segment would 404 at the router.
|
||||
// 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 = apiKeyPrefixByID(ctx, client, id)
|
||||
prefix, err = apiKeyPrefixForID(ctx, client, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = client.DeleteApiKey(ctx, apiv1.DeleteApiKeyParams{Prefix: prefix})
|
||||
resp, err := client.DeleteApiKeyWithResponse(ctx, prefix, &clientv1.DeleteApiKeyParams{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting api key: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, map[string]string{colResult: "Key deleted"}, "Key deleted")
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.JSON200, "Key deleted")
|
||||
}),
|
||||
}
|
||||
|
||||
// apiKeyPrefixByID looks up an API key's prefix by its numeric ID.
|
||||
func apiKeyPrefixByID(ctx context.Context, client *apiv1.Client, id uint64) (string, error) {
|
||||
resp, err := client.ListApiKeys(ctx)
|
||||
// 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)
|
||||
}
|
||||
|
||||
for _, key := range resp.GetApiKeys() {
|
||||
if key.GetID().Or(0) == id {
|
||||
return key.GetPrefix().Or(""), nil
|
||||
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: %d", errAPIKeyIDNotFound, id)
|
||||
return "", fmt.Errorf("%w: api key %d not found", errMissingParameter, id)
|
||||
}
|
||||
|
||||
+44
-36
@@ -3,8 +3,9 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/gen/api/v1"
|
||||
clientv1 "github.com/juanfont/headscale/gen/client/v1"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -33,59 +34,66 @@ var authCmd = &cobra.Command{
|
||||
var authRegisterCmd = &cobra.Command{
|
||||
Use: "register",
|
||||
Short: "Register a node to your network",
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, 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")
|
||||
|
||||
resp, err := client.AuthRegister(ctx, &apiv1.AuthRegisterReq{
|
||||
AuthId: apiv1.NewOptString(authID),
|
||||
User: apiv1.NewOptString(user),
|
||||
resp, err := client.AuthRegisterWithResponse(ctx, clientv1.AuthRegisterJSONRequestBody{
|
||||
AuthId: &authID,
|
||||
User: &user,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("registering node: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(
|
||||
cmd,
|
||||
resp.Node.Value,
|
||||
fmt.Sprintf("Node %s registered", resp.Node.Value.GivenName.Value),
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
// authDecisionRunE builds a RunE for an auth decision command (approve or
|
||||
// reject) that reads the auth-id flag, invokes the given API call, and prints a
|
||||
// result. errVerb is used in the error message; okMsg is printed on success.
|
||||
func authDecisionRunE(
|
||||
errVerb, okMsg string,
|
||||
call func(ctx context.Context, client *apiv1.Client, authID string) error,
|
||||
) func(*cobra.Command, []string) error {
|
||||
return apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
|
||||
authID, _ := cmd.Flags().GetString("auth-id")
|
||||
|
||||
err := call(ctx, client, authID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s auth request: %w", errVerb, err)
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, map[string]string{colResult: okMsg}, okMsg)
|
||||
})
|
||||
node := resp.JSON200.Node
|
||||
|
||||
return printOutput(
|
||||
cmd,
|
||||
node,
|
||||
fmt.Sprintf("Node %s registered", node.GivenName),
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
var authApproveCmd = &cobra.Command{
|
||||
Use: "approve",
|
||||
Short: "Approve a pending authentication request",
|
||||
RunE: authDecisionRunE("approving", "Auth request approved",
|
||||
func(ctx context.Context, client *apiv1.Client, authID string) error {
|
||||
return client.AuthApprove(ctx, &apiv1.AuthApproveReq{AuthId: apiv1.NewOptString(authID)})
|
||||
}),
|
||||
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
|
||||
authID, _ := cmd.Flags().GetString("auth-id")
|
||||
|
||||
resp, err := client.AuthApproveWithResponse(ctx, clientv1.AuthApproveJSONRequestBody{AuthId: &authID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("approving auth request: %w", err)
|
||||
}
|
||||
|
||||
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: authDecisionRunE("rejecting", "Auth request rejected",
|
||||
func(ctx context.Context, client *apiv1.Client, authID string) error {
|
||||
return client.AuthReject(ctx, &apiv1.AuthRejectReq{AuthId: apiv1.NewOptString(authID)})
|
||||
}),
|
||||
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
|
||||
authID, _ := cmd.Flags().GetString("auth-id")
|
||||
|
||||
resp, err := client.AuthRejectWithResponse(ctx, clientv1.AuthRejectJSONRequestBody{AuthId: &authID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("rejecting auth request: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.JSON200, "Auth request rejected")
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/gen/api/v1"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// apiRunE wraps a cobra [cobra.Command.RunE] func, injecting a ready v1 API
|
||||
// client and context. Connection lifecycle is managed by the wrapper.
|
||||
func apiRunE(
|
||||
fn func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error,
|
||||
) func(*cobra.Command, []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
ctx, client, cancel, err := newHeadscaleAPIClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connecting to headscale: %w", err)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
return fn(ctx, client, cmd, args)
|
||||
}
|
||||
}
|
||||
|
||||
// withAPI opens a v1 API client, runs fn with it, and cancels the context
|
||||
// afterwards. It is the building block for commands that branch on a flag
|
||||
// before deciding to talk to the server.
|
||||
func withAPI(fn func(ctx context.Context, client *apiv1.Client) error) error {
|
||||
ctx, client, cancel, err := newHeadscaleAPIClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connecting to headscale: %w", err)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
return fn(ctx, client)
|
||||
}
|
||||
|
||||
// newHeadscaleAPIClient builds a v1 HTTP API client. With no configured
|
||||
// address it talks to the local unix socket (filesystem permissions are the
|
||||
// trust boundary, no API key needed); otherwise it uses HTTPS with the API key.
|
||||
func newHeadscaleAPIClient() (context.Context, *apiv1.Client, 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 == "" {
|
||||
client, err := localSocketClient(cfg.UnixSocket)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return ctx, client, cancel, nil
|
||||
}
|
||||
|
||||
client, err := remoteClient(cfg.CLI.Address, cfg.CLI.APIKey, cfg.CLI.Insecure)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return ctx, client, cancel, nil
|
||||
}
|
||||
|
||||
func localSocketClient(socketPath string) (*apiv1.Client, error) {
|
||||
err := checkSocketPermissions(socketPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return dialSocketWaiting(ctx, socketPath)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// The socket bypasses bearer auth; the token is a placeholder.
|
||||
return apiv1.NewClient("http://unix", cliToken("local-socket"), apiv1.WithClient(httpClient))
|
||||
}
|
||||
|
||||
// dialSocketWaiting connects to the local unix socket, retrying while it is
|
||||
// still absent. headscale removes and rebinds the socket during startup, so a
|
||||
// CLI command issued right after "systemctl start" can beat the server to it.
|
||||
// This mirrors the old blocking dial, which retried until the CLI timeout.
|
||||
func dialSocketWaiting(ctx context.Context, socketPath string) (net.Conn, error) {
|
||||
var dialer net.Dialer
|
||||
|
||||
for {
|
||||
conn, err := dialer.DialContext(ctx, "unix", socketPath)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, err
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func remoteClient(address, apiKey string, insecure bool) (*apiv1.Client, error) {
|
||||
if apiKey == "" {
|
||||
return nil, errAPIKeyNotSet
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
if insecure {
|
||||
//nolint:gosec // G402: insecure is an explicit, documented opt-in.
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
return apiv1.NewClient(
|
||||
serverURLFromAddress(address),
|
||||
cliToken(apiKey),
|
||||
apiv1.WithClient(&http.Client{Transport: transport}),
|
||||
)
|
||||
}
|
||||
|
||||
// serverURLFromAddress turns a configured CLI address into a base URL,
|
||||
// defaulting to https when no scheme is given.
|
||||
func serverURLFromAddress(address string) string {
|
||||
if strings.Contains(address, "://") {
|
||||
return address
|
||||
}
|
||||
|
||||
return "https://" + address
|
||||
}
|
||||
|
||||
// checkSocketPermissions gives a friendlier error than a dial failure when the
|
||||
// user cannot access the headscale socket.
|
||||
func checkSocketPermissions(socketPath string) error {
|
||||
socket, err := os.OpenFile(socketPath, os.O_WRONLY, SocketWritePermissions) //nolint
|
||||
if err != nil {
|
||||
if os.IsPermission(err) {
|
||||
return fmt.Errorf(
|
||||
"unable to read/write to headscale socket %q, do you have the correct permissions? %w",
|
||||
socketPath, err,
|
||||
)
|
||||
}
|
||||
|
||||
// ENXIO and similar are expected for a socket opened with O_WRONLY; the
|
||||
// real connection uses net.Dial which handles sockets properly.
|
||||
return nil
|
||||
}
|
||||
|
||||
socket.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// optString / optUint64 / optTime build optional API request values from flag
|
||||
// inputs, treating zero values as "unset".
|
||||
|
||||
func optString(s string) apiv1.OptString {
|
||||
if s == "" {
|
||||
return apiv1.OptString{}
|
||||
}
|
||||
|
||||
return apiv1.NewOptString(s)
|
||||
}
|
||||
|
||||
func optUint64(v uint64) apiv1.OptUint64 {
|
||||
if v == 0 {
|
||||
return apiv1.OptUint64{}
|
||||
}
|
||||
|
||||
return apiv1.NewOptUint64(v)
|
||||
}
|
||||
|
||||
// expirationFromFlag parses the --expiration flag as a Prometheus-style
|
||||
// duration (e.g. "90d", "1h") and returns it as an absolute optional timestamp.
|
||||
// An empty flag yields an unset value.
|
||||
func expirationFromFlag(cmd *cobra.Command) (apiv1.OptDateTime, error) {
|
||||
durationStr, _ := cmd.Flags().GetString("expiration")
|
||||
if durationStr == "" {
|
||||
return apiv1.OptDateTime{}, nil
|
||||
}
|
||||
|
||||
duration, err := model.ParseDuration(durationStr)
|
||||
if err != nil {
|
||||
return apiv1.OptDateTime{}, fmt.Errorf("parsing duration: %w", err)
|
||||
}
|
||||
|
||||
return apiv1.NewOptDateTime(time.Now().UTC().Add(time.Duration(duration))), nil
|
||||
}
|
||||
|
||||
// cliToken is an [apiv1.SecuritySource] that supplies a fixed bearer token.
|
||||
type cliToken string
|
||||
|
||||
func (t cliToken) BearerAuth(context.Context, apiv1.OperationName) (apiv1.BearerAuth, error) {
|
||||
return apiv1.BearerAuth{Token: string(t)}, nil
|
||||
}
|
||||
@@ -3,8 +3,9 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/gen/api/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: apiRunE(func(ctx context.Context, client *apiv1.Client, 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,16 +45,20 @@ var createNodeCmd = &cobra.Command{
|
||||
|
||||
routes, _ := cmd.Flags().GetStringSlice("route")
|
||||
|
||||
resp, err := client.DebugCreateNode(ctx, &apiv1.DebugCreateNodeReq{
|
||||
Key: apiv1.NewOptString(registrationID),
|
||||
Name: apiv1.NewOptString(name),
|
||||
User: apiv1.NewOptString(user),
|
||||
Routes: routes,
|
||||
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, resp.Node.Value, "Node created")
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.JSON200.Node, "Node created")
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/gen/api/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: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
|
||||
resp, err := client.Health(ctx)
|
||||
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, resp, "")
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.JSON200, "")
|
||||
}),
|
||||
}
|
||||
|
||||
+154
-88
@@ -3,12 +3,13 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/gen/api/v1"
|
||||
clientv1 "github.com/juanfont/headscale/gen/client/v1"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/pterm/pterm"
|
||||
"github.com/samber/lo"
|
||||
@@ -66,22 +67,30 @@ var registerNodeCmd = &cobra.Command{
|
||||
Use: "register",
|
||||
Short: "Registers a node to your network",
|
||||
Deprecated: "use 'headscale auth register --auth-id <id> --user <user>' instead",
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, 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")
|
||||
|
||||
resp, err := client.RegisterNode(ctx, apiv1.RegisterNodeParams{
|
||||
Key: optString(registrationID),
|
||||
User: optString(user),
|
||||
})
|
||||
params := &clientv1.RegisterNodeParams{
|
||||
User: &user,
|
||||
Key: ®istrationID,
|
||||
}
|
||||
|
||||
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,
|
||||
resp.Node.Value,
|
||||
fmt.Sprintf("Node %s registered", resp.Node.Value.GivenName.Value),
|
||||
node,
|
||||
fmt.Sprintf("Node %s registered", node.GivenName),
|
||||
)
|
||||
}),
|
||||
}
|
||||
@@ -90,16 +99,27 @@ var listNodesCmd = &cobra.Command{
|
||||
Use: cmdList,
|
||||
Short: "List nodes",
|
||||
Aliases: []string{"ls", cmdShow},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, 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")
|
||||
|
||||
resp, err := client.ListNodes(ctx, apiv1.ListNodesParams{User: optString(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, resp.Nodes, func() error {
|
||||
tableData, err := nodesToPtables(resp.Nodes)
|
||||
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)
|
||||
}
|
||||
@@ -113,26 +133,32 @@ var listNodeRoutesCmd = &cobra.Command{
|
||||
Use: "list-routes",
|
||||
Short: "List routes available on nodes",
|
||||
Aliases: []string{"lsr", "routes"},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, 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")
|
||||
|
||||
resp, err := client.ListNodes(ctx, apiv1.ListNodesParams{})
|
||||
resp, err := client.ListNodesWithResponse(ctx, &clientv1.ListNodesParams{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing nodes: %w", err)
|
||||
}
|
||||
|
||||
nodes := resp.Nodes
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
nodes := resp.JSON200.Nodes
|
||||
|
||||
if identifier != 0 {
|
||||
for _, node := range resp.Nodes {
|
||||
if node.ID.Value == identifier {
|
||||
nodes = []apiv1.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 apiv1.Node, _ int) bool {
|
||||
nodes = lo.Filter(nodes, func(n clientv1.Node, _ int) bool {
|
||||
return len(n.SubnetRoutes) > 0 || len(n.ApprovedRoutes) > 0 || len(n.AvailableRoutes) > 0
|
||||
})
|
||||
|
||||
@@ -149,21 +175,27 @@ var expireNodeCmd = &cobra.Command{
|
||||
|
||||
Use --disable to disable key expiry (node will never expire).`,
|
||||
Aliases: []string{"logout", aliasExp, "e"},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, 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 {
|
||||
resp, err := client.ExpireNode(ctx, apiv1.ExpireNodeParams{
|
||||
NodeID: identifier,
|
||||
DisableExpiry: apiv1.NewOptBool(true),
|
||||
disable := true
|
||||
|
||||
resp, err := client.ExpireNodeWithResponse(ctx, nodeID, clientv1.ExpireNodeJSONRequestBody{
|
||||
DisableExpiry: &disable,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("disabling node expiry: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.Node.Value, "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")
|
||||
@@ -181,26 +213,31 @@ Use --disable to disable key expiry (node will never expire).`,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.ExpireNode(ctx, apiv1.ExpireNodeParams{
|
||||
NodeID: identifier,
|
||||
Expiry: apiv1.NewOptDateTime(expiryTime),
|
||||
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, resp.Node.Value, "Node expired")
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.Node.Value, "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: apiRunE(func(ctx context.Context, client *apiv1.Client, 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 := ""
|
||||
@@ -208,15 +245,16 @@ var renameNodeCmd = &cobra.Command{
|
||||
newName = args[0]
|
||||
}
|
||||
|
||||
resp, err := client.RenameNode(ctx, apiv1.RenameNodeParams{
|
||||
NodeID: identifier,
|
||||
NewName: newName,
|
||||
})
|
||||
resp, err := client.RenameNodeWithResponse(ctx, strconv.FormatUint(identifier, util.Base10), newName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("renaming node: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.Node.Value, "Node renamed")
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.JSON200.Node, "Node renamed")
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -224,26 +262,35 @@ var deleteNodeCmd = &cobra.Command{
|
||||
Use: cmdDelete,
|
||||
Short: "Delete a node",
|
||||
Aliases: []string{aliasDel},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, 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)
|
||||
|
||||
getResp, err := client.GetNode(ctx, apiv1.GetNodeParams{NodeID: identifier})
|
||||
getResponse, err := client.GetNodeWithResponse(ctx, nodeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting node: %w", err)
|
||||
}
|
||||
|
||||
if getResponse.StatusCode() != http.StatusOK {
|
||||
return apiError(getResponse.StatusCode(), getResponse.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
if !confirmAction(cmd, fmt.Sprintf(
|
||||
"Do you want to remove the node %s?",
|
||||
getResp.Node.Value.Name.Value,
|
||||
getResponse.JSON200.Node.Name,
|
||||
)) {
|
||||
return printOutput(cmd, map[string]string{colResult: "Node not deleted"}, "Node not deleted")
|
||||
}
|
||||
|
||||
err = client.DeleteNode(ctx, apiv1.DeleteNodeParams{NodeID: identifier})
|
||||
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"},
|
||||
@@ -267,23 +314,31 @@ all nodes that are missing.
|
||||
If you remove IPv4 or IPv6 prefixes from the config,
|
||||
it can be run to remove the IPs that should no longer
|
||||
be assigned to nodes.`,
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !confirmAction(cmd, "Are you sure that you want to assign/remove IPs to/from nodes?") {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := client.BackfillNodeIPs(ctx, apiv1.BackfillNodeIPsParams{
|
||||
Confirmed: apiv1.NewOptBool(true),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("backfilling IPs: %w", err)
|
||||
}
|
||||
return withClient(func(ctx context.Context, client *clientv1.ClientWithResponses) error {
|
||||
confirmed := true
|
||||
|
||||
return printOutput(cmd, resp.Changes, "Node IPs backfilled successfully")
|
||||
}),
|
||||
resp, err := client.BackfillNodeIPsWithResponse(ctx, &clientv1.BackfillNodeIPsParams{
|
||||
Confirmed: &confirmed,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("backfilling IPs: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.JSON200, "Node IPs backfilled successfully")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
func nodesToPtables(nodes []apiv1.Node) (pterm.TableData, error) {
|
||||
func nodesToPtables(nodes []clientv1.Node) (pterm.TableData, error) {
|
||||
tableHeader := []string{
|
||||
"ID",
|
||||
"Hostname",
|
||||
@@ -303,49 +358,46 @@ func nodesToPtables(nodes []apiv1.Node) (pterm.TableData, error) {
|
||||
tableData[0] = tableHeader
|
||||
|
||||
for _, node := range nodes {
|
||||
ephemeral := node.PreAuthKey.Set && node.PreAuthKey.Value.Ephemeral.Value
|
||||
// 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.Set {
|
||||
lastSeenTime = node.LastSeen.Value.Format(HeadscaleDateTimeFormat)
|
||||
if node.LastSeen != nil {
|
||||
lastSeenTime = node.LastSeen.Format(HeadscaleDateTimeFormat)
|
||||
}
|
||||
|
||||
expiryTime := "N/A"
|
||||
if node.Expiry.Set {
|
||||
expiryTime = node.Expiry.Value.Format(HeadscaleDateTimeFormat)
|
||||
if node.Expiry != nil {
|
||||
expiryTime = node.Expiry.Format(HeadscaleDateTimeFormat)
|
||||
}
|
||||
|
||||
var machineKey key.MachinePublic
|
||||
|
||||
err := machineKey.UnmarshalText([]byte(node.MachineKey.Value))
|
||||
err := machineKey.UnmarshalText([]byte(node.MachineKey))
|
||||
if err != nil {
|
||||
machineKey = key.MachinePublic{}
|
||||
}
|
||||
|
||||
var nodeKey key.NodePublic
|
||||
|
||||
err = nodeKey.UnmarshalText([]byte(node.NodeKey.Value))
|
||||
err = nodeKey.UnmarshalText([]byte(node.NodeKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
online := pterm.LightRed("offline")
|
||||
if node.Online.Value {
|
||||
if node.Online {
|
||||
online = pterm.LightGreen("online")
|
||||
}
|
||||
|
||||
expired := pterm.LightGreen("no")
|
||||
if node.Expiry.Set && node.Expiry.Value.Before(time.Now()) {
|
||||
if node.Expiry != nil && node.Expiry.Before(time.Now()) {
|
||||
expired = pterm.LightRed("yes")
|
||||
}
|
||||
|
||||
tags := strings.Join(node.Tags, "\n")
|
||||
|
||||
var user string
|
||||
if node.User.Set {
|
||||
user = node.User.Value.Name.Value
|
||||
}
|
||||
|
||||
var ipBuilder strings.Builder
|
||||
|
||||
for _, addr := range node.IpAddresses {
|
||||
@@ -359,28 +411,35 @@ func nodesToPtables(nodes []apiv1.Node) (pterm.TableData, error) {
|
||||
}
|
||||
}
|
||||
|
||||
ipAddresses := ipBuilder.String()
|
||||
|
||||
nodeData := []string{
|
||||
strconv.FormatUint(node.ID.Value, util.Base10),
|
||||
node.Name.Value,
|
||||
node.GivenName.Value,
|
||||
node.Id,
|
||||
node.Name,
|
||||
node.GivenName,
|
||||
machineKey.ShortString(),
|
||||
nodeKey.ShortString(),
|
||||
user,
|
||||
node.User.Name,
|
||||
tags,
|
||||
ipBuilder.String(),
|
||||
ipAddresses,
|
||||
strconv.FormatBool(ephemeral),
|
||||
lastSeenTime,
|
||||
expiryTime,
|
||||
online,
|
||||
expired,
|
||||
}
|
||||
tableData = append(tableData, nodeData)
|
||||
tableData = append(
|
||||
tableData,
|
||||
nodeData,
|
||||
)
|
||||
}
|
||||
|
||||
return tableData, nil
|
||||
}
|
||||
|
||||
func nodeRoutesToPtables(nodes []apiv1.Node) pterm.TableData {
|
||||
func nodeRoutesToPtables(
|
||||
nodes []clientv1.Node,
|
||||
) pterm.TableData {
|
||||
tableHeader := []string{
|
||||
"ID",
|
||||
"Hostname",
|
||||
@@ -393,13 +452,16 @@ func nodeRoutesToPtables(nodes []apiv1.Node) pterm.TableData {
|
||||
|
||||
for _, node := range nodes {
|
||||
nodeData := []string{
|
||||
strconv.FormatUint(node.ID.Value, util.Base10),
|
||||
node.GivenName.Value,
|
||||
node.Id,
|
||||
node.GivenName,
|
||||
strings.Join(node.ApprovedRoutes, "\n"),
|
||||
strings.Join(node.AvailableRoutes, "\n"),
|
||||
strings.Join(node.SubnetRoutes, "\n"),
|
||||
}
|
||||
tableData = append(tableData, nodeData)
|
||||
tableData = append(
|
||||
tableData,
|
||||
nodeData,
|
||||
)
|
||||
}
|
||||
|
||||
return tableData
|
||||
@@ -409,39 +471,43 @@ var tagCmd = &cobra.Command{
|
||||
Use: "tag",
|
||||
Short: "Manage the tags of a node",
|
||||
Aliases: []string{"tags", "t"},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, 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")
|
||||
|
||||
resp, err := client.SetTags(
|
||||
ctx,
|
||||
&apiv1.SetTagsReq{Tags: tagsToSet},
|
||||
apiv1.SetTagsParams{NodeID: identifier},
|
||||
)
|
||||
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.Node.Value, "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: apiRunE(func(ctx context.Context, client *apiv1.Client, 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")
|
||||
|
||||
resp, err := client.SetApprovedRoutes(
|
||||
ctx,
|
||||
&apiv1.SetApprovedRoutesReq{Routes: routes},
|
||||
apiv1.SetApprovedRoutesParams{NodeID: identifier},
|
||||
)
|
||||
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.Node.Value, "Node updated")
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.JSON200.Node, "Node updated")
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+34
-13
@@ -4,9 +4,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/gen/api/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"
|
||||
@@ -20,9 +21,8 @@ const (
|
||||
|
||||
var errAborted = errors.New("command aborted by user")
|
||||
|
||||
// bypassDatabase loads the server config and opens the database directly,
|
||||
// bypassing the running 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 {
|
||||
@@ -90,13 +90,17 @@ var getPolicy = &cobra.Command{
|
||||
|
||||
policyData = pol.Data
|
||||
} else {
|
||||
err := withAPI(func(ctx context.Context, client *apiv1.Client) error {
|
||||
resp, err := client.GetPolicy(ctx)
|
||||
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)
|
||||
}
|
||||
|
||||
policyData = resp.Policy.Value
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
policyData = resp.JSON200.Policy
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -150,14 +154,20 @@ var setPolicy = &cobra.Command{
|
||||
return fmt.Errorf("setting ACL policy: %w", err)
|
||||
}
|
||||
} else {
|
||||
err := withAPI(func(ctx context.Context, client *apiv1.Client) error {
|
||||
_, err := client.SetPolicy(ctx, &apiv1.SetPolicyReq{
|
||||
Policy: apiv1.NewOptString(string(policyBytes)),
|
||||
policyStr := string(policyBytes)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -223,10 +233,21 @@ var checkPolicy = &cobra.Command{
|
||||
return nil
|
||||
}
|
||||
|
||||
err = withAPI(func(ctx context.Context, client *apiv1.Client) error {
|
||||
return client.CheckPolicy(ctx, &apiv1.CheckPolicyReq{
|
||||
Policy: apiv1.NewOptString(string(policyBytes)),
|
||||
policyStr := 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
|
||||
|
||||
@@ -3,10 +3,11 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/gen/api/v1"
|
||||
clientv1 "github.com/juanfont/headscale/gen/client/v1"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -44,39 +45,40 @@ var listPreAuthKeys = &cobra.Command{
|
||||
Use: cmdList,
|
||||
Short: "List all preauthkeys",
|
||||
Aliases: []string{"ls", cmdShow},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
|
||||
resp, err := client.ListPreAuthKeys(ctx)
|
||||
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, resp.PreAuthKeys, func() error {
|
||||
rows := make([][]string, 0, len(resp.PreAuthKeys))
|
||||
for _, key := range resp.PreAuthKeys {
|
||||
expiration := "-"
|
||||
if key.Expiration.Set {
|
||||
expiration = ColourTime(key.Expiration.Value)
|
||||
}
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
var owner string
|
||||
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.Set:
|
||||
owner = key.User.Value.Name.Value
|
||||
default:
|
||||
owner = "-"
|
||||
case key.User.Id != "":
|
||||
owner = key.User.Name
|
||||
}
|
||||
|
||||
rows = append(rows, []string{
|
||||
strconv.FormatUint(key.ID.Value, util.Base10),
|
||||
key.Key.Value,
|
||||
strconv.FormatBool(key.Reusable.Value),
|
||||
strconv.FormatBool(key.Ephemeral.Value),
|
||||
strconv.FormatBool(key.Used.Value),
|
||||
key.Id,
|
||||
key.Key,
|
||||
strconv.FormatBool(key.Reusable),
|
||||
strconv.FormatBool(key.Ephemeral),
|
||||
strconv.FormatBool(key.Used),
|
||||
expiration,
|
||||
key.CreatedAt.Value.Format(HeadscaleDateTimeFormat),
|
||||
key.CreatedAt.Format(HeadscaleDateTimeFormat),
|
||||
owner,
|
||||
})
|
||||
}
|
||||
@@ -99,29 +101,39 @@ var createPreAuthKeyCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Creates a new preauthkey",
|
||||
Aliases: []string{"c", cmdNew},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, 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
|
||||
}
|
||||
|
||||
resp, err := client.CreatePreAuthKey(ctx, &apiv1.CreatePreAuthKeyReq{
|
||||
User: optUint64(user),
|
||||
Reusable: apiv1.NewOptBool(reusable),
|
||||
Ephemeral: apiv1.NewOptBool(ephemeral),
|
||||
AclTags: tags,
|
||||
Expiration: expiration,
|
||||
})
|
||||
userStr := strconv.FormatUint(user, util.Base10)
|
||||
|
||||
request := clientv1.CreatePreAuthKeyJSONRequestBody{
|
||||
User: &userStr,
|
||||
Reusable: &reusable,
|
||||
Ephemeral: &ephemeral,
|
||||
AclTags: &tags,
|
||||
Expiration: &expiryTime,
|
||||
}
|
||||
|
||||
resp, err := client.CreatePreAuthKeyWithResponse(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating preauthkey: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.PreAuthKey.Value, resp.PreAuthKey.Value.Key.Value)
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
preAuthKey := resp.JSON200.PreAuthKey
|
||||
|
||||
return printOutput(cmd, preAuthKey, preAuthKey.Key)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -139,18 +151,26 @@ var expirePreAuthKeyCmd = &cobra.Command{
|
||||
Use: cmdExpire,
|
||||
Short: "Expire a preauthkey",
|
||||
Aliases: []string{"revoke", aliasExp, "e"},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
|
||||
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
|
||||
id, err := preAuthKeyID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.ExpirePreAuthKey(ctx, &apiv1.ExpirePreAuthKeyReq{ID: apiv1.NewOptUint64(id)})
|
||||
idStr := strconv.FormatUint(id, util.Base10)
|
||||
|
||||
resp, err := client.ExpirePreAuthKeyWithResponse(ctx, clientv1.ExpirePreAuthKeyJSONRequestBody{
|
||||
Id: &idStr,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("expiring preauthkey: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, map[string]string{colResult: "Key expired"}, "Key expired")
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.JSON200, "Key expired")
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -158,17 +178,25 @@ var deletePreAuthKeyCmd = &cobra.Command{
|
||||
Use: cmdDelete,
|
||||
Short: "Delete a preauthkey",
|
||||
Aliases: []string{aliasDel, "rm", "d"},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
|
||||
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
|
||||
id, err := preAuthKeyID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.DeletePreAuthKey(ctx, apiv1.DeletePreAuthKeyParams{ID: apiv1.NewOptUint64(id)})
|
||||
idStr := strconv.FormatUint(id, util.Base10)
|
||||
|
||||
resp, err := client.DeletePreAuthKeyWithResponse(ctx, &clientv1.DeletePreAuthKeyParams{
|
||||
Id: &idStr,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting preauthkey: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, map[string]string{colResult: "Key deleted"}, "Key deleted")
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.JSON200, "Key deleted")
|
||||
}),
|
||||
}
|
||||
|
||||
+79
-43
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/gen/api/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/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -43,27 +46,39 @@ func usernameAndIDFromFlag(cmd *cobra.Command) (uint64, string, error) {
|
||||
// returning the raw flag id and the matched user.
|
||||
func resolveSingleUser(
|
||||
ctx context.Context,
|
||||
client *apiv1.Client,
|
||||
client *clientv1.ClientWithResponses,
|
||||
cmd *cobra.Command,
|
||||
) (uint64, *apiv1.User, error) {
|
||||
) (uint64, *clientv1.User, error) {
|
||||
id, username, err := usernameAndIDFromFlag(cmd)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
resp, err := client.ListUsers(ctx, apiv1.ListUsersParams{
|
||||
Name: optString(username),
|
||||
ID: optUint64(id),
|
||||
})
|
||||
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 len(resp.Users) != 1 {
|
||||
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, &resp.Users[0], nil
|
||||
return id, &users[0], nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -100,32 +115,41 @@ var createUserCmd = &cobra.Command{
|
||||
|
||||
return nil
|
||||
},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
|
||||
req := &apiv1.CreateUserReq{Name: apiv1.NewOptString(args[0])}
|
||||
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 API client")
|
||||
|
||||
request := clientv1.CreateUserJSONRequestBody{Name: &userName}
|
||||
|
||||
if displayName, _ := cmd.Flags().GetString("display-name"); displayName != "" {
|
||||
req.DisplayName = apiv1.NewOptString(displayName)
|
||||
request.DisplayName = &displayName
|
||||
}
|
||||
|
||||
if email, _ := cmd.Flags().GetString("email"); email != "" {
|
||||
req.Email = apiv1.NewOptString(email)
|
||||
request.Email = &email
|
||||
}
|
||||
|
||||
if pictureURL, _ := cmd.Flags().GetString("picture-url"); pictureURL != "" {
|
||||
_, err := url.Parse(pictureURL)
|
||||
if err != nil {
|
||||
if _, err := url.Parse(pictureURL); err != nil { //nolint:noinlineerr
|
||||
return fmt.Errorf("invalid picture URL: %w", err)
|
||||
}
|
||||
|
||||
req.PictureUrl = apiv1.NewOptString(pictureURL)
|
||||
request.PictureUrl = &pictureURL
|
||||
}
|
||||
|
||||
resp, err := client.CreateUser(ctx, req)
|
||||
log.Trace().Interface(zf.Request, request).Msg("sending CreateUser request")
|
||||
|
||||
resp, err := client.CreateUserWithResponse(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating user: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.User.Value, "User created")
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.JSON200.User, "User created")
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -133,25 +157,29 @@ var destroyUserCmd = &cobra.Command{
|
||||
Use: "destroy --identifier ID or --name NAME",
|
||||
Short: "Destroys a user",
|
||||
Aliases: []string{cmdDelete},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
|
||||
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
|
||||
}
|
||||
|
||||
if !confirmAction(cmd, fmt.Sprintf(
|
||||
"Do you want to remove the user %q (%d) and any associated preauthkeys?",
|
||||
user.Name.Value, user.ID.Value,
|
||||
"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")
|
||||
}
|
||||
|
||||
err = client.DeleteUser(ctx, apiv1.DeleteUserParams{ID: user.ID.Value})
|
||||
resp, err := client.DeleteUserWithResponse(ctx, user.Id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("destroying user: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, map[string]string{colResult: "User destroyed"}, "User destroyed")
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.JSON200, "User destroyed")
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -159,8 +187,8 @@ var listUsersCmd = &cobra.Command{
|
||||
Use: cmdList,
|
||||
Short: "List all the users",
|
||||
Aliases: []string{"ls", cmdShow},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
|
||||
var params apiv1.ListUsersParams
|
||||
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")
|
||||
@@ -169,29 +197,36 @@ var listUsersCmd = &cobra.Command{
|
||||
// filter by one param at most
|
||||
switch {
|
||||
case id > 0:
|
||||
params.ID = apiv1.NewOptUint64(uint64(id))
|
||||
idStr := strconv.FormatInt(id, util.Base10)
|
||||
params.Id = &idStr
|
||||
case username != "":
|
||||
params.Name = apiv1.NewOptString(username)
|
||||
params.Name = &username
|
||||
case email != "":
|
||||
params.Email = apiv1.NewOptString(email)
|
||||
params.Email = &email
|
||||
}
|
||||
|
||||
resp, err := client.ListUsers(ctx, params)
|
||||
resp, err := client.ListUsersWithResponse(ctx, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing users: %w", err)
|
||||
}
|
||||
|
||||
return printListOutput(cmd, resp.Users, func() error {
|
||||
rows := make([][]string, 0, len(resp.Users))
|
||||
for _, user := range resp.Users {
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
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.ID.Value, util.Base10),
|
||||
user.DisplayName.Value,
|
||||
user.Name.Value,
|
||||
user.Email.Value,
|
||||
user.CreatedAt.Value.Format(HeadscaleDateTimeFormat),
|
||||
user.Id,
|
||||
user.DisplayName,
|
||||
user.Name,
|
||||
user.Email,
|
||||
user.CreatedAt.Format(HeadscaleDateTimeFormat),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -205,7 +240,7 @@ var renameUserCmd = &cobra.Command{
|
||||
Use: "rename",
|
||||
Short: "Renames a user",
|
||||
Aliases: []string{"mv"},
|
||||
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
|
||||
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
|
||||
@@ -213,14 +248,15 @@ var renameUserCmd = &cobra.Command{
|
||||
|
||||
newName, _ := cmd.Flags().GetString("new-name")
|
||||
|
||||
resp, err := client.RenameUser(ctx, apiv1.RenameUserParams{
|
||||
OldID: id,
|
||||
NewName: newName,
|
||||
})
|
||||
resp, err := client.RenameUserWithResponse(ctx, strconv.FormatUint(id, util.Base10), newName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("renaming user: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.User.Value, "User renamed")
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.JSON200.User, "User renamed")
|
||||
}),
|
||||
}
|
||||
|
||||
+231
-40
@@ -1,17 +1,27 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -28,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)
|
||||
@@ -59,37 +103,185 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// addressableForJSON makes value-typed generated API structs (and slice
|
||||
// elements) addressable so their pointer-receiver MarshalJSON is used. That
|
||||
// method omits unset optional fields; stdlib reflection over a value instead
|
||||
// calls Opt*.MarshalJSON directly, which returns empty for an unset field and
|
||||
// fails with "unexpected end of JSON input".
|
||||
func addressableForJSON(v any) any {
|
||||
rv := reflect.ValueOf(v)
|
||||
// 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, cancel, err := newHeadscaleCLIWithConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connecting to headscale: %w", err)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
switch rv.Kind() { //nolint:exhaustive // default handles all other kinds
|
||||
case reflect.Slice, reflect.Array:
|
||||
out := make([]any, rv.Len())
|
||||
for i := range out {
|
||||
out[i] = addressableForJSON(rv.Index(i).Interface())
|
||||
return fn(ctx, client, cmd, args)
|
||||
}
|
||||
}
|
||||
|
||||
// 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, fmt.Errorf("loading configuration: %w", err)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Dur("timeout", cfg.CLI.Timeout).
|
||||
Msgf("Setting timeout")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout)
|
||||
|
||||
address := cfg.CLI.Address
|
||||
|
||||
// If the address is not set, we assume that we are on the server hosting [hscontrol].
|
||||
if address == "" {
|
||||
log.Debug().
|
||||
Str("socket", cfg.UnixSocket).
|
||||
Msgf("HEADSCALE_CLI_ADDRESS environment is not set, connecting to unix socket.")
|
||||
|
||||
client, err := newSocketClient(cfg.UnixSocket)
|
||||
if err != nil {
|
||||
cancel()
|
||||
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return out
|
||||
case reflect.Struct:
|
||||
p := reflect.New(rv.Type())
|
||||
p.Elem().Set(rv)
|
||||
log.Trace().Caller().Str(zf.Address, cfg.UnixSocket).Msg("connecting via unix socket")
|
||||
|
||||
return p.Interface()
|
||||
default:
|
||||
return v
|
||||
return ctx, client, cancel, nil
|
||||
}
|
||||
|
||||
// Remote connections require an API key for authentication.
|
||||
apiKey := cfg.CLI.APIKey
|
||||
if apiKey == "" {
|
||||
cancel()
|
||||
|
||||
return nil, nil, nil, errAPIKeyNotSet
|
||||
}
|
||||
|
||||
client, err := newRemoteClient(address, apiKey, cfg.CLI.Insecure)
|
||||
if err != nil {
|
||||
cancel()
|
||||
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
log.Trace().Caller().Str(zf.Address, address).Msg("connecting via HTTPS")
|
||||
|
||||
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
|
||||
// default (empty) format the human-readable override string is returned.
|
||||
func formatOutput(result any, override string, outputFormat string) (string, error) {
|
||||
result = addressableForJSON(result)
|
||||
|
||||
switch outputFormat {
|
||||
case outputFormatJSON:
|
||||
b, err := json.MarshalIndent(result, "", "\t")
|
||||
@@ -106,21 +298,7 @@ func formatOutput(result any, override string, outputFormat string) (string, err
|
||||
|
||||
return string(b), nil
|
||||
case outputFormatYAML:
|
||||
// Route through JSON so types with a custom MarshalJSON (the generated
|
||||
// API types) serialise by their JSON shape, then convert to YAML.
|
||||
j, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshalling output: %w", err)
|
||||
}
|
||||
|
||||
var generic any
|
||||
|
||||
err = yaml.Unmarshal(j, &generic)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("converting output to YAML: %w", err)
|
||||
}
|
||||
|
||||
b, err := yaml.Marshal(generic)
|
||||
b, err := yaml.Marshal(result)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshalling YAML output: %w", err)
|
||||
}
|
||||
@@ -146,6 +324,19 @@ func printOutput(cmd *cobra.Command, result any, override string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// expirationFromFlag parses the --expiration flag as a Prometheus-style
|
||||
// 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 time.Time{}, fmt.Errorf("parsing duration: %w", err)
|
||||
}
|
||||
|
||||
return time.Now().UTC().Add(time.Duration(duration)), nil
|
||||
}
|
||||
|
||||
// confirmAction returns true when the user confirms a prompt, or when
|
||||
// --force is set. Callers decide what to do when it returns false.
|
||||
func confirmAction(cmd *cobra.Command, prompt string) bool {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
+27
-144
@@ -1,152 +1,35 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
import "testing"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/gen/api/v1"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
// sampleNode builds a fully-populated node with valid machine/node keys so the
|
||||
// table renderer's UnmarshalText/ParseAddr paths exercise real data.
|
||||
func sampleNode(t *testing.T) apiv1.Node {
|
||||
t.Helper()
|
||||
|
||||
mkey, err := key.NewMachine().Public().MarshalText()
|
||||
if err != nil {
|
||||
t.Fatalf("machine key: %v", err)
|
||||
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",
|
||||
},
|
||||
}
|
||||
|
||||
nkey, err := key.NewNode().Public().MarshalText()
|
||||
if err != nil {
|
||||
t.Fatalf("node key: %v", err)
|
||||
}
|
||||
|
||||
return apiv1.Node{
|
||||
ID: apiv1.NewOptUint64(7),
|
||||
Name: apiv1.NewOptString("host7"),
|
||||
GivenName: apiv1.NewOptString("host7"),
|
||||
MachineKey: apiv1.NewOptString(string(mkey)),
|
||||
NodeKey: apiv1.NewOptString(string(nkey)),
|
||||
User: apiv1.NewOptUser(apiv1.User{Name: apiv1.NewOptString("alice")}),
|
||||
IpAddresses: []string{"100.64.0.7", "fd7a:115c:a1e0::7"},
|
||||
Online: apiv1.NewOptBool(true),
|
||||
// Expiry, LastSeen, Tags deliberately unset.
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatOutputValueWithUnsetOptional guards against a regression where the
|
||||
// generated API types, passed by value with unset optional fields, fail to
|
||||
// marshal because stdlib reflection calls Opt*.MarshalJSON directly.
|
||||
func TestFormatOutputValueWithUnsetOptional(t *testing.T) {
|
||||
cases := map[string]any{
|
||||
"user": apiv1.User{ID: apiv1.NewOptUint64(1), Name: apiv1.NewOptString("alice")},
|
||||
"node": sampleNode(t),
|
||||
"preauthkey": apiv1.PreAuthKey{ID: apiv1.NewOptUint64(2), Key: apiv1.NewOptString("abc")},
|
||||
"apikey": apiv1.ApiKey{ID: apiv1.NewOptUint64(3), Prefix: apiv1.NewOptString("pref")},
|
||||
}
|
||||
|
||||
formats := []string{outputFormatJSON, outputFormatJSONLine, outputFormatYAML}
|
||||
|
||||
for name, val := range cases {
|
||||
for _, format := range formats {
|
||||
// Single value.
|
||||
out, err := formatOutput(val, "", format)
|
||||
if err != nil {
|
||||
t.Fatalf("formatOutput(%s, %s) value: %v", name, format, err)
|
||||
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)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(out) == "" {
|
||||
t.Errorf("formatOutput(%s, %s) value: empty output", name, format)
|
||||
}
|
||||
|
||||
// Slice of values (the "list" commands).
|
||||
sliceOut, err := formatOutput([]any{val}, "", format)
|
||||
if err != nil {
|
||||
t.Fatalf("formatOutput(%s, %s) slice: %v", name, format, err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(sliceOut) == "" {
|
||||
t.Errorf("formatOutput(%s, %s) slice: empty output", name, format)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatOutputJSONShape pins that a value-typed object serialises by its
|
||||
// JSON field names (not the Go struct shape) and omits unset optionals.
|
||||
func TestFormatOutputJSONShape(t *testing.T) {
|
||||
user := apiv1.User{ID: apiv1.NewOptUint64(1), Name: apiv1.NewOptString("alice")}
|
||||
|
||||
out, err := formatOutput(user, "", outputFormatJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("formatOutput: %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
|
||||
err = json.Unmarshal([]byte(out), &decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
if decoded["name"] != "alice" || decoded["id"] != float64(1) {
|
||||
t.Errorf("unexpected JSON shape: %s", out)
|
||||
}
|
||||
|
||||
if _, present := decoded["displayName"]; present {
|
||||
t.Errorf("unset optional should be omitted, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodesToPtables exercises the default table renderer, whose manual
|
||||
// MachineKey/NodeKey UnmarshalText and IP ParseAddr have no other coverage.
|
||||
func TestNodesToPtables(t *testing.T) {
|
||||
data, err := nodesToPtables([]apiv1.Node{sampleNode(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("nodesToPtables: %v", err)
|
||||
}
|
||||
|
||||
if len(data) != 2 {
|
||||
t.Fatalf("want header + 1 row, got %d rows", len(data))
|
||||
}
|
||||
|
||||
row := strings.Join(data[1], "|")
|
||||
for _, want := range []string{"host7", "alice", "100.64.0.7", "fd7a:115c:a1e0::7"} {
|
||||
if !strings.Contains(row, want) {
|
||||
t.Errorf("row missing %q: %s", want, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodesToPtablesEmptyNodeKey documents that a missing NodeKey makes the
|
||||
// table render fail loudly rather than silently — so a decode regression that
|
||||
// drops NodeKey over HTTP is caught at the CLI, not just in -o json.
|
||||
func TestNodesToPtablesEmptyNodeKey(t *testing.T) {
|
||||
node := sampleNode(t)
|
||||
node.NodeKey = apiv1.OptString{}
|
||||
|
||||
_, err := nodesToPtables([]apiv1.Node{node})
|
||||
if err == nil {
|
||||
t.Error("expected error for empty NodeKey, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeRoutesToPtables renders the list-routes table, which has zero
|
||||
// integration coverage.
|
||||
func TestNodeRoutesToPtables(t *testing.T) {
|
||||
node := sampleNode(t)
|
||||
node.SubnetRoutes = []string{"10.0.0.0/24"}
|
||||
node.ApprovedRoutes = []string{"10.0.0.0/24"}
|
||||
|
||||
data := nodeRoutesToPtables([]apiv1.Node{node})
|
||||
if len(data) != 2 {
|
||||
t.Fatalf("want header + 1 row, got %d rows", len(data))
|
||||
}
|
||||
|
||||
if !strings.Contains(strings.Join(data[1], "|"), "10.0.0.0/24") {
|
||||
t.Errorf("route row missing approved route: %v", data[1])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestUsernameAndIDFromFlag(t *testing.T) {
|
||||
newCmd := func() *cobra.Command {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().StringP("name", "n", "", "")
|
||||
cmd.Flags().Int64P("identifier", "i", -1, "")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
t.Run("neither flag is required", func(t *testing.T) {
|
||||
_, _, err := usernameAndIDFromFlag(newCmd())
|
||||
if !errors.Is(err, errFlagRequired) {
|
||||
t.Fatalf("want errFlagRequired, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("name only", func(t *testing.T) {
|
||||
cmd := newCmd()
|
||||
_ = cmd.Flags().Set("name", "alice")
|
||||
|
||||
id, name, err := usernameAndIDFromFlag(cmd)
|
||||
if err != nil || id != 0 || name != "alice" {
|
||||
t.Fatalf("got id=%d name=%q err=%v", id, name, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("id only", func(t *testing.T) {
|
||||
cmd := newCmd()
|
||||
_ = cmd.Flags().Set("identifier", "7")
|
||||
|
||||
id, name, err := usernameAndIDFromFlag(cmd)
|
||||
if err != nil || id != 7 || name != "" {
|
||||
t.Fatalf("got id=%d name=%q err=%v", id, name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApiKeyIDOrPrefix(t *testing.T) {
|
||||
newCmd := func() *cobra.Command {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().Uint64P("id", "i", 0, "")
|
||||
cmd.Flags().StringP("prefix", "p", "", "")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
t.Run("neither is an error", func(t *testing.T) {
|
||||
_, _, err := apiKeyIDOrPrefix(newCmd())
|
||||
if !errors.Is(err, errMissingParameter) {
|
||||
t.Fatalf("want errMissingParameter, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("both is an error", func(t *testing.T) {
|
||||
cmd := newCmd()
|
||||
_ = cmd.Flags().Set("id", "1")
|
||||
_ = cmd.Flags().Set("prefix", "abc")
|
||||
|
||||
_, _, err := apiKeyIDOrPrefix(cmd)
|
||||
if !errors.Is(err, errMissingParameter) {
|
||||
t.Fatalf("want errMissingParameter, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("id only", func(t *testing.T) {
|
||||
cmd := newCmd()
|
||||
_ = cmd.Flags().Set("id", "5")
|
||||
|
||||
id, prefix, err := apiKeyIDOrPrefix(cmd)
|
||||
if err != nil || id != 5 || prefix != "" {
|
||||
t.Fatalf("got id=%d prefix=%q err=%v", id, prefix, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExpirationFromFlag(t *testing.T) {
|
||||
newCmd := func() *cobra.Command {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().StringP("expiration", "e", "", "")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
t.Run("empty is unset", func(t *testing.T) {
|
||||
exp, err := expirationFromFlag(newCmd())
|
||||
if err != nil || exp.Set {
|
||||
t.Fatalf("want unset, got set=%v err=%v", exp.Set, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid duration is an error", func(t *testing.T) {
|
||||
cmd := newCmd()
|
||||
_ = cmd.Flags().Set("expiration", "not-a-duration")
|
||||
|
||||
_, err := expirationFromFlag(cmd)
|
||||
if err == nil {
|
||||
t.Fatal("want error for invalid duration")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid duration is in the future", func(t *testing.T) {
|
||||
cmd := newCmd()
|
||||
_ = cmd.Flags().Set("expiration", "1h")
|
||||
|
||||
exp, err := expirationFromFlag(cmd)
|
||||
if err != nil || !exp.Set {
|
||||
t.Fatalf("want set, got set=%v err=%v", exp.Set, err)
|
||||
}
|
||||
|
||||
if !exp.Value.After(time.Now()) {
|
||||
t.Errorf("expiration not in the future: %v", exp.Value)
|
||||
}
|
||||
})
|
||||
}
|
||||
+18
-3
@@ -187,14 +187,29 @@ 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") ||
|
||||
strings.Contains(name, "hs-") ||
|
||||
strings.Contains(name, "ts-") ||
|
||||
strings.Contains(name, "derp-") {
|
||||
matchesTestContainerPrefix(name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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{
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/juanfont/headscale/integration/dockertestutil"
|
||||
"github.com/juanfont/headscale/integration/k3sic"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
nameDockerContext = "Docker Context"
|
||||
nameDockerSocket = "Docker Socket"
|
||||
nameGolangImage = "Golang Image"
|
||||
nameK3sImage = "K3s Image"
|
||||
nameGoInstall = "Go Installation"
|
||||
)
|
||||
|
||||
@@ -66,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
|
||||
@@ -242,6 +245,44 @@ func checkGolangImage(ctx context.Context) DoctorResult {
|
||||
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")
|
||||
|
||||
+9
-9
@@ -230,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
|
||||
|
||||
@@ -245,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
|
||||
@@ -279,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: ""
|
||||
|
||||
@@ -460,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.
|
||||
|
||||
+3
-4
@@ -29,12 +29,12 @@ headscale apikeys expire --prefix <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 <API_KEY>` 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,7 +54,7 @@ Headscale server at `/swagger` for details.
|
||||
|
||||
```console
|
||||
curl -H "Authorization: Bearer <API_KEY>" \
|
||||
--json '{"user": "<USER>", "authId": "AUTH_ID>"}' \
|
||||
--json '{"user": "<USER>", "authId": "<AUTH_ID>"}' \
|
||||
https://headscale.example.com/api/v1/auth/register
|
||||
```
|
||||
|
||||
@@ -66,7 +66,6 @@ The `headscale` binary can control a Headscale instance from a remote machine ov
|
||||
|
||||
- A workstation to run `headscale` (any supported platform, e.g. Linux).
|
||||
- The Headscale server reachable over HTTP(S).
|
||||
- Remote access requires an encrypted connection via TLS.
|
||||
- An [API key](#api) to authenticate with the Headscale server.
|
||||
|
||||
### Setup remote control
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-3
@@ -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": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
**It might be outdated and it might miss necessary steps**.
|
||||
|
||||
Headscale can be built from source using the latest version of [Go](https://golang.org). See the [Contributing
|
||||
section in the GitHub README](https://github.com/juanfont/headscale#contributing) for more information.
|
||||
Headscale can be built from source using the latest version of [Go](https://golang.org) and [Buf](https://buf.build)
|
||||
(Protobuf generator). See the [Contributing section in the GitHub
|
||||
README](https://github.com/juanfont/headscale#contributing) for more information.
|
||||
|
||||
## OpenBSD
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
# v1 API: behaviour changes (gRPC/grpc-gateway → ogen OpenAPI 3.0)
|
||||
|
||||
This file records every deliberate deviation of the new ogen-based v1 HTTP API
|
||||
from the previous gRPC + grpc-gateway behaviour. Anything not listed here is
|
||||
preserved: HTTP verbs and path templates, JSON property names (camelCase,
|
||||
matching protojson), enum values (`SCREAMING_SNAKE`, e.g.
|
||||
`REGISTER_METHOD_CLI`), RFC3339 timestamp strings, response envelopes
|
||||
(`{"user": …}`, `{"nodes": […]}`, …), and the set of operations and their side
|
||||
effects on the state layer.
|
||||
|
||||
Each entry: **what changed**, **why**, **client impact**.
|
||||
|
||||
## Wire format
|
||||
|
||||
### Integer IDs are JSON numbers, not strings
|
||||
|
||||
**What:** `id`, `nodeId`/`node_id`, `oldId`, `user`, and every other integer
|
||||
identifier is now a JSON number (`"id": 42`). grpc-gateway emitted protojson's
|
||||
int64-as-string form (`"id": "42"`).
|
||||
|
||||
**Why:** native integers are the idiomatic OpenAPI 3.0 / ogen representation,
|
||||
type-safe end to end, and remove per-field string⇄int conversion. Headscale
|
||||
identifiers are small and well within the JS safe-integer range.
|
||||
|
||||
**Client impact:** HTTP clients that read IDs as strings must read them as
|
||||
numbers. `headscale … -o json/yaml` output changes accordingly (the CLI now
|
||||
runs on the generated client).
|
||||
|
||||
### Error responses are RFC 7807 problem documents
|
||||
|
||||
**What:** errors are returned as `application/problem+json` with a body of
|
||||
`{type?, title, status, detail, instance?}`. grpc-gateway returned an
|
||||
`rpcStatus` envelope `{code, message, details[]}` as `application/json`, where
|
||||
`code` was a gRPC status code and `details` was effectively always empty.
|
||||
|
||||
**Why:** RFC 7807 is the standard HTTP error shape; the gRPC `code`/`details`
|
||||
fields were gRPC implementation leakage with no value over the HTTP status line.
|
||||
|
||||
**Client impact:** clients that parsed `{code, message}` must read
|
||||
`{status, detail}` and the `application/problem+json` content type. The HTTP
|
||||
status code itself is unchanged for equivalent conditions (e.g. unknown user →
|
||||
404, invalid argument → 400, bad/My missing API key → 401).
|
||||
|
||||
## Behaviour
|
||||
|
||||
### Client errors return 4xx consistently
|
||||
|
||||
**What:** client mistakes now map to the appropriate 4xx status instead of 500.
|
||||
Missing resources are `404` (e.g. `RenameUser`, `DeleteUser`, `GetNode`,
|
||||
`DeleteNode`); invalid input is `400` (e.g. an unparseable route in
|
||||
`SetApprovedRoutes`, a malformed registration key in `RegisterNode`, an invalid
|
||||
tag in `SetTags`, an unconfirmed `BackfillNodeIPs`). Many of these gRPC handlers
|
||||
returned a plain Go error, which grpc-gateway rendered as `500`.
|
||||
|
||||
**Why:** a missing resource or bad input is a client error, not a server error;
|
||||
4xx is the correct, consistent status.
|
||||
|
||||
**Client impact:** clients that treated these as 500 should treat them as 400/404.
|
||||
|
||||
### Health on database failure
|
||||
|
||||
**What:** `GET /api/v1/health` returns `200 {"databaseConnectivity": true}` when
|
||||
the database is reachable and `500` (problem document) when the ping fails. The
|
||||
gRPC implementation returned the ping error, which grpc-gateway rendered as a
|
||||
500; the `databaseConnectivity:false` body was never observable on failure.
|
||||
|
||||
**Why:** preserves the observable contract (200 healthy, 500 unhealthy) under
|
||||
the new error shape.
|
||||
|
||||
**Client impact:** none beyond the problem-document error shape above.
|
||||
|
||||
## CLI
|
||||
|
||||
### Remote CLI connects to the HTTP API, not the gRPC port
|
||||
|
||||
**What:** with a configured `cli.address` (or `HEADSCALE_CLI_ADDRESS`), the CLI
|
||||
now speaks HTTP to the headscale API URL rather than gRPC to `grpc_listen_addr`.
|
||||
A bare `host:port` is assumed to be `https://host:port`. Locally (no address)
|
||||
the CLI talks HTTP over the existing unix socket, unchanged in spirit — no API
|
||||
key needed, filesystem permissions are the trust boundary.
|
||||
|
||||
**Why:** the gRPC service and its TCP listener are removed; the CLI runs on the
|
||||
generated HTTP client.
|
||||
|
||||
**Client impact:** point `cli.address` at the headscale HTTP server (the same
|
||||
URL `server_url` is reachable on) instead of the gRPC address. `cli.api_key`
|
||||
and `cli.insecure` are unchanged.
|
||||
|
||||
### `delete`/`expire` commands print a result message
|
||||
|
||||
**What:** commands whose API operation has no response body (user/node/key
|
||||
delete, key expire, auth approve/reject) print a small
|
||||
`{"result": "..."}`-style object (or the human-readable message) instead of the
|
||||
previous empty `{}`.
|
||||
|
||||
**Why:** the operations return no content; a result message is more useful than
|
||||
an empty object.
|
||||
|
||||
**Client impact:** scripts parsing the empty `{}` should read the `result`
|
||||
field (machine-readable output) or rely on the exit code.
|
||||
|
||||
### CLI database-bypass flag renamed
|
||||
|
||||
**What:** `--bypass-grpc-and-access-database-directly` is now
|
||||
`--bypass-server-and-access-database-directly`.
|
||||
|
||||
**Why:** the gRPC server is gone; the flag bypasses the running server whatever
|
||||
its transport, so the name no longer mentions gRPC.
|
||||
|
||||
**Client impact:** scripts using the old flag name must update it.
|
||||
|
||||
### Missing resources return a consistent `404`
|
||||
|
||||
**What:** renaming or expiring an unknown node, and expiring or deleting an
|
||||
unknown pre-auth key, now return `404 Not Found`. Previously the node
|
||||
operations surfaced as `500` and the pre-auth key operations reported success
|
||||
without changing anything.
|
||||
|
||||
**Why:** a missing resource is a client error, not a server error, and an
|
||||
expire or delete that matched no row should not report success.
|
||||
|
||||
**Client impact:** code that treated these as `500` or as a silent success
|
||||
should handle `404`.
|
||||
|
||||
## Delivery note (not a shipped behaviour change)
|
||||
|
||||
The grpc-gateway HTTP facade is replaced wholesale at `/api/v1` by the ogen
|
||||
server in the foundation commit, rather than path-by-path. During development,
|
||||
endpoints whose resource group has not yet been migrated return `501` over HTTP;
|
||||
all are implemented before the branch is complete. The CLI is unaffected during
|
||||
this window because it still uses the gRPC servers until its own migration. No
|
||||
intermediate state is released.
|
||||
@@ -1,147 +0,0 @@
|
||||
# Plan: Headscale v1 API — gRPC/Protobuf → ogen OpenAPI 3.0
|
||||
|
||||
This is the implementation plan for converting the Headscale v1 API from a
|
||||
Protobuf gRPC service with a grpc-gateway REST facade to an OpenAPI 3.0 spec
|
||||
driving ogen-generated server stubs and a Go client, backed unchanged by the
|
||||
`hscontrol/state` layer. It is written so coding agents can execute the
|
||||
remaining work autonomously; tests are the gate.
|
||||
|
||||
See [CHANGES.md](./CHANGES.md) for every deliberate behaviour deviation.
|
||||
|
||||
## Locked decisions
|
||||
|
||||
- **Integer IDs** are native JSON numbers (`type: integer, format: uint64`), not protojson strings.
|
||||
- **Errors** are RFC 7807 problem documents (`application/problem+json`).
|
||||
- **Local CLI** talks HTTP over the existing unix socket (no API key; socket permissions are the trust boundary). Remote stays HTTPS + bearer key.
|
||||
- Preserved by default: HTTP verbs/paths, camelCase JSON keys, `SCREAMING_SNAKE` enums, RFC3339 timestamps, response envelopes, operation set and state side effects.
|
||||
|
||||
## Package layout (v1 self-contained; v2 drops in as a sibling)
|
||||
|
||||
| Concern | Path | Package |
|
||||
| --------------------------- | --------------------------- | -------------------------------- |
|
||||
| Spec (source of truth) | `openapi/v1/headscale.yaml` | — |
|
||||
| Generated server+client | `gen/api/v1/` | `apiv1` (imported as `oas`) |
|
||||
| Handlers / state adapter | `hscontrol/api/v1/` | `apiv1` |
|
||||
| HTTP-parity tests + harness | `hscontrol/servertest/` | `servertest` / `servertest_test` |
|
||||
|
||||
v1 and v2 must not import each other or share generated types — they converge
|
||||
only on `hscontrol/state`. Codegen: `go generate ./gen/api/v1/` (also run by
|
||||
`make generate` via `go generate ./...`); CI freshness via
|
||||
`.github/workflows/check-generated.yml`. Never hand-edit generated code; fix the
|
||||
spec, never reach for `x-ogen` without recording in CHANGES.md why no spec
|
||||
change works.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Routing.** chi is the top mux. The ogen server (`http.Handler`) is mounted at `/api/v1/*` in `app.go` `createRouter`; chi leaves `r.URL.Path` intact so ogen's router matches the full path. ogen owns `/api/v1` wholesale: un-migrated operations inherit `oas.UnimplementedHandler` (501) until their unit lands. Parallel endpoint agents therefore touch only their handler file + tests — never `app.go`.
|
||||
- **Auth.** Spec declares `bearerAuth` (http/bearer) global security. `Server.HandleBearerAuth` → `state.ValidateAPIKey`; any failure → 401. (Unix-socket auth bypass is added in the CLI phase, when the socket serves HTTP.)
|
||||
- **Errors.** `Server.NewError` (ogen's convenient-error hook for security/decoding) and `errorHandler` (`WithErrorHandler`, for plain handler errors) both route through `classify` in `hscontrol/api/v1/errors.go`: typed `*oas.ErrorStatusCode` pass through; ogen framework errors use their `Code()` (security→401, decode→400); state errors via `mapStateError` (not-found sentinels→404, `ErrPolicyUpdateIsDisabled`→400, else 500). Handlers build expected errors with `apiError`/`notFound`/`badRequest`/`mapStateError`.
|
||||
- **Side effects.** Handlers replicate the exact `state.*` call sequence and `change(...)` notifications from `hscontrol/grpcv1.go` (policy reload on SetPolicy, route auto-approval on RegisterNode, change broadcasts on node mutations, etc.). The adapter holds `state *state.State`, `cfg *types.Config`, and `change func(...change.Change)` (wired to `Headscale.Change`); it does not import the parent `hscontrol` package (no import cycle).
|
||||
- **Transitional gRPC.** The gRPC unix-socket and TCP servers keep running through the endpoint phase so the existing CLI works; they (and proto/gateway code) are removed in cutover.
|
||||
|
||||
## Operation inventory (backbone) → state mapping
|
||||
|
||||
All paths keep the current `google.api.http` templates. Source of truth for
|
||||
behaviour: `hscontrol/grpcv1.go`.
|
||||
|
||||
**User** — `hscontrol/api/v1/users.go`
|
||||
|
||||
- `POST /api/v1/user` CreateUser → `state.CreateUser`; `change(policyChanged)`.
|
||||
- `GET /api/v1/user` ListUsers(`id`,`name`,`email` query) → `ListUsersWithFilter` (by name|email|id) else `ListAllUsers`; sort by id.
|
||||
- `POST /api/v1/user/{old_id}/rename/{new_name}` RenameUser → `GetUserByID`,`RenameUser`,`change(c)`,`GetUserByName`.
|
||||
- `DELETE /api/v1/user/{id}` DeleteUser → `GetUserByID`,`DeleteUser`,`change`.
|
||||
|
||||
**PreAuthKey** — `preauthkeys.go`
|
||||
|
||||
- `POST /api/v1/preauthkey` CreatePreAuthKey → validate each `aclTags` (`validateTag`), optional `GetUserByID`, `CreatePreAuthKey(userID, reusable, ephemeral, &expiration, aclTags)`.
|
||||
- `GET /api/v1/preauthkey` ListPreAuthKeys → `ListPreAuthKeys`; sort by id.
|
||||
- `POST /api/v1/preauthkey/expire` ExpirePreAuthKey → `ExpirePreAuthKey(id)`.
|
||||
- `DELETE /api/v1/preauthkey` DeletePreAuthKey(`id` query) → `DeletePreAuthKey(id)`.
|
||||
|
||||
**Node** — `nodes.go`
|
||||
|
||||
- `GET /api/v1/node` ListNodes(`user` query) → `GetUserByName`+`ListNodesByUser` | `ListNodes`; `nodesToProto` (tagged→`TaggedDevices` user; `subnetRoutes` = primary+exit routes); sort by id.
|
||||
- `POST /api/v1/node/register` RegisterNode(`user`,`key` query) → `AuthIDFromString`,`GetUserByName`,`HandleNodeFromAuthPath(...,RegisterMethodCLI)`,`AutoApproveRoutes`,`change(nodeChange,routeChange)`.
|
||||
- `POST /api/v1/node/backfillips` BackfillNodeIPs(`confirmed` query) → require confirmed else error; `BackfillNodeIPs`.
|
||||
- `GET /api/v1/node/{node_id}` GetNode → `GetNodeByID`; not found → 404.
|
||||
- `DELETE /api/v1/node/{node_id}` DeleteNode → `GetNodeByID`(404),`DeleteNode`,`change`.
|
||||
- `POST /api/v1/node/{node_id}/tags` SetTags → non-empty tags else 400; `validateTag` each; `GetNodeByID`(404); `SetNodeTags`(invalid→400); `change`.
|
||||
- `POST /api/v1/node/{node_id}/approve_routes` SetApprovedRoutes → parse prefixes; exit-route expansion (AllIPv4/AllIPv6 → add both); sort+compact; `SetApprovedRoutes`(invalid→400); `change`; set `subnetRoutes` = `GetNodePrimaryRoutes`.
|
||||
- `POST /api/v1/node/{node_id}/expire` ExpireNode(`expiry`,`disable_expiry` query) → both set → 400; disable → `SetNodeExpiry(nil)`; else `SetNodeExpiry(&expiry)` (default now); `change`.
|
||||
- `POST /api/v1/node/{node_id}/rename/{new_name}` RenameNode → `RenameNode`,`change`.
|
||||
- `POST /api/v1/debug/node` DebugCreateNode → `GetUserByName`,`StringToIPPrefix`,`AuthIDFromString`,`SetAuthCacheEntry`; echo synthetic node.
|
||||
|
||||
**Auth** — `auth.go`
|
||||
|
||||
- `POST /api/v1/auth/register` AuthRegister → delegates to RegisterNode(`key`=authId,`user`).
|
||||
- `POST /api/v1/auth/approve` AuthApprove → `AuthIDFromString`(400),`GetAuthCacheEntry`(404),`FinishAuth({})`.
|
||||
- `POST /api/v1/auth/reject` AuthReject → `AuthIDFromString`(400),`GetAuthCacheEntry`(404),`FinishAuth({Err})`.
|
||||
|
||||
**ApiKey** — `apikeys.go`
|
||||
|
||||
- `POST /api/v1/apikey` CreateApiKey → `CreateAPIKey(&expiration)`; returns raw `apiKey` string.
|
||||
- `GET /api/v1/apikey` ListApiKeys → `ListAPIKeys`; sort by id.
|
||||
- `POST /api/v1/apikey/expire` ExpireApiKey → `getAPIKey` (id xor prefix; neither/both→400),`ExpireAPIKey`.
|
||||
- `DELETE /api/v1/apikey/{prefix}` DeleteApiKey(`id` query) → `getAPIKey`,`DestroyAPIKey`.
|
||||
|
||||
**Policy** — `policy.go`
|
||||
|
||||
- `GET /api/v1/policy` GetPolicy → DB mode: `GetPolicy`; file mode: read `cfg.Policy.Path`.
|
||||
- `PUT /api/v1/policy` SetPolicy → DB mode only (`ErrPolicyUpdateIsDisabled`→400); `ListNodes`,`SetPolicy`,`SSHPolicy(node0)`,`SetPolicyInDB`,`ReloadPolicy`,`change(cs...)`.
|
||||
- `POST /api/v1/policy/check` CheckPolicy → `ListAllUsers`,`ListNodes`,`NewPolicyManager`+`SetPolicy` (no persist; invalid→400).
|
||||
|
||||
**Health** — `health.go` (done) — `PingDB`; ok→200, fail→500.
|
||||
|
||||
## Handler authoring recipe (for endpoint agents)
|
||||
|
||||
For each resource group, in this order, then `/commit` (Go style: `api: <imperative>`):
|
||||
|
||||
1. **Tests first.** Add `hscontrol/servertest/apiv1_<resource>_test.go` (package `servertest_test`). Use `srv := servertest.NewServer(t)`, `client := srv.APIClient(t, srv.CreateAPIKey(t))`, seed state via `srv.State()` / existing `servertest` helpers. Assert: success payloads (status, envelope, field values), every error path (404/400/401 with `*apiv1.ErrorStatusCode` status), and state side effects (re-read via `srv.State()`). Cover real behaviour, not just happy paths. Where the new contract intentionally differs from the old, the assertion encodes the new value **and** there is a matching CHANGES.md entry.
|
||||
1. **Implement** the group's handler methods on `*apiv1.Server` in `hscontrol/api/v1/<resource>.go`, mirroring the `grpcv1.go` logic and side effects exactly. Convert between ogen types (`*XxxReq`, `XxxParams`, `*XxxOK`, `oas.User`/`oas.Node`/…) and state types. Build `oas.OptXxx` for optional fields; map state results to the response envelope. Errors: `notFound`/`badRequest`/`mapStateError`/`apiError`.
|
||||
1. **CLI tests** for the group's commands belong to the CLI phase (`integration/`); reference them in the unit's acceptance.
|
||||
1. **CHANGES.md**: add an entry for any deviation; if a test had to encode a non-preserved behaviour, it must correspond to an entry.
|
||||
1. Run `goimports -w`, `go test ./hscontrol/servertest/ -run TestAPIv1_<Resource>`, then `make fmt`/`make lint` on touched files. Commit.
|
||||
|
||||
A reusable proto→ogen conversion (`oas.User` from `types.User`, etc.) should live in `hscontrol/api/v1/convert.go`; the first agent to need a converter adds it there and others reuse it. Do not duplicate.
|
||||
|
||||
## Work units & dependency map
|
||||
|
||||
```
|
||||
F1 spec → F2 codegen+CI → F3 server/adapter/auth/errors → F4 harness [DONE]
|
||||
│
|
||||
┌────────────────────────────────────────────────────┐
|
||||
E1 User E2 PreAuthKey E3 Node E4 Auth E5 ApiKey E6 Policy E7 Health(done)
|
||||
└────────────────────────────────────────────────────┘ (parallel; touch only own handler+tests)
|
||||
F2 → C0 CLI client adapter → C1..C8 per-command (each needs its E-unit) (parallel)
|
||||
all E* + all C* → X1 flip/auth → X2 remove proto/gRPC/gateway → X3 docs+served spec → X4 reconcile
|
||||
```
|
||||
|
||||
- **E1–E7**: each owns its handler file + `apiv1_<resource>_test.go`. Acceptance: that group's HTTP-parity tests green; behaviour matches `grpcv1.go` modulo CHANGES.md.
|
||||
- **C0**: rework `cmd/headscale/cli/utils.go` dial point to build `apiv1.Client` (local HTTP-over-unix, no key; remote HTTPS+`HEADSCALE_CLI_API_KEY`+`cli.insecure`). Serve ogen over the unix socket; add socket auth-bypass. **C1–C8**: convert each `cmd/headscale/cli/*.go` off the gRPC client; gate with `integration/` CLI tests. Acceptance: no `v1.HeadscaleServiceClient` usage remains.
|
||||
- **X1**: ogen is already the sole `/api/v1` handler; ensure no chi auth duplication, remove any dead gateway wiring. **X2**: delete `proto/headscale/v1`, `gen/go`, `gen/openapiv2`, `hscontrol/grpcv1.go`(+test), gRPC servers, `buf.*`; drop grpc/grpc-gateway deps (verify Noise is independent). **X3**: serve `openapi/v1/headscale.yaml` at `/swagger`; update `swagger.go` embed + `docs/`. **X4**: reconcile PLAN.md/CHANGES.md to the built state; DoD checklist green.
|
||||
|
||||
## Status
|
||||
|
||||
- [x] **F1** spec — `openapi/v1/headscale.yaml` (all 27 ops, native int64, RFC7807 `Problem`, bearerAuth). ogen generates cleanly.
|
||||
- [x] **F2** codegen — `gen/api/v1/generate.go` (`go generate`), ogen tool dep in `go.mod`, CI freshness covers `openapi/**`.
|
||||
- [x] **F3** server/adapter/auth/errors — `hscontrol/api/v1/{server,errors,auth,health}.go`; mounted at `/api/v1`; gateway unmounted; gRPC servers retained.
|
||||
- [x] **F4** harness — `servertest.APIClient`/`CreateAPIKey`; Health parity + 401 tests green.
|
||||
- [x] **E1–E7** endpoints — all 27 operations implemented in `hscontrol/api/v1/{users,apikeys,preauthkeys,nodes,auth,policy,health}.go` with HTTP-parity tests in `hscontrol/servertest/apiv1_*_test.go` (30 tests, all green).
|
||||
- [x] **C0–C8** CLI migration — server serves the API over the unix socket (auth bypassed via `apiv1.WithSocketAuth`); every `cmd/headscale/cli/*.go` command runs on the generated `apiv1.Client`; no gRPC client usage remains.
|
||||
- [x] **X1/X2** proto/gRPC removal — `grpcv1.go`, the TCP gRPC server + interceptor, the `.Proto()`/`RegisterMethodToV1Enum` builders, `proto/`, `gen/go/`, `gen/openapiv2/`, and `buf.gen.yaml` are deleted; `convert.go` reads the state views directly; `integration/` decodes into `apiv1` types. `grep` finds zero proto references in any `.go` file. grpc/grpc-gateway are no longer direct go.mod deps. Proto lint/format targets removed from the Makefile.
|
||||
- [x] **X3** served spec — `/swagger` serves the embedded `openapi/v1/headscale.yaml` (3.0).
|
||||
- [x] **X4** reconcile — this document and CHANGES.md reflect the built state.
|
||||
|
||||
The conversion is complete. The full `go test ./...` suite is validatable here for
|
||||
everything except the Docker-based `integration/` package, which is
|
||||
compile-clean (`go vet ./integration/...`) and ported to the new wire format; its
|
||||
end-to-end run is gated on Docker (`go run ./cmd/hi`).
|
||||
|
||||
## Definition of done
|
||||
|
||||
Every v1 op served by ogen over the state layer; HTTP + CLI tests cover every
|
||||
endpoint and pass; CLI on the generated client (no gRPC client); proto/gRPC +
|
||||
gateway + generated code removed; generated code matches committed spec
|
||||
(CI green); docs + `/swagger` point at the 3.0 spec; v1 self-contained, no
|
||||
cross-version coupling; current behaviour preserved except deliberate,
|
||||
test-matched, CHANGES.md-recorded deviations.
|
||||
Generated
+78
-1
@@ -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",
|
||||
|
||||
@@ -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.<system>.<name>` 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
|
||||
@@ -152,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
|
||||
@@ -176,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.<system>.<name>`; 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`
|
||||
@@ -221,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;
|
||||
});
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"vendor": {
|
||||
"goModSum": "sha256-pE9YS5dCh93GWjRWlbDTmPLhvXbHIoHEKU/6qiN2ex4=",
|
||||
"sri": "sha256-KRgUvdDEVfkkwcEU6mxGWyCGXn/IMw9ZBVQmVGQZUcY="
|
||||
"goModSum": "sha256-SJml8RXGmb2p0g1nOsHn86FA1hwgd5ZffLSkUj5zek8=",
|
||||
"sri": "sha256-pjGNuVtgFFzWNq/2cK7a4iyF13AfcHz098nk92a9Ido="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// Package apiv1 contains the ogen-generated server and client for the
|
||||
// Headscale v1 HTTP API. The code is generated from the committed OpenAPI 3.0
|
||||
// spec at openapi/v1/headscale.yaml — the single source of truth. Do not edit
|
||||
// generated files by hand; run `make generate` (or `go generate ./...`) after
|
||||
// changing the spec.
|
||||
//
|
||||
//go:generate go run github.com/ogen-go/ogen/cmd/ogen --target . --package apiv1 --clean ../../../openapi/v1/headscale.yaml
|
||||
package apiv1
|
||||
@@ -1,318 +0,0 @@
|
||||
// Code generated by ogen, DO NOT EDIT.
|
||||
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
ht "github.com/ogen-go/ogen/http"
|
||||
"github.com/ogen-go/ogen/middleware"
|
||||
"github.com/ogen-go/ogen/ogenerrors"
|
||||
"github.com/ogen-go/ogen/otelogen"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
// Allocate option closure once.
|
||||
clientSpanKind = trace.WithSpanKind(trace.SpanKindClient)
|
||||
// Allocate option closure once.
|
||||
serverSpanKind = trace.WithSpanKind(trace.SpanKindServer)
|
||||
)
|
||||
|
||||
type (
|
||||
optionFunc[C any] func(*C)
|
||||
otelOptionFunc func(*otelConfig)
|
||||
)
|
||||
|
||||
type otelConfig struct {
|
||||
TracerProvider trace.TracerProvider
|
||||
Tracer trace.Tracer
|
||||
MeterProvider metric.MeterProvider
|
||||
Meter metric.Meter
|
||||
Attributes []attribute.KeyValue
|
||||
}
|
||||
|
||||
func (cfg *otelConfig) initOTEL() {
|
||||
if cfg.TracerProvider == nil {
|
||||
cfg.TracerProvider = otel.GetTracerProvider()
|
||||
}
|
||||
if cfg.MeterProvider == nil {
|
||||
cfg.MeterProvider = otel.GetMeterProvider()
|
||||
}
|
||||
cfg.Tracer = cfg.TracerProvider.Tracer(otelogen.Name,
|
||||
trace.WithInstrumentationVersion(otelogen.SemVersion()),
|
||||
)
|
||||
cfg.Meter = cfg.MeterProvider.Meter(otelogen.Name,
|
||||
metric.WithInstrumentationVersion(otelogen.SemVersion()),
|
||||
)
|
||||
}
|
||||
|
||||
// ErrorHandler is error handler.
|
||||
type ErrorHandler = ogenerrors.ErrorHandler
|
||||
|
||||
type serverConfig struct {
|
||||
otelConfig
|
||||
NotFound http.HandlerFunc
|
||||
MethodNotAllowed func(w http.ResponseWriter, r *http.Request, allowed string)
|
||||
ErrorHandler ErrorHandler
|
||||
Prefix string
|
||||
Middleware Middleware
|
||||
MaxMultipartMemory int64
|
||||
}
|
||||
|
||||
// ServerOption is server config option.
|
||||
type ServerOption interface {
|
||||
applyServer(*serverConfig)
|
||||
}
|
||||
|
||||
var _ ServerOption = (optionFunc[serverConfig])(nil)
|
||||
|
||||
func (o optionFunc[C]) applyServer(c *C) {
|
||||
o(c)
|
||||
}
|
||||
|
||||
var _ ServerOption = (otelOptionFunc)(nil)
|
||||
|
||||
func (o otelOptionFunc) applyServer(c *serverConfig) {
|
||||
o(&c.otelConfig)
|
||||
}
|
||||
|
||||
func newServerConfig(opts ...ServerOption) serverConfig {
|
||||
cfg := serverConfig{
|
||||
NotFound: http.NotFound,
|
||||
MethodNotAllowed: nil,
|
||||
ErrorHandler: ogenerrors.DefaultErrorHandler,
|
||||
Middleware: nil,
|
||||
MaxMultipartMemory: 32 << 20, // 32 MB
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt.applyServer(&cfg)
|
||||
}
|
||||
cfg.initOTEL()
|
||||
return cfg
|
||||
}
|
||||
|
||||
type baseServer struct {
|
||||
cfg serverConfig
|
||||
requests metric.Int64Counter
|
||||
errors metric.Int64Counter
|
||||
duration metric.Float64Histogram
|
||||
}
|
||||
|
||||
func (s baseServer) notFound(w http.ResponseWriter, r *http.Request) {
|
||||
s.cfg.NotFound(w, r)
|
||||
}
|
||||
|
||||
type notAllowedParams struct {
|
||||
allowedMethods string
|
||||
allowedHeaders map[string]string
|
||||
acceptPost string
|
||||
acceptPatch string
|
||||
}
|
||||
|
||||
func (s baseServer) notAllowed(w http.ResponseWriter, r *http.Request, params notAllowedParams) {
|
||||
h := w.Header()
|
||||
isOptions := r.Method == "OPTIONS"
|
||||
if isOptions {
|
||||
h.Set("Access-Control-Allow-Methods", params.allowedMethods)
|
||||
if params.allowedHeaders != nil {
|
||||
m := r.Header.Get("Access-Control-Request-Method")
|
||||
if m != "" {
|
||||
allowedHeaders, ok := params.allowedHeaders[strings.ToUpper(m)]
|
||||
if ok {
|
||||
h.Set("Access-Control-Allow-Headers", allowedHeaders)
|
||||
}
|
||||
}
|
||||
}
|
||||
if params.acceptPost != "" {
|
||||
h.Set("Accept-Post", params.acceptPost)
|
||||
}
|
||||
if params.acceptPatch != "" {
|
||||
h.Set("Accept-Patch", params.acceptPatch)
|
||||
}
|
||||
}
|
||||
if s.cfg.MethodNotAllowed != nil {
|
||||
s.cfg.MethodNotAllowed(w, r, params.allowedMethods)
|
||||
return
|
||||
}
|
||||
status := http.StatusNoContent
|
||||
if !isOptions {
|
||||
h.Set("Allow", params.allowedMethods)
|
||||
status = http.StatusMethodNotAllowed
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (cfg serverConfig) baseServer() (s baseServer, err error) {
|
||||
s = baseServer{cfg: cfg}
|
||||
if s.requests, err = otelogen.ServerRequestCountCounter(s.cfg.Meter); err != nil {
|
||||
return s, err
|
||||
}
|
||||
if s.errors, err = otelogen.ServerErrorsCountCounter(s.cfg.Meter); err != nil {
|
||||
return s, err
|
||||
}
|
||||
if s.duration, err = otelogen.ServerDurationHistogram(s.cfg.Meter); err != nil {
|
||||
return s, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type clientConfig struct {
|
||||
otelConfig
|
||||
Client ht.Client
|
||||
}
|
||||
|
||||
// ClientOption is client config option.
|
||||
type ClientOption interface {
|
||||
applyClient(*clientConfig)
|
||||
}
|
||||
|
||||
var _ ClientOption = (optionFunc[clientConfig])(nil)
|
||||
|
||||
func (o optionFunc[C]) applyClient(c *C) {
|
||||
o(c)
|
||||
}
|
||||
|
||||
var _ ClientOption = (otelOptionFunc)(nil)
|
||||
|
||||
func (o otelOptionFunc) applyClient(c *clientConfig) {
|
||||
o(&c.otelConfig)
|
||||
}
|
||||
|
||||
func newClientConfig(opts ...ClientOption) clientConfig {
|
||||
cfg := clientConfig{
|
||||
Client: http.DefaultClient,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt.applyClient(&cfg)
|
||||
}
|
||||
cfg.initOTEL()
|
||||
return cfg
|
||||
}
|
||||
|
||||
type baseClient struct {
|
||||
cfg clientConfig
|
||||
requests metric.Int64Counter
|
||||
errors metric.Int64Counter
|
||||
duration metric.Float64Histogram
|
||||
}
|
||||
|
||||
func (cfg clientConfig) baseClient() (c baseClient, err error) {
|
||||
c = baseClient{cfg: cfg}
|
||||
if c.requests, err = otelogen.ClientRequestCountCounter(c.cfg.Meter); err != nil {
|
||||
return c, err
|
||||
}
|
||||
if c.errors, err = otelogen.ClientErrorsCountCounter(c.cfg.Meter); err != nil {
|
||||
return c, err
|
||||
}
|
||||
if c.duration, err = otelogen.ClientDurationHistogram(c.cfg.Meter); err != nil {
|
||||
return c, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Option is config option.
|
||||
type Option interface {
|
||||
ServerOption
|
||||
ClientOption
|
||||
}
|
||||
|
||||
// WithTracerProvider specifies a tracer provider to use for creating a tracer.
|
||||
//
|
||||
// If none is specified, the global provider is used.
|
||||
func WithTracerProvider(provider trace.TracerProvider) Option {
|
||||
return otelOptionFunc(func(cfg *otelConfig) {
|
||||
if provider != nil {
|
||||
cfg.TracerProvider = provider
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// WithMeterProvider specifies a meter provider to use for creating a meter.
|
||||
//
|
||||
// If none is specified, the otel.GetMeterProvider() is used.
|
||||
func WithMeterProvider(provider metric.MeterProvider) Option {
|
||||
return otelOptionFunc(func(cfg *otelConfig) {
|
||||
if provider != nil {
|
||||
cfg.MeterProvider = provider
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// WithAttributes specifies default otel attributes.
|
||||
func WithAttributes(attributes ...attribute.KeyValue) Option {
|
||||
return otelOptionFunc(func(cfg *otelConfig) {
|
||||
cfg.Attributes = attributes
|
||||
})
|
||||
}
|
||||
|
||||
// WithClient specifies http client to use.
|
||||
func WithClient(client ht.Client) ClientOption {
|
||||
return optionFunc[clientConfig](func(cfg *clientConfig) {
|
||||
if client != nil {
|
||||
cfg.Client = client
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// WithNotFound specifies Not Found handler to use.
|
||||
func WithNotFound(notFound http.HandlerFunc) ServerOption {
|
||||
return optionFunc[serverConfig](func(cfg *serverConfig) {
|
||||
if notFound != nil {
|
||||
cfg.NotFound = notFound
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// WithMethodNotAllowed specifies Method Not Allowed handler to use.
|
||||
func WithMethodNotAllowed(methodNotAllowed func(w http.ResponseWriter, r *http.Request, allowed string)) ServerOption {
|
||||
return optionFunc[serverConfig](func(cfg *serverConfig) {
|
||||
if methodNotAllowed != nil {
|
||||
cfg.MethodNotAllowed = methodNotAllowed
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// WithErrorHandler specifies error handler to use.
|
||||
func WithErrorHandler(h ErrorHandler) ServerOption {
|
||||
return optionFunc[serverConfig](func(cfg *serverConfig) {
|
||||
if h != nil {
|
||||
cfg.ErrorHandler = h
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// WithPathPrefix specifies server path prefix.
|
||||
func WithPathPrefix(prefix string) ServerOption {
|
||||
return optionFunc[serverConfig](func(cfg *serverConfig) {
|
||||
cfg.Prefix = prefix
|
||||
})
|
||||
}
|
||||
|
||||
// WithMiddleware specifies middlewares to use.
|
||||
func WithMiddleware(m ...Middleware) ServerOption {
|
||||
return optionFunc[serverConfig](func(cfg *serverConfig) {
|
||||
switch len(m) {
|
||||
case 0:
|
||||
cfg.Middleware = nil
|
||||
case 1:
|
||||
cfg.Middleware = m[0]
|
||||
default:
|
||||
cfg.Middleware = middleware.ChainMiddlewares(m...)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// WithMaxMultipartMemory specifies limit of memory for storing file parts.
|
||||
// File parts which can't be stored in memory will be stored on disk in temporary files.
|
||||
func WithMaxMultipartMemory(max int64) ServerOption {
|
||||
return optionFunc[serverConfig](func(cfg *serverConfig) {
|
||||
if max > 0 {
|
||||
cfg.MaxMultipartMemory = max
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
// Code generated by ogen, DO NOT EDIT.
|
||||
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
)
|
||||
|
||||
// Labeler is used to allow adding custom attributes to the server request metrics.
|
||||
type Labeler struct {
|
||||
attrs []attribute.KeyValue
|
||||
}
|
||||
|
||||
// Add attributes to the Labeler.
|
||||
func (l *Labeler) Add(attrs ...attribute.KeyValue) {
|
||||
l.attrs = append(l.attrs, attrs...)
|
||||
}
|
||||
|
||||
// AttributeSet returns the attributes added to the Labeler as an attribute.Set.
|
||||
func (l *Labeler) AttributeSet() attribute.Set {
|
||||
return attribute.NewSet(l.attrs...)
|
||||
}
|
||||
|
||||
type labelerContextKey struct{}
|
||||
|
||||
// LabelerFromContext retrieves the Labeler from the provided context, if present.
|
||||
//
|
||||
// If no Labeler was found in the provided context a new, empty Labeler is returned and the second
|
||||
// return value is false. In this case it is safe to use the Labeler but any attributes added to
|
||||
// it will not be used.
|
||||
func LabelerFromContext(ctx context.Context) (*Labeler, bool) {
|
||||
if l, ok := ctx.Value(labelerContextKey{}).(*Labeler); ok {
|
||||
return l, true
|
||||
}
|
||||
return &Labeler{}, false
|
||||
}
|
||||
|
||||
func contextWithLabeler(ctx context.Context, l *Labeler) context.Context {
|
||||
return context.WithValue(ctx, labelerContextKey{}, l)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// Code generated by ogen, DO NOT EDIT.
|
||||
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"github.com/ogen-go/ogen/middleware"
|
||||
)
|
||||
|
||||
// Middleware is middleware type.
|
||||
type Middleware = middleware.Middleware
|
||||
@@ -1,38 +0,0 @@
|
||||
// Code generated by ogen, DO NOT EDIT.
|
||||
|
||||
package apiv1
|
||||
|
||||
// OperationName is the ogen operation name
|
||||
type OperationName = string
|
||||
|
||||
const (
|
||||
AuthApproveOperation OperationName = "AuthApprove"
|
||||
AuthRegisterOperation OperationName = "AuthRegister"
|
||||
AuthRejectOperation OperationName = "AuthReject"
|
||||
BackfillNodeIPsOperation OperationName = "BackfillNodeIPs"
|
||||
CheckPolicyOperation OperationName = "CheckPolicy"
|
||||
CreateApiKeyOperation OperationName = "CreateApiKey"
|
||||
CreatePreAuthKeyOperation OperationName = "CreatePreAuthKey"
|
||||
CreateUserOperation OperationName = "CreateUser"
|
||||
DebugCreateNodeOperation OperationName = "DebugCreateNode"
|
||||
DeleteApiKeyOperation OperationName = "DeleteApiKey"
|
||||
DeleteNodeOperation OperationName = "DeleteNode"
|
||||
DeletePreAuthKeyOperation OperationName = "DeletePreAuthKey"
|
||||
DeleteUserOperation OperationName = "DeleteUser"
|
||||
ExpireApiKeyOperation OperationName = "ExpireApiKey"
|
||||
ExpireNodeOperation OperationName = "ExpireNode"
|
||||
ExpirePreAuthKeyOperation OperationName = "ExpirePreAuthKey"
|
||||
GetNodeOperation OperationName = "GetNode"
|
||||
GetPolicyOperation OperationName = "GetPolicy"
|
||||
HealthOperation OperationName = "Health"
|
||||
ListApiKeysOperation OperationName = "ListApiKeys"
|
||||
ListNodesOperation OperationName = "ListNodes"
|
||||
ListPreAuthKeysOperation OperationName = "ListPreAuthKeys"
|
||||
ListUsersOperation OperationName = "ListUsers"
|
||||
RegisterNodeOperation OperationName = "RegisterNode"
|
||||
RenameNodeOperation OperationName = "RenameNode"
|
||||
RenameUserOperation OperationName = "RenameUser"
|
||||
SetApprovedRoutesOperation OperationName = "SetApprovedRoutes"
|
||||
SetPolicyOperation OperationName = "SetPolicy"
|
||||
SetTagsOperation OperationName = "SetTags"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,938 +0,0 @@
|
||||
// Code generated by ogen, DO NOT EDIT.
|
||||
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-faster/errors"
|
||||
"github.com/go-faster/jx"
|
||||
"github.com/ogen-go/ogen/ogenerrors"
|
||||
"github.com/ogen-go/ogen/validate"
|
||||
)
|
||||
|
||||
func (s *Server) decodeAuthApproveRequest(r *http.Request) (
|
||||
req *AuthApproveReq,
|
||||
rawBody []byte,
|
||||
close func() error,
|
||||
rerr error,
|
||||
) {
|
||||
var closers []func() error
|
||||
close = func() error {
|
||||
var merr error
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return req, rawBody, close, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
if r.ContentLength == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
buf, err := io.ReadAll(r.Body)
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
|
||||
// Reset the body to allow for downstream reading.
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(buf))
|
||||
|
||||
if len(buf) == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
|
||||
rawBody = append(rawBody, buf...)
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var request AuthApproveReq
|
||||
if err := func() error {
|
||||
if err := request.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
return &request, rawBody, close, nil
|
||||
default:
|
||||
return req, rawBody, close, validate.InvalidContentType(ct)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) decodeAuthRegisterRequest(r *http.Request) (
|
||||
req *AuthRegisterReq,
|
||||
rawBody []byte,
|
||||
close func() error,
|
||||
rerr error,
|
||||
) {
|
||||
var closers []func() error
|
||||
close = func() error {
|
||||
var merr error
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return req, rawBody, close, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
if r.ContentLength == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
buf, err := io.ReadAll(r.Body)
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
|
||||
// Reset the body to allow for downstream reading.
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(buf))
|
||||
|
||||
if len(buf) == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
|
||||
rawBody = append(rawBody, buf...)
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var request AuthRegisterReq
|
||||
if err := func() error {
|
||||
if err := request.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
return &request, rawBody, close, nil
|
||||
default:
|
||||
return req, rawBody, close, validate.InvalidContentType(ct)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) decodeAuthRejectRequest(r *http.Request) (
|
||||
req *AuthRejectReq,
|
||||
rawBody []byte,
|
||||
close func() error,
|
||||
rerr error,
|
||||
) {
|
||||
var closers []func() error
|
||||
close = func() error {
|
||||
var merr error
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return req, rawBody, close, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
if r.ContentLength == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
buf, err := io.ReadAll(r.Body)
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
|
||||
// Reset the body to allow for downstream reading.
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(buf))
|
||||
|
||||
if len(buf) == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
|
||||
rawBody = append(rawBody, buf...)
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var request AuthRejectReq
|
||||
if err := func() error {
|
||||
if err := request.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
return &request, rawBody, close, nil
|
||||
default:
|
||||
return req, rawBody, close, validate.InvalidContentType(ct)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) decodeCheckPolicyRequest(r *http.Request) (
|
||||
req *CheckPolicyReq,
|
||||
rawBody []byte,
|
||||
close func() error,
|
||||
rerr error,
|
||||
) {
|
||||
var closers []func() error
|
||||
close = func() error {
|
||||
var merr error
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return req, rawBody, close, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
if r.ContentLength == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
buf, err := io.ReadAll(r.Body)
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
|
||||
// Reset the body to allow for downstream reading.
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(buf))
|
||||
|
||||
if len(buf) == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
|
||||
rawBody = append(rawBody, buf...)
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var request CheckPolicyReq
|
||||
if err := func() error {
|
||||
if err := request.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
return &request, rawBody, close, nil
|
||||
default:
|
||||
return req, rawBody, close, validate.InvalidContentType(ct)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) decodeCreateApiKeyRequest(r *http.Request) (
|
||||
req *CreateApiKeyReq,
|
||||
rawBody []byte,
|
||||
close func() error,
|
||||
rerr error,
|
||||
) {
|
||||
var closers []func() error
|
||||
close = func() error {
|
||||
var merr error
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return req, rawBody, close, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
if r.ContentLength == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
buf, err := io.ReadAll(r.Body)
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
|
||||
// Reset the body to allow for downstream reading.
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(buf))
|
||||
|
||||
if len(buf) == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
|
||||
rawBody = append(rawBody, buf...)
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var request CreateApiKeyReq
|
||||
if err := func() error {
|
||||
if err := request.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
return &request, rawBody, close, nil
|
||||
default:
|
||||
return req, rawBody, close, validate.InvalidContentType(ct)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) decodeCreatePreAuthKeyRequest(r *http.Request) (
|
||||
req *CreatePreAuthKeyReq,
|
||||
rawBody []byte,
|
||||
close func() error,
|
||||
rerr error,
|
||||
) {
|
||||
var closers []func() error
|
||||
close = func() error {
|
||||
var merr error
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return req, rawBody, close, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
if r.ContentLength == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
buf, err := io.ReadAll(r.Body)
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
|
||||
// Reset the body to allow for downstream reading.
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(buf))
|
||||
|
||||
if len(buf) == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
|
||||
rawBody = append(rawBody, buf...)
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var request CreatePreAuthKeyReq
|
||||
if err := func() error {
|
||||
if err := request.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
return &request, rawBody, close, nil
|
||||
default:
|
||||
return req, rawBody, close, validate.InvalidContentType(ct)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) decodeCreateUserRequest(r *http.Request) (
|
||||
req *CreateUserReq,
|
||||
rawBody []byte,
|
||||
close func() error,
|
||||
rerr error,
|
||||
) {
|
||||
var closers []func() error
|
||||
close = func() error {
|
||||
var merr error
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return req, rawBody, close, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
if r.ContentLength == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
buf, err := io.ReadAll(r.Body)
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
|
||||
// Reset the body to allow for downstream reading.
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(buf))
|
||||
|
||||
if len(buf) == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
|
||||
rawBody = append(rawBody, buf...)
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var request CreateUserReq
|
||||
if err := func() error {
|
||||
if err := request.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
return &request, rawBody, close, nil
|
||||
default:
|
||||
return req, rawBody, close, validate.InvalidContentType(ct)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) decodeDebugCreateNodeRequest(r *http.Request) (
|
||||
req *DebugCreateNodeReq,
|
||||
rawBody []byte,
|
||||
close func() error,
|
||||
rerr error,
|
||||
) {
|
||||
var closers []func() error
|
||||
close = func() error {
|
||||
var merr error
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return req, rawBody, close, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
if r.ContentLength == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
buf, err := io.ReadAll(r.Body)
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
|
||||
// Reset the body to allow for downstream reading.
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(buf))
|
||||
|
||||
if len(buf) == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
|
||||
rawBody = append(rawBody, buf...)
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var request DebugCreateNodeReq
|
||||
if err := func() error {
|
||||
if err := request.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
return &request, rawBody, close, nil
|
||||
default:
|
||||
return req, rawBody, close, validate.InvalidContentType(ct)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) decodeExpireApiKeyRequest(r *http.Request) (
|
||||
req *ExpireApiKeyReq,
|
||||
rawBody []byte,
|
||||
close func() error,
|
||||
rerr error,
|
||||
) {
|
||||
var closers []func() error
|
||||
close = func() error {
|
||||
var merr error
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return req, rawBody, close, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
if r.ContentLength == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
buf, err := io.ReadAll(r.Body)
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
|
||||
// Reset the body to allow for downstream reading.
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(buf))
|
||||
|
||||
if len(buf) == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
|
||||
rawBody = append(rawBody, buf...)
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var request ExpireApiKeyReq
|
||||
if err := func() error {
|
||||
if err := request.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
return &request, rawBody, close, nil
|
||||
default:
|
||||
return req, rawBody, close, validate.InvalidContentType(ct)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) decodeExpirePreAuthKeyRequest(r *http.Request) (
|
||||
req *ExpirePreAuthKeyReq,
|
||||
rawBody []byte,
|
||||
close func() error,
|
||||
rerr error,
|
||||
) {
|
||||
var closers []func() error
|
||||
close = func() error {
|
||||
var merr error
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return req, rawBody, close, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
if r.ContentLength == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
buf, err := io.ReadAll(r.Body)
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
|
||||
// Reset the body to allow for downstream reading.
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(buf))
|
||||
|
||||
if len(buf) == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
|
||||
rawBody = append(rawBody, buf...)
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var request ExpirePreAuthKeyReq
|
||||
if err := func() error {
|
||||
if err := request.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
return &request, rawBody, close, nil
|
||||
default:
|
||||
return req, rawBody, close, validate.InvalidContentType(ct)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) decodeSetApprovedRoutesRequest(r *http.Request) (
|
||||
req *SetApprovedRoutesReq,
|
||||
rawBody []byte,
|
||||
close func() error,
|
||||
rerr error,
|
||||
) {
|
||||
var closers []func() error
|
||||
close = func() error {
|
||||
var merr error
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return req, rawBody, close, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
if r.ContentLength == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
buf, err := io.ReadAll(r.Body)
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
|
||||
// Reset the body to allow for downstream reading.
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(buf))
|
||||
|
||||
if len(buf) == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
|
||||
rawBody = append(rawBody, buf...)
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var request SetApprovedRoutesReq
|
||||
if err := func() error {
|
||||
if err := request.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
return &request, rawBody, close, nil
|
||||
default:
|
||||
return req, rawBody, close, validate.InvalidContentType(ct)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) decodeSetPolicyRequest(r *http.Request) (
|
||||
req *SetPolicyReq,
|
||||
rawBody []byte,
|
||||
close func() error,
|
||||
rerr error,
|
||||
) {
|
||||
var closers []func() error
|
||||
close = func() error {
|
||||
var merr error
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return req, rawBody, close, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
if r.ContentLength == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
buf, err := io.ReadAll(r.Body)
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
|
||||
// Reset the body to allow for downstream reading.
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(buf))
|
||||
|
||||
if len(buf) == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
|
||||
rawBody = append(rawBody, buf...)
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var request SetPolicyReq
|
||||
if err := func() error {
|
||||
if err := request.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
return &request, rawBody, close, nil
|
||||
default:
|
||||
return req, rawBody, close, validate.InvalidContentType(ct)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) decodeSetTagsRequest(r *http.Request) (
|
||||
req *SetTagsReq,
|
||||
rawBody []byte,
|
||||
close func() error,
|
||||
rerr error,
|
||||
) {
|
||||
var closers []func() error
|
||||
close = func() error {
|
||||
var merr error
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return req, rawBody, close, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
if r.ContentLength == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
buf, err := io.ReadAll(r.Body)
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
|
||||
// Reset the body to allow for downstream reading.
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(buf))
|
||||
|
||||
if len(buf) == 0 {
|
||||
return req, rawBody, close, validate.ErrBodyRequired
|
||||
}
|
||||
|
||||
rawBody = append(rawBody, buf...)
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var request SetTagsReq
|
||||
if err := func() error {
|
||||
if err := request.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return req, rawBody, close, err
|
||||
}
|
||||
return &request, rawBody, close, nil
|
||||
default:
|
||||
return req, rawBody, close, validate.InvalidContentType(ct)
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
// Code generated by ogen, DO NOT EDIT.
|
||||
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-faster/jx"
|
||||
ht "github.com/ogen-go/ogen/http"
|
||||
)
|
||||
|
||||
func encodeAuthApproveRequest(
|
||||
req *AuthApproveReq,
|
||||
r *http.Request,
|
||||
) error {
|
||||
const contentType = "application/json"
|
||||
e := new(jx.Encoder)
|
||||
{
|
||||
req.Encode(e)
|
||||
}
|
||||
encoded := e.Bytes()
|
||||
ht.SetBody(r, bytes.NewReader(encoded), contentType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeAuthRegisterRequest(
|
||||
req *AuthRegisterReq,
|
||||
r *http.Request,
|
||||
) error {
|
||||
const contentType = "application/json"
|
||||
e := new(jx.Encoder)
|
||||
{
|
||||
req.Encode(e)
|
||||
}
|
||||
encoded := e.Bytes()
|
||||
ht.SetBody(r, bytes.NewReader(encoded), contentType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeAuthRejectRequest(
|
||||
req *AuthRejectReq,
|
||||
r *http.Request,
|
||||
) error {
|
||||
const contentType = "application/json"
|
||||
e := new(jx.Encoder)
|
||||
{
|
||||
req.Encode(e)
|
||||
}
|
||||
encoded := e.Bytes()
|
||||
ht.SetBody(r, bytes.NewReader(encoded), contentType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeCheckPolicyRequest(
|
||||
req *CheckPolicyReq,
|
||||
r *http.Request,
|
||||
) error {
|
||||
const contentType = "application/json"
|
||||
e := new(jx.Encoder)
|
||||
{
|
||||
req.Encode(e)
|
||||
}
|
||||
encoded := e.Bytes()
|
||||
ht.SetBody(r, bytes.NewReader(encoded), contentType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeCreateApiKeyRequest(
|
||||
req *CreateApiKeyReq,
|
||||
r *http.Request,
|
||||
) error {
|
||||
const contentType = "application/json"
|
||||
e := new(jx.Encoder)
|
||||
{
|
||||
req.Encode(e)
|
||||
}
|
||||
encoded := e.Bytes()
|
||||
ht.SetBody(r, bytes.NewReader(encoded), contentType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeCreatePreAuthKeyRequest(
|
||||
req *CreatePreAuthKeyReq,
|
||||
r *http.Request,
|
||||
) error {
|
||||
const contentType = "application/json"
|
||||
e := new(jx.Encoder)
|
||||
{
|
||||
req.Encode(e)
|
||||
}
|
||||
encoded := e.Bytes()
|
||||
ht.SetBody(r, bytes.NewReader(encoded), contentType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeCreateUserRequest(
|
||||
req *CreateUserReq,
|
||||
r *http.Request,
|
||||
) error {
|
||||
const contentType = "application/json"
|
||||
e := new(jx.Encoder)
|
||||
{
|
||||
req.Encode(e)
|
||||
}
|
||||
encoded := e.Bytes()
|
||||
ht.SetBody(r, bytes.NewReader(encoded), contentType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeDebugCreateNodeRequest(
|
||||
req *DebugCreateNodeReq,
|
||||
r *http.Request,
|
||||
) error {
|
||||
const contentType = "application/json"
|
||||
e := new(jx.Encoder)
|
||||
{
|
||||
req.Encode(e)
|
||||
}
|
||||
encoded := e.Bytes()
|
||||
ht.SetBody(r, bytes.NewReader(encoded), contentType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeExpireApiKeyRequest(
|
||||
req *ExpireApiKeyReq,
|
||||
r *http.Request,
|
||||
) error {
|
||||
const contentType = "application/json"
|
||||
e := new(jx.Encoder)
|
||||
{
|
||||
req.Encode(e)
|
||||
}
|
||||
encoded := e.Bytes()
|
||||
ht.SetBody(r, bytes.NewReader(encoded), contentType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeExpirePreAuthKeyRequest(
|
||||
req *ExpirePreAuthKeyReq,
|
||||
r *http.Request,
|
||||
) error {
|
||||
const contentType = "application/json"
|
||||
e := new(jx.Encoder)
|
||||
{
|
||||
req.Encode(e)
|
||||
}
|
||||
encoded := e.Bytes()
|
||||
ht.SetBody(r, bytes.NewReader(encoded), contentType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeSetApprovedRoutesRequest(
|
||||
req *SetApprovedRoutesReq,
|
||||
r *http.Request,
|
||||
) error {
|
||||
const contentType = "application/json"
|
||||
e := new(jx.Encoder)
|
||||
{
|
||||
req.Encode(e)
|
||||
}
|
||||
encoded := e.Bytes()
|
||||
ht.SetBody(r, bytes.NewReader(encoded), contentType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeSetPolicyRequest(
|
||||
req *SetPolicyReq,
|
||||
r *http.Request,
|
||||
) error {
|
||||
const contentType = "application/json"
|
||||
e := new(jx.Encoder)
|
||||
{
|
||||
req.Encode(e)
|
||||
}
|
||||
encoded := e.Bytes()
|
||||
ht.SetBody(r, bytes.NewReader(encoded), contentType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeSetTagsRequest(
|
||||
req *SetTagsReq,
|
||||
r *http.Request,
|
||||
) error {
|
||||
const contentType = "application/json"
|
||||
e := new(jx.Encoder)
|
||||
{
|
||||
req.Encode(e)
|
||||
}
|
||||
encoded := e.Bytes()
|
||||
ht.SetBody(r, bytes.NewReader(encoded), contentType)
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,415 +0,0 @@
|
||||
// Code generated by ogen, DO NOT EDIT.
|
||||
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-faster/errors"
|
||||
"github.com/go-faster/jx"
|
||||
ht "github.com/ogen-go/ogen/http"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
func encodeAuthApproveResponse(response *AuthApproveOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeAuthRegisterResponse(response *AuthRegisterOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeAuthRejectResponse(response *AuthRejectOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeBackfillNodeIPsResponse(response *BackfillNodeIPsOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeCheckPolicyResponse(response *CheckPolicyOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeCreateApiKeyResponse(response *CreateApiKeyOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeCreatePreAuthKeyResponse(response *CreatePreAuthKeyOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeCreateUserResponse(response *CreateUserOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeDebugCreateNodeResponse(response *DebugCreateNodeOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeDeleteApiKeyResponse(response *DeleteApiKeyOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeDeleteNodeResponse(response *DeleteNodeOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeDeletePreAuthKeyResponse(response *DeletePreAuthKeyOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeDeleteUserResponse(response *DeleteUserOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeExpireApiKeyResponse(response *ExpireApiKeyOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeExpireNodeResponse(response *ExpireNodeOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeExpirePreAuthKeyResponse(response *ExpirePreAuthKeyOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeGetNodeResponse(response *GetNodeOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeGetPolicyResponse(response *GetPolicyOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeHealthResponse(response *HealthOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeListApiKeysResponse(response *ListApiKeysOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeListNodesResponse(response *ListNodesOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeListPreAuthKeysResponse(response *ListPreAuthKeysOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeListUsersResponse(response *ListUsersOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeRegisterNodeResponse(response *RegisterNodeOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeRenameNodeResponse(response *RenameNodeOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeRenameUserResponse(response *RenameUserOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeSetApprovedRoutesResponse(response *SetApprovedRoutesOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeSetPolicyResponse(response *SetPolicyOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeSetTagsResponse(response *SetTagsOK, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeErrorResponse(response *ErrorStatusCode, w http.ResponseWriter, span trace.Span) error {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
code := response.StatusCode
|
||||
if code == 0 {
|
||||
// Set default status code.
|
||||
code = http.StatusOK
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
if code >= http.StatusInternalServerError {
|
||||
span.SetStatus(codes.Error, http.StatusText(code))
|
||||
}
|
||||
|
||||
e := new(jx.Encoder)
|
||||
response.Response.Encode(e)
|
||||
if _, err := e.WriteTo(w); err != nil {
|
||||
return errors.Wrap(err, "write")
|
||||
}
|
||||
|
||||
if code >= http.StatusInternalServerError {
|
||||
return errors.Wrapf(ht.ErrInternalServerErrorResponse, "code: %d, message: %s", code, http.StatusText(code))
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,119 +0,0 @@
|
||||
// Code generated by ogen, DO NOT EDIT.
|
||||
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-faster/errors"
|
||||
"github.com/ogen-go/ogen/ogenerrors"
|
||||
)
|
||||
|
||||
// SecurityHandler is handler for security parameters.
|
||||
type SecurityHandler interface {
|
||||
// HandleBearerAuth handles bearerAuth security.
|
||||
HandleBearerAuth(ctx context.Context, operationName OperationName, t BearerAuth) (context.Context, error)
|
||||
}
|
||||
|
||||
func findAuthorization(h http.Header, prefix string) (string, bool) {
|
||||
v, ok := h["Authorization"]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
for _, vv := range v {
|
||||
scheme, value, ok := strings.Cut(vv, " ")
|
||||
if !ok || !strings.EqualFold(scheme, prefix) {
|
||||
continue
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// operationRolesBearerAuth is a private map storing roles per operation.
|
||||
var operationRolesBearerAuth = map[string][]string{
|
||||
AuthApproveOperation: []string{},
|
||||
AuthRegisterOperation: []string{},
|
||||
AuthRejectOperation: []string{},
|
||||
BackfillNodeIPsOperation: []string{},
|
||||
CheckPolicyOperation: []string{},
|
||||
CreateApiKeyOperation: []string{},
|
||||
CreatePreAuthKeyOperation: []string{},
|
||||
CreateUserOperation: []string{},
|
||||
DebugCreateNodeOperation: []string{},
|
||||
DeleteApiKeyOperation: []string{},
|
||||
DeleteNodeOperation: []string{},
|
||||
DeletePreAuthKeyOperation: []string{},
|
||||
DeleteUserOperation: []string{},
|
||||
ExpireApiKeyOperation: []string{},
|
||||
ExpireNodeOperation: []string{},
|
||||
ExpirePreAuthKeyOperation: []string{},
|
||||
GetNodeOperation: []string{},
|
||||
GetPolicyOperation: []string{},
|
||||
HealthOperation: []string{},
|
||||
ListApiKeysOperation: []string{},
|
||||
ListNodesOperation: []string{},
|
||||
ListPreAuthKeysOperation: []string{},
|
||||
ListUsersOperation: []string{},
|
||||
RegisterNodeOperation: []string{},
|
||||
RenameNodeOperation: []string{},
|
||||
RenameUserOperation: []string{},
|
||||
SetApprovedRoutesOperation: []string{},
|
||||
SetPolicyOperation: []string{},
|
||||
SetTagsOperation: []string{},
|
||||
}
|
||||
|
||||
// GetRolesForBearerAuth returns the required roles for the given operation.
|
||||
//
|
||||
// This is useful for authorization scenarios where you need to know which roles
|
||||
// are required for an operation.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// requiredRoles := GetRolesForBearerAuth(AddPetOperation)
|
||||
//
|
||||
// Returns nil if the operation has no role requirements or if the operation is unknown.
|
||||
func GetRolesForBearerAuth(operation string) []string {
|
||||
roles, ok := operationRolesBearerAuth[operation]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// Return a copy to prevent external modification
|
||||
result := make([]string, len(roles))
|
||||
copy(result, roles)
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Server) securityBearerAuth(ctx context.Context, operationName OperationName, req *http.Request) (context.Context, bool, error) {
|
||||
var t BearerAuth
|
||||
token, ok := findAuthorization(req.Header, "Bearer")
|
||||
if !ok {
|
||||
return ctx, false, nil
|
||||
}
|
||||
t.Token = token
|
||||
t.Roles = operationRolesBearerAuth[operationName]
|
||||
rctx, err := s.sec.HandleBearerAuth(ctx, operationName, t)
|
||||
if errors.Is(err, ogenerrors.ErrSkipServerSecurity) {
|
||||
return nil, false, nil
|
||||
} else if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return rctx, true, err
|
||||
}
|
||||
|
||||
// SecuritySource is provider of security values (tokens, passwords, etc.).
|
||||
type SecuritySource interface {
|
||||
// BearerAuth provides bearerAuth security value.
|
||||
BearerAuth(ctx context.Context, operationName OperationName) (BearerAuth, error)
|
||||
}
|
||||
|
||||
func (s *Client) securityBearerAuth(ctx context.Context, operationName OperationName, req *http.Request) error {
|
||||
t, err := s.sec.BearerAuth(ctx, operationName)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "security source \"BearerAuth\"")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+t.Token)
|
||||
return nil
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
// Code generated by ogen, DO NOT EDIT.
|
||||
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Handler handles operations described by OpenAPI v3 specification.
|
||||
type Handler interface {
|
||||
// AuthApprove implements AuthApprove operation.
|
||||
//
|
||||
// Approve a pending auth session.
|
||||
//
|
||||
// POST /api/v1/auth/approve
|
||||
AuthApprove(ctx context.Context, req *AuthApproveReq) error
|
||||
// AuthRegister implements AuthRegister operation.
|
||||
//
|
||||
// Register a node via an auth id (alias of RegisterNode).
|
||||
//
|
||||
// POST /api/v1/auth/register
|
||||
AuthRegister(ctx context.Context, req *AuthRegisterReq) (*AuthRegisterOK, error)
|
||||
// AuthReject implements AuthReject operation.
|
||||
//
|
||||
// Reject a pending auth session.
|
||||
//
|
||||
// POST /api/v1/auth/reject
|
||||
AuthReject(ctx context.Context, req *AuthRejectReq) error
|
||||
// BackfillNodeIPs implements BackfillNodeIPs operation.
|
||||
//
|
||||
// Backfill missing IP addresses for all nodes.
|
||||
//
|
||||
// POST /api/v1/node/backfillips
|
||||
BackfillNodeIPs(ctx context.Context, params BackfillNodeIPsParams) (*BackfillNodeIPsOK, error)
|
||||
// CheckPolicy implements CheckPolicy operation.
|
||||
//
|
||||
// Validate a policy against live users and nodes without storing it.
|
||||
//
|
||||
// POST /api/v1/policy/check
|
||||
CheckPolicy(ctx context.Context, req *CheckPolicyReq) error
|
||||
// CreateApiKey implements CreateApiKey operation.
|
||||
//
|
||||
// Create an API key.
|
||||
//
|
||||
// POST /api/v1/apikey
|
||||
CreateApiKey(ctx context.Context, req *CreateApiKeyReq) (*CreateApiKeyOK, error)
|
||||
// CreatePreAuthKey implements CreatePreAuthKey operation.
|
||||
//
|
||||
// Create a pre-auth key.
|
||||
//
|
||||
// POST /api/v1/preauthkey
|
||||
CreatePreAuthKey(ctx context.Context, req *CreatePreAuthKeyReq) (*CreatePreAuthKeyOK, error)
|
||||
// CreateUser implements CreateUser operation.
|
||||
//
|
||||
// Create a user.
|
||||
//
|
||||
// POST /api/v1/user
|
||||
CreateUser(ctx context.Context, req *CreateUserReq) (*CreateUserOK, error)
|
||||
// DebugCreateNode implements DebugCreateNode operation.
|
||||
//
|
||||
// Create a debug node and cache its registration (testing only).
|
||||
//
|
||||
// POST /api/v1/debug/node
|
||||
DebugCreateNode(ctx context.Context, req *DebugCreateNodeReq) (*DebugCreateNodeOK, error)
|
||||
// DeleteApiKey implements DeleteApiKey operation.
|
||||
//
|
||||
// Delete an API key by prefix (or id).
|
||||
//
|
||||
// DELETE /api/v1/apikey/{prefix}
|
||||
DeleteApiKey(ctx context.Context, params DeleteApiKeyParams) error
|
||||
// DeleteNode implements DeleteNode operation.
|
||||
//
|
||||
// Delete a node.
|
||||
//
|
||||
// DELETE /api/v1/node/{node_id}
|
||||
DeleteNode(ctx context.Context, params DeleteNodeParams) error
|
||||
// DeletePreAuthKey implements DeletePreAuthKey operation.
|
||||
//
|
||||
// Delete a pre-auth key.
|
||||
//
|
||||
// DELETE /api/v1/preauthkey
|
||||
DeletePreAuthKey(ctx context.Context, params DeletePreAuthKeyParams) error
|
||||
// DeleteUser implements DeleteUser operation.
|
||||
//
|
||||
// Delete a user.
|
||||
//
|
||||
// DELETE /api/v1/user/{id}
|
||||
DeleteUser(ctx context.Context, params DeleteUserParams) error
|
||||
// ExpireApiKey implements ExpireApiKey operation.
|
||||
//
|
||||
// Expire an API key by id or prefix.
|
||||
//
|
||||
// POST /api/v1/apikey/expire
|
||||
ExpireApiKey(ctx context.Context, req *ExpireApiKeyReq) error
|
||||
// ExpireNode implements ExpireNode operation.
|
||||
//
|
||||
// Expire a node, or disable its expiry.
|
||||
//
|
||||
// POST /api/v1/node/{node_id}/expire
|
||||
ExpireNode(ctx context.Context, params ExpireNodeParams) (*ExpireNodeOK, error)
|
||||
// ExpirePreAuthKey implements ExpirePreAuthKey operation.
|
||||
//
|
||||
// Expire a pre-auth key.
|
||||
//
|
||||
// POST /api/v1/preauthkey/expire
|
||||
ExpirePreAuthKey(ctx context.Context, req *ExpirePreAuthKeyReq) error
|
||||
// GetNode implements GetNode operation.
|
||||
//
|
||||
// Get a node by id.
|
||||
//
|
||||
// GET /api/v1/node/{node_id}
|
||||
GetNode(ctx context.Context, params GetNodeParams) (*GetNodeOK, error)
|
||||
// GetPolicy implements GetPolicy operation.
|
||||
//
|
||||
// Get the current ACL policy.
|
||||
//
|
||||
// GET /api/v1/policy
|
||||
GetPolicy(ctx context.Context) (*GetPolicyOK, error)
|
||||
// Health implements Health operation.
|
||||
//
|
||||
// Report server health, including database connectivity.
|
||||
//
|
||||
// GET /api/v1/health
|
||||
Health(ctx context.Context) (*HealthOK, error)
|
||||
// ListApiKeys implements ListApiKeys operation.
|
||||
//
|
||||
// List all API keys.
|
||||
//
|
||||
// GET /api/v1/apikey
|
||||
ListApiKeys(ctx context.Context) (*ListApiKeysOK, error)
|
||||
// ListNodes implements ListNodes operation.
|
||||
//
|
||||
// List nodes, optionally filtered by user.
|
||||
//
|
||||
// GET /api/v1/node
|
||||
ListNodes(ctx context.Context, params ListNodesParams) (*ListNodesOK, error)
|
||||
// ListPreAuthKeys implements ListPreAuthKeys operation.
|
||||
//
|
||||
// List all pre-auth keys.
|
||||
//
|
||||
// GET /api/v1/preauthkey
|
||||
ListPreAuthKeys(ctx context.Context) (*ListPreAuthKeysOK, error)
|
||||
// ListUsers implements ListUsers operation.
|
||||
//
|
||||
// List users, optionally filtered by id, name, or email.
|
||||
//
|
||||
// GET /api/v1/user
|
||||
ListUsers(ctx context.Context, params ListUsersParams) (*ListUsersOK, error)
|
||||
// RegisterNode implements RegisterNode operation.
|
||||
//
|
||||
// Register a node to a user using a registration id.
|
||||
//
|
||||
// POST /api/v1/node/register
|
||||
RegisterNode(ctx context.Context, params RegisterNodeParams) (*RegisterNodeOK, error)
|
||||
// RenameNode implements RenameNode operation.
|
||||
//
|
||||
// Rename a node.
|
||||
//
|
||||
// POST /api/v1/node/{node_id}/rename/{new_name}
|
||||
RenameNode(ctx context.Context, params RenameNodeParams) (*RenameNodeOK, error)
|
||||
// RenameUser implements RenameUser operation.
|
||||
//
|
||||
// Rename a user.
|
||||
//
|
||||
// POST /api/v1/user/{old_id}/rename/{new_name}
|
||||
RenameUser(ctx context.Context, params RenameUserParams) (*RenameUserOK, error)
|
||||
// SetApprovedRoutes implements SetApprovedRoutes operation.
|
||||
//
|
||||
// Set the approved subnet routes of a node.
|
||||
//
|
||||
// POST /api/v1/node/{node_id}/approve_routes
|
||||
SetApprovedRoutes(ctx context.Context, req *SetApprovedRoutesReq, params SetApprovedRoutesParams) (*SetApprovedRoutesOK, error)
|
||||
// SetPolicy implements SetPolicy operation.
|
||||
//
|
||||
// Set the ACL policy (database policy mode only).
|
||||
//
|
||||
// PUT /api/v1/policy
|
||||
SetPolicy(ctx context.Context, req *SetPolicyReq) (*SetPolicyOK, error)
|
||||
// SetTags implements SetTags operation.
|
||||
//
|
||||
// Set the tags of a node (converts it to a tagged node).
|
||||
//
|
||||
// POST /api/v1/node/{node_id}/tags
|
||||
SetTags(ctx context.Context, req *SetTagsReq, params SetTagsParams) (*SetTagsOK, error)
|
||||
// NewError creates *ErrorStatusCode from error returned by handler.
|
||||
//
|
||||
// Used for common default response.
|
||||
NewError(ctx context.Context, err error) *ErrorStatusCode
|
||||
}
|
||||
|
||||
// Server implements http server based on OpenAPI v3 specification and
|
||||
// calls Handler to handle requests.
|
||||
type Server struct {
|
||||
h Handler
|
||||
sec SecurityHandler
|
||||
baseServer
|
||||
}
|
||||
|
||||
// NewServer creates new Server.
|
||||
func NewServer(h Handler, sec SecurityHandler, opts ...ServerOption) (*Server, error) {
|
||||
s, err := newServerConfig(opts...).baseServer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{
|
||||
h: h,
|
||||
sec: sec,
|
||||
baseServer: s,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
// Code generated by ogen, DO NOT EDIT.
|
||||
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
ht "github.com/ogen-go/ogen/http"
|
||||
)
|
||||
|
||||
// UnimplementedHandler is no-op Handler which returns http.ErrNotImplemented.
|
||||
type UnimplementedHandler struct{}
|
||||
|
||||
var _ Handler = UnimplementedHandler{}
|
||||
|
||||
// AuthApprove implements AuthApprove operation.
|
||||
//
|
||||
// Approve a pending auth session.
|
||||
//
|
||||
// POST /api/v1/auth/approve
|
||||
func (UnimplementedHandler) AuthApprove(ctx context.Context, req *AuthApproveReq) error {
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// AuthRegister implements AuthRegister operation.
|
||||
//
|
||||
// Register a node via an auth id (alias of RegisterNode).
|
||||
//
|
||||
// POST /api/v1/auth/register
|
||||
func (UnimplementedHandler) AuthRegister(ctx context.Context, req *AuthRegisterReq) (r *AuthRegisterOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// AuthReject implements AuthReject operation.
|
||||
//
|
||||
// Reject a pending auth session.
|
||||
//
|
||||
// POST /api/v1/auth/reject
|
||||
func (UnimplementedHandler) AuthReject(ctx context.Context, req *AuthRejectReq) error {
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// BackfillNodeIPs implements BackfillNodeIPs operation.
|
||||
//
|
||||
// Backfill missing IP addresses for all nodes.
|
||||
//
|
||||
// POST /api/v1/node/backfillips
|
||||
func (UnimplementedHandler) BackfillNodeIPs(ctx context.Context, params BackfillNodeIPsParams) (r *BackfillNodeIPsOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// CheckPolicy implements CheckPolicy operation.
|
||||
//
|
||||
// Validate a policy against live users and nodes without storing it.
|
||||
//
|
||||
// POST /api/v1/policy/check
|
||||
func (UnimplementedHandler) CheckPolicy(ctx context.Context, req *CheckPolicyReq) error {
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// CreateApiKey implements CreateApiKey operation.
|
||||
//
|
||||
// Create an API key.
|
||||
//
|
||||
// POST /api/v1/apikey
|
||||
func (UnimplementedHandler) CreateApiKey(ctx context.Context, req *CreateApiKeyReq) (r *CreateApiKeyOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// CreatePreAuthKey implements CreatePreAuthKey operation.
|
||||
//
|
||||
// Create a pre-auth key.
|
||||
//
|
||||
// POST /api/v1/preauthkey
|
||||
func (UnimplementedHandler) CreatePreAuthKey(ctx context.Context, req *CreatePreAuthKeyReq) (r *CreatePreAuthKeyOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// CreateUser implements CreateUser operation.
|
||||
//
|
||||
// Create a user.
|
||||
//
|
||||
// POST /api/v1/user
|
||||
func (UnimplementedHandler) CreateUser(ctx context.Context, req *CreateUserReq) (r *CreateUserOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// DebugCreateNode implements DebugCreateNode operation.
|
||||
//
|
||||
// Create a debug node and cache its registration (testing only).
|
||||
//
|
||||
// POST /api/v1/debug/node
|
||||
func (UnimplementedHandler) DebugCreateNode(ctx context.Context, req *DebugCreateNodeReq) (r *DebugCreateNodeOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// DeleteApiKey implements DeleteApiKey operation.
|
||||
//
|
||||
// Delete an API key by prefix (or id).
|
||||
//
|
||||
// DELETE /api/v1/apikey/{prefix}
|
||||
func (UnimplementedHandler) DeleteApiKey(ctx context.Context, params DeleteApiKeyParams) error {
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// DeleteNode implements DeleteNode operation.
|
||||
//
|
||||
// Delete a node.
|
||||
//
|
||||
// DELETE /api/v1/node/{node_id}
|
||||
func (UnimplementedHandler) DeleteNode(ctx context.Context, params DeleteNodeParams) error {
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// DeletePreAuthKey implements DeletePreAuthKey operation.
|
||||
//
|
||||
// Delete a pre-auth key.
|
||||
//
|
||||
// DELETE /api/v1/preauthkey
|
||||
func (UnimplementedHandler) DeletePreAuthKey(ctx context.Context, params DeletePreAuthKeyParams) error {
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// DeleteUser implements DeleteUser operation.
|
||||
//
|
||||
// Delete a user.
|
||||
//
|
||||
// DELETE /api/v1/user/{id}
|
||||
func (UnimplementedHandler) DeleteUser(ctx context.Context, params DeleteUserParams) error {
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// ExpireApiKey implements ExpireApiKey operation.
|
||||
//
|
||||
// Expire an API key by id or prefix.
|
||||
//
|
||||
// POST /api/v1/apikey/expire
|
||||
func (UnimplementedHandler) ExpireApiKey(ctx context.Context, req *ExpireApiKeyReq) error {
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// ExpireNode implements ExpireNode operation.
|
||||
//
|
||||
// Expire a node, or disable its expiry.
|
||||
//
|
||||
// POST /api/v1/node/{node_id}/expire
|
||||
func (UnimplementedHandler) ExpireNode(ctx context.Context, params ExpireNodeParams) (r *ExpireNodeOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// ExpirePreAuthKey implements ExpirePreAuthKey operation.
|
||||
//
|
||||
// Expire a pre-auth key.
|
||||
//
|
||||
// POST /api/v1/preauthkey/expire
|
||||
func (UnimplementedHandler) ExpirePreAuthKey(ctx context.Context, req *ExpirePreAuthKeyReq) error {
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// GetNode implements GetNode operation.
|
||||
//
|
||||
// Get a node by id.
|
||||
//
|
||||
// GET /api/v1/node/{node_id}
|
||||
func (UnimplementedHandler) GetNode(ctx context.Context, params GetNodeParams) (r *GetNodeOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// GetPolicy implements GetPolicy operation.
|
||||
//
|
||||
// Get the current ACL policy.
|
||||
//
|
||||
// GET /api/v1/policy
|
||||
func (UnimplementedHandler) GetPolicy(ctx context.Context) (r *GetPolicyOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// Health implements Health operation.
|
||||
//
|
||||
// Report server health, including database connectivity.
|
||||
//
|
||||
// GET /api/v1/health
|
||||
func (UnimplementedHandler) Health(ctx context.Context) (r *HealthOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// ListApiKeys implements ListApiKeys operation.
|
||||
//
|
||||
// List all API keys.
|
||||
//
|
||||
// GET /api/v1/apikey
|
||||
func (UnimplementedHandler) ListApiKeys(ctx context.Context) (r *ListApiKeysOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// ListNodes implements ListNodes operation.
|
||||
//
|
||||
// List nodes, optionally filtered by user.
|
||||
//
|
||||
// GET /api/v1/node
|
||||
func (UnimplementedHandler) ListNodes(ctx context.Context, params ListNodesParams) (r *ListNodesOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// ListPreAuthKeys implements ListPreAuthKeys operation.
|
||||
//
|
||||
// List all pre-auth keys.
|
||||
//
|
||||
// GET /api/v1/preauthkey
|
||||
func (UnimplementedHandler) ListPreAuthKeys(ctx context.Context) (r *ListPreAuthKeysOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// ListUsers implements ListUsers operation.
|
||||
//
|
||||
// List users, optionally filtered by id, name, or email.
|
||||
//
|
||||
// GET /api/v1/user
|
||||
func (UnimplementedHandler) ListUsers(ctx context.Context, params ListUsersParams) (r *ListUsersOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// RegisterNode implements RegisterNode operation.
|
||||
//
|
||||
// Register a node to a user using a registration id.
|
||||
//
|
||||
// POST /api/v1/node/register
|
||||
func (UnimplementedHandler) RegisterNode(ctx context.Context, params RegisterNodeParams) (r *RegisterNodeOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// RenameNode implements RenameNode operation.
|
||||
//
|
||||
// Rename a node.
|
||||
//
|
||||
// POST /api/v1/node/{node_id}/rename/{new_name}
|
||||
func (UnimplementedHandler) RenameNode(ctx context.Context, params RenameNodeParams) (r *RenameNodeOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// RenameUser implements RenameUser operation.
|
||||
//
|
||||
// Rename a user.
|
||||
//
|
||||
// POST /api/v1/user/{old_id}/rename/{new_name}
|
||||
func (UnimplementedHandler) RenameUser(ctx context.Context, params RenameUserParams) (r *RenameUserOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// SetApprovedRoutes implements SetApprovedRoutes operation.
|
||||
//
|
||||
// Set the approved subnet routes of a node.
|
||||
//
|
||||
// POST /api/v1/node/{node_id}/approve_routes
|
||||
func (UnimplementedHandler) SetApprovedRoutes(ctx context.Context, req *SetApprovedRoutesReq, params SetApprovedRoutesParams) (r *SetApprovedRoutesOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// SetPolicy implements SetPolicy operation.
|
||||
//
|
||||
// Set the ACL policy (database policy mode only).
|
||||
//
|
||||
// PUT /api/v1/policy
|
||||
func (UnimplementedHandler) SetPolicy(ctx context.Context, req *SetPolicyReq) (r *SetPolicyOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// SetTags implements SetTags operation.
|
||||
//
|
||||
// Set the tags of a node (converts it to a tagged node).
|
||||
//
|
||||
// POST /api/v1/node/{node_id}/tags
|
||||
func (UnimplementedHandler) SetTags(ctx context.Context, req *SetTagsReq, params SetTagsParams) (r *SetTagsOK, _ error) {
|
||||
return r, ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// NewError creates *ErrorStatusCode from error returned by handler.
|
||||
//
|
||||
// Used for common default response.
|
||||
func (UnimplementedHandler) NewError(ctx context.Context, err error) (r *ErrorStatusCode) {
|
||||
r = new(ErrorStatusCode)
|
||||
return r
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
// Code generated by ogen, DO NOT EDIT.
|
||||
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-faster/errors"
|
||||
"github.com/ogen-go/ogen/validate"
|
||||
)
|
||||
|
||||
func (s *AuthRegisterOK) Validate() error {
|
||||
if s == nil {
|
||||
return validate.ErrNilPointer
|
||||
}
|
||||
|
||||
var failures []validate.FieldError
|
||||
if err := func() error {
|
||||
if value, ok := s.Node.Get(); ok {
|
||||
if err := func() error {
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
failures = append(failures, validate.FieldError{
|
||||
Name: "node",
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return &validate.Error{Fields: failures}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DebugCreateNodeOK) Validate() error {
|
||||
if s == nil {
|
||||
return validate.ErrNilPointer
|
||||
}
|
||||
|
||||
var failures []validate.FieldError
|
||||
if err := func() error {
|
||||
if value, ok := s.Node.Get(); ok {
|
||||
if err := func() error {
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
failures = append(failures, validate.FieldError{
|
||||
Name: "node",
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return &validate.Error{Fields: failures}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ExpireNodeOK) Validate() error {
|
||||
if s == nil {
|
||||
return validate.ErrNilPointer
|
||||
}
|
||||
|
||||
var failures []validate.FieldError
|
||||
if err := func() error {
|
||||
if value, ok := s.Node.Get(); ok {
|
||||
if err := func() error {
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
failures = append(failures, validate.FieldError{
|
||||
Name: "node",
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return &validate.Error{Fields: failures}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GetNodeOK) Validate() error {
|
||||
if s == nil {
|
||||
return validate.ErrNilPointer
|
||||
}
|
||||
|
||||
var failures []validate.FieldError
|
||||
if err := func() error {
|
||||
if value, ok := s.Node.Get(); ok {
|
||||
if err := func() error {
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
failures = append(failures, validate.FieldError{
|
||||
Name: "node",
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return &validate.Error{Fields: failures}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ListNodesOK) Validate() error {
|
||||
if s == nil {
|
||||
return validate.ErrNilPointer
|
||||
}
|
||||
|
||||
var failures []validate.FieldError
|
||||
if err := func() error {
|
||||
var failures []validate.FieldError
|
||||
for i, elem := range s.Nodes {
|
||||
if err := func() error {
|
||||
if err := elem.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
failures = append(failures, validate.FieldError{
|
||||
Name: fmt.Sprintf("[%d]", i),
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return &validate.Error{Fields: failures}
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
failures = append(failures, validate.FieldError{
|
||||
Name: "nodes",
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return &validate.Error{Fields: failures}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Node) Validate() error {
|
||||
if s == nil {
|
||||
return validate.ErrNilPointer
|
||||
}
|
||||
|
||||
var failures []validate.FieldError
|
||||
if err := func() error {
|
||||
if value, ok := s.RegisterMethod.Get(); ok {
|
||||
if err := func() error {
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
failures = append(failures, validate.FieldError{
|
||||
Name: "registerMethod",
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return &validate.Error{Fields: failures}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s RegisterMethod) Validate() error {
|
||||
switch s {
|
||||
case "REGISTER_METHOD_UNSPECIFIED":
|
||||
return nil
|
||||
case "REGISTER_METHOD_AUTH_KEY":
|
||||
return nil
|
||||
case "REGISTER_METHOD_CLI":
|
||||
return nil
|
||||
case "REGISTER_METHOD_OIDC":
|
||||
return nil
|
||||
default:
|
||||
return errors.Errorf("invalid value: %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RegisterNodeOK) Validate() error {
|
||||
if s == nil {
|
||||
return validate.ErrNilPointer
|
||||
}
|
||||
|
||||
var failures []validate.FieldError
|
||||
if err := func() error {
|
||||
if value, ok := s.Node.Get(); ok {
|
||||
if err := func() error {
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
failures = append(failures, validate.FieldError{
|
||||
Name: "node",
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return &validate.Error{Fields: failures}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RenameNodeOK) Validate() error {
|
||||
if s == nil {
|
||||
return validate.ErrNilPointer
|
||||
}
|
||||
|
||||
var failures []validate.FieldError
|
||||
if err := func() error {
|
||||
if value, ok := s.Node.Get(); ok {
|
||||
if err := func() error {
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
failures = append(failures, validate.FieldError{
|
||||
Name: "node",
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return &validate.Error{Fields: failures}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SetApprovedRoutesOK) Validate() error {
|
||||
if s == nil {
|
||||
return validate.ErrNilPointer
|
||||
}
|
||||
|
||||
var failures []validate.FieldError
|
||||
if err := func() error {
|
||||
if value, ok := s.Node.Get(); ok {
|
||||
if err := func() error {
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
failures = append(failures, validate.FieldError{
|
||||
Name: "node",
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return &validate.Error{Fields: failures}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SetTagsOK) Validate() error {
|
||||
if s == nil {
|
||||
return validate.ErrNilPointer
|
||||
}
|
||||
|
||||
var failures []validate.FieldError
|
||||
if err := func() error {
|
||||
if value, ok := s.Node.Get(); ok {
|
||||
if err := func() error {
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
failures = append(failures, validate.FieldError{
|
||||
Name: "node",
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return &validate.Error{Fields: failures}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -10,22 +10,21 @@ 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
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/go-chi/metrics v0.1.1
|
||||
github.com/go-faster/errors v0.7.1
|
||||
github.com/go-faster/jx v1.2.0
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.6
|
||||
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/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/ogen-go/ogen v1.22.0
|
||||
github.com/ory/dockertest/v3 v3.12.0
|
||||
github.com/pkg/profile v1.7.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
@@ -43,9 +42,6 @@ require (
|
||||
github.com/tailscale/squibble v0.0.0-20260411062017-141f5d618bc4
|
||||
github.com/tailscale/tailsql v0.0.0-20260522170732-77aec5aabc76
|
||||
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/metric v1.44.0
|
||||
go.opentelemetry.io/otel/trace v1.44.0
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/exp v0.0.0-20260603202125-055de637280b
|
||||
@@ -57,6 +53,7 @@ require (
|
||||
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
|
||||
)
|
||||
|
||||
@@ -105,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
|
||||
@@ -135,20 +133,16 @@ require (
|
||||
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
|
||||
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/docker/cli v29.5.3+incompatible // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
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/ghodss/yaml v1.0.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-faster/yaml v0.4.6 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
@@ -214,8 +208,6 @@ require (
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/safchain/ethtool v0.7.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
@@ -234,37 +226,35 @@ require (
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20260603202125-055de637280b // indirect
|
||||
golang.org/x/image v0.41.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/term v0.44.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.46.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||
google.golang.org/genproto/googleapis/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
|
||||
gopkg.in/yaml.v2 v2.4.0 // 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
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/ogen-go/ogen/cmd/ogen
|
||||
golang.org/x/tools/cmd/stress
|
||||
golang.org/x/tools/cmd/stringer
|
||||
tailscale.com/cmd/tailscale
|
||||
|
||||
@@ -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=
|
||||
@@ -146,8 +152,6 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs=
|
||||
github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||
@@ -158,8 +162,6 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||
github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=
|
||||
github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
|
||||
@@ -171,12 +173,10 @@ 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/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
|
||||
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
@@ -187,12 +187,6 @@ github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/metrics v0.1.1 h1:CXhbnkAVVjb0k73EBRQ6Z2YdWFnbXZgNtg1Mboguibk=
|
||||
github.com/go-chi/metrics v0.1.1/go.mod h1:mcGTM1pPalP7WCtb+akNYFO/lwNwBBLCuedepqjoPn4=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI=
|
||||
github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE=
|
||||
github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I=
|
||||
github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.6 h1:VtX+l1Stj2v5RGubVQk0LS/8EPGXR+ldcOyCmlmKoyg=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.6/go.mod h1:PZpedQc4tWaxn6kvXicwhinh3L0seLpMc5ReKRX5id4=
|
||||
github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
|
||||
@@ -298,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=
|
||||
@@ -363,10 +358,12 @@ 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/ogen-go/ogen v1.22.0 h1:7wU+jcIKg/JBAhM95909ULLdAkGr43KQOuvNpJ7Mxb4=
|
||||
github.com/ogen-go/ogen v1.22.0/go.mod h1:7BOh9a51QiPCC92RMrj1LlkLjejhBAyPhR+oMc6lR9g=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -425,12 +422,8 @@ github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/sasha-s/go-deadlock v0.3.9 h1:fiaT9rB7g5sr5ddNZvlwheclN9IP86eFW9WgqlEQV+w=
|
||||
github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
@@ -446,6 +439,7 @@ 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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
@@ -510,8 +504,6 @@ github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQ
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
@@ -534,10 +526,6 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
@@ -559,8 +547,8 @@ golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -612,8 +600,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
|
||||
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
@@ -686,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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+208
-71
@@ -3,106 +3,243 @@ package apiv1
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
oas "github.com/juanfont/headscale/gen/api/v1"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
)
|
||||
|
||||
// CreateApiKey creates an API key and returns the full secret. This is the only
|
||||
// time the secret is exposed.
|
||||
func (s *Server) CreateApiKey(
|
||||
_ context.Context,
|
||||
req *oas.CreateApiKeyReq,
|
||||
) (*oas.CreateApiKeyOK, error) {
|
||||
var expiration time.Time
|
||||
if v, ok := req.Expiration.Get(); ok {
|
||||
expiration = v
|
||||
}
|
||||
|
||||
key, _, err := s.state.CreateAPIKey(&expiration)
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
return &oas.CreateApiKeyOK{ApiKey: oas.NewOptString(key)}, nil
|
||||
func init() {
|
||||
registrations = append(registrations, registerApiKeys)
|
||||
}
|
||||
|
||||
// ListApiKeys lists all API keys (masked), sorted by id.
|
||||
func (s *Server) ListApiKeys(_ context.Context) (*oas.ListApiKeysOK, error) {
|
||||
keys, err := s.state.ListAPIKeys()
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
slices.SortFunc(keys, func(a, b types.APIKey) int { return cmp.Compare(a.ID, b.ID) })
|
||||
|
||||
out := make([]oas.ApiKey, len(keys))
|
||||
for i := range keys {
|
||||
out[i] = oasAPIKey(&keys[i])
|
||||
}
|
||||
|
||||
return &oas.ListApiKeysOK{ApiKeys: out}, nil
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// ExpireApiKey expires an API key identified by id or prefix.
|
||||
func (s *Server) ExpireApiKey(_ context.Context, req *oas.ExpireApiKeyReq) error {
|
||||
key, apiErr := s.apiKeyByIDOrPrefix(req.ID.Or(0), req.Prefix.Or(""))
|
||||
if apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
|
||||
err := s.state.ExpireAPIKey(key)
|
||||
if err != nil {
|
||||
return mapStateError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
// CreateApiKeyRequestBody is the v1.CreateApiKeyRequest body.
|
||||
type CreateApiKeyRequestBody struct {
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteApiKey deletes an API key identified by prefix (or id).
|
||||
func (s *Server) DeleteApiKey(_ context.Context, params oas.DeleteApiKeyParams) error {
|
||||
key, apiErr := s.apiKeyByIDOrPrefix(params.ID.Or(0), params.Prefix)
|
||||
if apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
|
||||
err := s.state.DestroyAPIKey(*key)
|
||||
if err != nil {
|
||||
return mapStateError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
// ExpireApiKeyRequestBody is the v1.ExpireApiKeyRequest body.
|
||||
type ExpireApiKeyRequestBody struct {
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
ID string `format:"uint64" json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// apiKeyByIDOrPrefix looks up an API key by exactly one of id or prefix.
|
||||
// Providing neither or both is a 400.
|
||||
func (s *Server) apiKeyByIDOrPrefix(
|
||||
id uint64,
|
||||
prefix string,
|
||||
) (*types.APIKey, *oas.ErrorStatusCode) {
|
||||
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, badRequest("provide either id or prefix, not both")
|
||||
return nil, huma.Error400BadRequest("provide either id or prefix, not both")
|
||||
case hasID:
|
||||
key, err := s.state.GetAPIKeyByID(id)
|
||||
key, err := b.State.GetAPIKeyByID(id)
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
return nil, mapError("getting api key", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
case hasPrefix:
|
||||
key, err := s.state.GetAPIKey(prefix)
|
||||
key, err := b.State.GetAPIKey(prefix)
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
return nil, mapError("getting api key", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
default:
|
||||
return nil, badRequest("must provide id or prefix")
|
||||
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 + "***"
|
||||
}
|
||||
|
||||
+134
-78
@@ -5,102 +5,158 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
oas "github.com/juanfont/headscale/gen/api/v1"
|
||||
"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")
|
||||
|
||||
type socketAuthContextKey struct{}
|
||||
|
||||
// WithSocketAuth marks ctx as originating from the trusted local unix socket,
|
||||
// where filesystem permissions are the trust boundary. Requests carrying this
|
||||
// marker bypass bearer-token validation. The server mounts this on the
|
||||
// socket-only listener.
|
||||
func WithSocketAuth(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, socketAuthContextKey{}, true)
|
||||
// AuthRegisterRequestBody is the v1.AuthRegisterRequest body.
|
||||
type AuthRegisterRequestBody struct {
|
||||
User string `json:"user,omitempty"`
|
||||
AuthID string `json:"authId,omitempty"`
|
||||
}
|
||||
|
||||
func isSocketAuth(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(socketAuthContextKey{}).(bool)
|
||||
return v
|
||||
// AuthApproveRequestBody is the v1.AuthApproveRequest body.
|
||||
type AuthApproveRequestBody struct {
|
||||
AuthID string `json:"authId,omitempty"`
|
||||
}
|
||||
|
||||
// HandleBearerAuth validates the API key bearer token against the state layer.
|
||||
// A missing or malformed Authorization header is reported by ogen before this
|
||||
// is reached. Any validation failure — a malformed/unknown key (which
|
||||
// [state.State.ValidateAPIKey] reports as an error) or an expired/invalid one —
|
||||
// is a 401, matching the previous middleware which rejected every such case
|
||||
// with Unauthorized.
|
||||
func (s *Server) HandleBearerAuth(
|
||||
ctx context.Context,
|
||||
_ oas.OperationName,
|
||||
t oas.BearerAuth,
|
||||
) (context.Context, error) {
|
||||
// Requests from the local unix socket are trusted via filesystem permissions.
|
||||
if isSocketAuth(ctx) {
|
||||
return ctx, nil
|
||||
// AuthRejectRequestBody is the v1.AuthRejectRequest body.
|
||||
type AuthRejectRequestBody struct {
|
||||
AuthID string `json:"authId,omitempty"`
|
||||
}
|
||||
|
||||
type (
|
||||
authRegisterInput struct {
|
||||
Body AuthRegisterRequestBody
|
||||
}
|
||||
|
||||
valid, err := s.state.ValidateAPIKey(t.Token)
|
||||
if err != nil || !valid {
|
||||
return ctx, apiError(http.StatusUnauthorized, "invalid API key")
|
||||
authRegisterOutput struct {
|
||||
Body struct {
|
||||
Node Node `json:"node"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
type (
|
||||
authApproveInput struct {
|
||||
Body AuthApproveRequestBody
|
||||
}
|
||||
authApproveOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
// AuthRegister registers a node via an auth id; it is an alias of RegisterNode.
|
||||
func (s *Server) AuthRegister(
|
||||
ctx context.Context,
|
||||
req *oas.AuthRegisterReq,
|
||||
) (*oas.AuthRegisterOK, error) {
|
||||
resp, err := s.RegisterNode(ctx, oas.RegisterNodeParams{
|
||||
Key: oas.NewOptString(req.AuthId.Or("")),
|
||||
User: oas.NewOptString(req.User.Or("")),
|
||||
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, err
|
||||
return nil, huma.Error400BadRequest("invalid auth_id", err)
|
||||
}
|
||||
|
||||
return &oas.AuthRegisterOK{Node: resp.Node}, nil
|
||||
}
|
||||
|
||||
// AuthApprove approves a pending auth session.
|
||||
func (s *Server) AuthApprove(_ context.Context, req *oas.AuthApproveReq) error {
|
||||
authReq, apiErr := s.pendingAuth(req.AuthId.Or(""))
|
||||
if apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
|
||||
authReq.FinishAuth(types.AuthVerdict{})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthReject rejects a pending auth session.
|
||||
func (s *Server) AuthReject(_ context.Context, req *oas.AuthRejectReq) error {
|
||||
authReq, apiErr := s.pendingAuth(req.AuthId.Or(""))
|
||||
if apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
|
||||
authReq.FinishAuth(types.AuthVerdict{Err: errAuthRejected})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pendingAuth resolves an auth id to its cached, in-progress auth request.
|
||||
// An unparseable id is a 400; an unknown one is a 404.
|
||||
func (s *Server) pendingAuth(authID string) (*types.AuthRequest, *oas.ErrorStatusCode) {
|
||||
id, err := types.AuthIDFromString(authID)
|
||||
if err != nil {
|
||||
return nil, badRequest("invalid auth_id: " + err.Error())
|
||||
}
|
||||
|
||||
authReq, ok := s.state.GetAuthCacheEntry(id)
|
||||
authReq, ok := b.State.GetAuthCacheEntry(authID)
|
||||
if !ok {
|
||||
return nil, notFound("no pending auth session for auth_id " + id.String())
|
||||
return nil, huma.Error404NotFound("no pending auth session for auth_id " + authID.String())
|
||||
}
|
||||
|
||||
return authReq, nil
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
oas "github.com/juanfont/headscale/gen/api/v1"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
// This file converts the state-layer types into the ogen API types. It reads
|
||||
// the copy-on-write view types (NodeView, UserView, PreAuthKeyView) directly so
|
||||
// no node/user is deep-copied on the read path, and reproduces the same fields
|
||||
// the previous API emitted — username fallback, masked key prefixes, online
|
||||
// computation, the register-method enum.
|
||||
//
|
||||
// These converters omit zero-value and absent fields — empty strings, false booleans, zero
|
||||
// numbers, empty arrays, nil timestamps/objects, and the unspecified register
|
||||
// method. See docs/v1-ogen/CHANGES.md.
|
||||
|
||||
func optString(s string) oas.OptString {
|
||||
if s == "" {
|
||||
return oas.OptString{}
|
||||
}
|
||||
|
||||
return oas.NewOptString(s)
|
||||
}
|
||||
|
||||
func optUint64(v uint64) oas.OptUint64 {
|
||||
if v == 0 {
|
||||
return oas.OptUint64{}
|
||||
}
|
||||
|
||||
return oas.NewOptUint64(v)
|
||||
}
|
||||
|
||||
func optBool(b bool) oas.OptBool {
|
||||
if !b {
|
||||
return oas.OptBool{}
|
||||
}
|
||||
|
||||
return oas.NewOptBool(b)
|
||||
}
|
||||
|
||||
// optTimeVal sets a timestamp from a value; the previous proto builders always
|
||||
// emitted these fields (timestamppb.New is never nil), so they are always set.
|
||||
func optTimeVal(t time.Time) oas.OptDateTime {
|
||||
return oas.NewOptDateTime(t)
|
||||
}
|
||||
|
||||
// optTimePtr sets a timestamp only when present, matching the proto builders'
|
||||
// nil checks.
|
||||
func optTimePtr(t *time.Time) oas.OptDateTime {
|
||||
if t == nil {
|
||||
return oas.OptDateTime{}
|
||||
}
|
||||
|
||||
return oas.NewOptDateTime(*t)
|
||||
}
|
||||
|
||||
// optTimeVP sets a timestamp from a view's optional pointer.
|
||||
func optTimeVP(p views.ValuePointer[time.Time]) oas.OptDateTime {
|
||||
if !p.Valid() {
|
||||
return oas.OptDateTime{}
|
||||
}
|
||||
|
||||
return oas.NewOptDateTime(p.Get())
|
||||
}
|
||||
|
||||
// strs normalises an empty slice to nil so it is omitted from the response
|
||||
// rather than emitted as an empty array.
|
||||
func strs(s []string) []string {
|
||||
if len(s) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func oasRegisterMethod(rm string) oas.OptRegisterMethod {
|
||||
var v oas.RegisterMethod
|
||||
|
||||
switch rm {
|
||||
case "authkey":
|
||||
v = "REGISTER_METHOD_AUTH_KEY"
|
||||
case "oidc":
|
||||
v = "REGISTER_METHOD_OIDC"
|
||||
case "cli":
|
||||
v = "REGISTER_METHOD_CLI"
|
||||
default:
|
||||
return oas.OptRegisterMethod{}
|
||||
}
|
||||
|
||||
return oas.NewOptRegisterMethod(v)
|
||||
}
|
||||
|
||||
func oasUser(u types.UserView) oas.User {
|
||||
// Use Name if set, otherwise the display-friendly Username() fallback.
|
||||
name := u.Name()
|
||||
if name == "" {
|
||||
name = u.Username()
|
||||
}
|
||||
|
||||
return oas.User{
|
||||
ID: optUint64(uint64(u.Model().ID)),
|
||||
Name: optString(name),
|
||||
CreatedAt: optTimeVal(u.Model().CreatedAt),
|
||||
DisplayName: optString(u.DisplayName()),
|
||||
Email: optString(u.Email()),
|
||||
ProviderId: optString(u.ProviderIdentifier().String),
|
||||
Provider: optString(u.Provider()),
|
||||
ProfilePicUrl: optString(u.ProfilePicURL()),
|
||||
}
|
||||
}
|
||||
|
||||
func optUser(u types.UserView) oas.OptUser {
|
||||
if !u.Valid() {
|
||||
return oas.OptUser{}
|
||||
}
|
||||
|
||||
return oas.NewOptUser(oasUser(u))
|
||||
}
|
||||
|
||||
func apiKeyMaskedPrefix(prefix string) string {
|
||||
if len(prefix) == types.NewAPIKeyPrefixLength {
|
||||
return "hskey-api-" + prefix + "-***"
|
||||
}
|
||||
|
||||
return prefix + "***"
|
||||
}
|
||||
|
||||
func oasAPIKey(k *types.APIKey) oas.ApiKey {
|
||||
return oas.ApiKey{
|
||||
ID: optUint64(k.ID),
|
||||
Prefix: optString(apiKeyMaskedPrefix(k.Prefix)),
|
||||
Expiration: optTimePtr(k.Expiration),
|
||||
CreatedAt: optTimePtr(k.CreatedAt),
|
||||
LastSeen: optTimePtr(k.LastSeen),
|
||||
}
|
||||
}
|
||||
|
||||
func preAuthKeyMaskedPrefix(prefix string) string {
|
||||
if prefix != "" {
|
||||
return "hskey-auth-" + prefix + "-***"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func oasPreAuthKey(k types.PreAuthKeyView) oas.PreAuthKey {
|
||||
out := oas.PreAuthKey{
|
||||
User: optUser(k.User()),
|
||||
ID: optUint64(k.ID()),
|
||||
Reusable: optBool(k.Reusable()),
|
||||
Ephemeral: optBool(k.Ephemeral()),
|
||||
Used: optBool(k.Used()),
|
||||
Expiration: optTimeVP(k.Expiration()),
|
||||
CreatedAt: optTimeVP(k.CreatedAt()),
|
||||
AclTags: strs(k.Tags().AsSlice()),
|
||||
}
|
||||
|
||||
// New keys show the masked prefix; legacy keys (with a plaintext key) show
|
||||
// the full key for backwards compatibility.
|
||||
if masked := preAuthKeyMaskedPrefix(k.Prefix()); masked != "" {
|
||||
out.Key = optString(masked)
|
||||
} else if k.Key() != "" {
|
||||
out.Key = optString(k.Key())
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// oasPreAuthKeyNew converts a freshly created pre-auth key, which exposes the
|
||||
// full secret key exactly once and has no view type.
|
||||
func oasPreAuthKeyNew(k *types.PreAuthKeyNew) oas.PreAuthKey {
|
||||
out := oas.PreAuthKey{
|
||||
ID: optUint64(k.ID),
|
||||
Key: optString(k.Key),
|
||||
Reusable: optBool(k.Reusable),
|
||||
Ephemeral: optBool(k.Ephemeral),
|
||||
Expiration: optTimePtr(k.Expiration),
|
||||
CreatedAt: optTimePtr(k.CreatedAt),
|
||||
AclTags: strs(k.Tags),
|
||||
}
|
||||
|
||||
if k.User != nil {
|
||||
out.User = oas.NewOptUser(oasUser(k.User.View()))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// oasNode converts a node view. user is the user to present (the node's own
|
||||
// user for most callers, or TaggedDevices for tagged nodes); subnetRoutes
|
||||
// carries the routes actively served from the node (primary + exit), which only
|
||||
// some callers populate.
|
||||
func oasNode(nv types.NodeView, user types.UserView, subnetRoutes []string) oas.Node {
|
||||
out := oas.Node{
|
||||
ID: optUint64(uint64(nv.ID())),
|
||||
MachineKey: optString(nv.MachineKey().String()),
|
||||
NodeKey: optString(nv.NodeKey().String()),
|
||||
DiscoKey: optString(nv.DiscoKey().String()),
|
||||
IpAddresses: strs(nv.IPsAsString()),
|
||||
Name: optString(nv.Hostname()),
|
||||
User: optUser(user),
|
||||
CreatedAt: optTimeVal(nv.CreatedAt()),
|
||||
RegisterMethod: oasRegisterMethod(nv.RegisterMethod()),
|
||||
GivenName: optString(nv.GivenName()),
|
||||
Online: optBool(nv.IsOnline().GetOr(false)),
|
||||
ApprovedRoutes: strs(util.PrefixesToString(nv.ApprovedRoutes().AsSlice())),
|
||||
AvailableRoutes: strs(util.PrefixesToString(nv.AnnouncedRoutes())),
|
||||
SubnetRoutes: strs(subnetRoutes),
|
||||
Tags: strs(nv.Tags().AsSlice()),
|
||||
LastSeen: optTimeVP(nv.LastSeen()),
|
||||
Expiry: optTimeVP(nv.Expiry()),
|
||||
}
|
||||
|
||||
if nv.AuthKey().Valid() {
|
||||
out.PreAuthKey = oas.NewOptPreAuthKey(oasPreAuthKey(nv.AuthKey()))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
+30
-136
@@ -1,155 +1,49 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
oas "github.com/juanfont/headscale/gen/api/v1"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/db"
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/ogen-go/ogen/ogenerrors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const problemContentType = "application/problem+json"
|
||||
|
||||
// apiError builds an RFC 7807 problem response with the given HTTP status and
|
||||
// detail. Handlers return it so expected errors render as problem documents
|
||||
// with the correct status code (ogen encodes [oas.ErrorStatusCode] directly,
|
||||
// without going through [errorHandler]).
|
||||
func apiError(status int, detail string) *oas.ErrorStatusCode {
|
||||
return &oas.ErrorStatusCode{
|
||||
StatusCode: status,
|
||||
Response: oas.Problem{
|
||||
Title: oas.NewOptString(http.StatusText(status)),
|
||||
//nolint:gosec // G115: status is an HTTP status code, always within int32.
|
||||
Status: oas.NewOptInt32(int32(status)),
|
||||
Detail: oas.NewOptString(detail),
|
||||
},
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
func notFound(
|
||||
detail string,
|
||||
) *oas.ErrorStatusCode {
|
||||
return apiError(http.StatusNotFound, detail)
|
||||
}
|
||||
|
||||
func badRequest(
|
||||
detail string,
|
||||
) *oas.ErrorStatusCode {
|
||||
return apiError(http.StatusBadRequest, detail)
|
||||
}
|
||||
|
||||
func internalError(detail string) *oas.ErrorStatusCode {
|
||||
return apiError(http.StatusInternalServerError, detail)
|
||||
}
|
||||
|
||||
// mapStateError classifies an error returned by the state layer into an HTTP
|
||||
// problem response. Not-found sentinels become 404; everything else is a 500.
|
||||
// Handlers that need a different status (e.g. validation 400) build the problem
|
||||
// explicitly with [badRequest] rather than routing through here.
|
||||
func mapStateError(err error) *oas.ErrorStatusCode {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound),
|
||||
errors.Is(err, state.ErrNodeNotFound),
|
||||
errors.Is(err, state.ErrNodeNotInNodeStore),
|
||||
errors.Is(err, db.ErrUserNotFound):
|
||||
return notFound(err.Error())
|
||||
case errors.Is(err, types.ErrPolicyUpdateIsDisabled):
|
||||
return badRequest(err.Error())
|
||||
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 internalError(err.Error())
|
||||
return huma.Error500InternalServerError(msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
// NewError converts an error that ogen raises outside a handler return value —
|
||||
// failed authentication, request decoding — into a typed problem response. ogen
|
||||
// calls it from the generated security/decoding paths; the resulting
|
||||
// [oas.ErrorStatusCode] is then encoded as application/problem+json.
|
||||
func (s *Server) NewError(_ context.Context, err error) *oas.ErrorStatusCode {
|
||||
return classify(err)
|
||||
}
|
||||
|
||||
// errorHandler renders problems for the remaining ogen error path: a plain
|
||||
// error returned from a handler. Expected errors are returned as
|
||||
// [oas.ErrorStatusCode] (encoded directly by ogen), so this is the safety net
|
||||
// for anything else.
|
||||
func errorHandler(
|
||||
_ context.Context,
|
||||
w http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
err error,
|
||||
) {
|
||||
writeProblem(w, classify(err))
|
||||
}
|
||||
|
||||
// classify maps an arbitrary error to a problem response. An already-typed
|
||||
// [oas.ErrorStatusCode] passes through; ogen's framework errors carry their own
|
||||
// HTTP status via Code() (security -> 401, decode -> 400); everything else is
|
||||
// classified by [mapStateError].
|
||||
func classify(err error) *oas.ErrorStatusCode {
|
||||
var esc *oas.ErrorStatusCode
|
||||
if errors.As(err, &esc) {
|
||||
return esc
|
||||
}
|
||||
|
||||
// A failed security requirement (missing/malformed bearer) must not echo
|
||||
// ogen's internal "operation X: security ...: not satisfied" message, which
|
||||
// leaks the operation name. Return a minimal 401.
|
||||
var secErr *ogenerrors.SecurityError
|
||||
if errors.As(err, &secErr) {
|
||||
return apiError(http.StatusUnauthorized, "valid API key required")
|
||||
}
|
||||
|
||||
var coder interface{ Code() int }
|
||||
if errors.As(err, &coder) {
|
||||
return apiError(coder.Code(), err.Error())
|
||||
}
|
||||
|
||||
return mapStateError(err)
|
||||
}
|
||||
|
||||
func writeProblem(w http.ResponseWriter, esc *oas.ErrorStatusCode) {
|
||||
w.Header().Set("Content-Type", problemContentType)
|
||||
w.WriteHeader(esc.StatusCode)
|
||||
|
||||
p := esc.Response
|
||||
|
||||
status := esc.StatusCode
|
||||
if v, ok := p.Status.Get(); ok {
|
||||
status = int(v)
|
||||
}
|
||||
|
||||
body := problemJSON{
|
||||
Title: p.Title.Or(""),
|
||||
Status: status,
|
||||
Detail: p.Detail.Or(""),
|
||||
}
|
||||
if v, ok := p.Type.Get(); ok {
|
||||
body.Type = v.String()
|
||||
}
|
||||
|
||||
if v, ok := p.Instance.Get(); ok {
|
||||
body.Instance = v.String()
|
||||
}
|
||||
|
||||
err := json.NewEncoder(w).Encode(body)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("writing problem response failed")
|
||||
}
|
||||
}
|
||||
|
||||
// problemJSON mirrors [oas.Problem] for hand-written encoding in
|
||||
// [errorHandler]; ogen's own encoder is package-private.
|
||||
type problemJSON struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ogen-go/ogen/ogenerrors"
|
||||
)
|
||||
|
||||
var errSecurityNotSatisfied = errors.New(
|
||||
`operation ListUsers: security "": security requirement is not satisfied`,
|
||||
)
|
||||
|
||||
// TestClassifySecurityErrorIsMinimal ensures a failed security requirement
|
||||
// becomes a clean 401 that does not leak ogen's internal operation/security
|
||||
// message.
|
||||
func TestClassifySecurityErrorIsMinimal(t *testing.T) {
|
||||
secErr := &ogenerrors.SecurityError{Err: errSecurityNotSatisfied}
|
||||
|
||||
esc := classify(secErr)
|
||||
|
||||
if esc.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", esc.StatusCode)
|
||||
}
|
||||
|
||||
detail := esc.Response.Detail.Or("")
|
||||
if strings.Contains(detail, "operation") || strings.Contains(detail, "ListUsers") {
|
||||
t.Errorf("401 detail leaks internals: %q", detail)
|
||||
}
|
||||
}
|
||||
+33
-11
@@ -2,18 +2,40 @@ package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
oas "github.com/juanfont/headscale/gen/api/v1"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// Health reports server health, including database connectivity. A failed
|
||||
// database ping is a 500; the previous implementation likewise returned the ping
|
||||
// error (the body's databaseConnectivity flag was never observable on failure).
|
||||
func (s *Server) Health(ctx context.Context) (*oas.HealthOK, error) {
|
||||
err := s.state.PingDB(ctx)
|
||||
if err != nil {
|
||||
return nil, internalError("pinging database: " + err.Error())
|
||||
}
|
||||
|
||||
return &oas.HealthOK{DatabaseConnectivity: oas.NewOptBool(true)}, nil
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
+678
-289
@@ -1,313 +1,702 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
oas "github.com/juanfont/headscale/gen/api/v1"
|
||||
"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"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
// RegisterNode registers a node to a user using a registration id, then
|
||||
// auto-approves its routes.
|
||||
func (s *Server) RegisterNode(
|
||||
_ context.Context,
|
||||
params oas.RegisterNodeParams,
|
||||
) (*oas.RegisterNodeOK, error) {
|
||||
registrationID, err := types.AuthIDFromString(params.Key.Or(""))
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error())
|
||||
}
|
||||
|
||||
user, err := s.state.GetUserByName(params.User.Or(""))
|
||||
if err != nil {
|
||||
return nil, mapStateError(fmt.Errorf("looking up user: %w", err))
|
||||
}
|
||||
|
||||
node, nodeChange, err := s.state.HandleNodeFromAuthPath(
|
||||
registrationID,
|
||||
types.UserID(user.ID),
|
||||
nil,
|
||||
util.RegisterMethodCLI,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
routeChange, err := s.state.AutoApproveRoutes(node)
|
||||
if err != nil {
|
||||
return nil, internalError("auto approving routes: " + err.Error())
|
||||
}
|
||||
|
||||
s.change(nodeChange, routeChange)
|
||||
|
||||
return &oas.RegisterNodeOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil
|
||||
func init() {
|
||||
registrations = append(registrations, registerNodes)
|
||||
}
|
||||
|
||||
// GetNode returns a node by id.
|
||||
func (s *Server) GetNode(_ context.Context, params oas.GetNodeParams) (*oas.GetNodeOK, error) {
|
||||
node, ok := s.state.GetNodeByID(types.NodeID(params.NodeID))
|
||||
if !ok {
|
||||
return nil, notFound("node not found")
|
||||
}
|
||||
// errBackfillNotConfirmed guards BackfillNodeIPs behind explicit confirmed=true.
|
||||
var errBackfillNotConfirmed = errors.New("not confirmed, aborting")
|
||||
|
||||
return &oas.GetNodeOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil
|
||||
// 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",
|
||||
}
|
||||
|
||||
// SetTags sets the ACL tags of a node, converting it to a tagged node.
|
||||
func (s *Server) SetTags(
|
||||
_ context.Context,
|
||||
req *oas.SetTagsReq,
|
||||
params oas.SetTagsParams,
|
||||
) (*oas.SetTagsOK, error) {
|
||||
if len(req.Tags) == 0 {
|
||||
return nil, badRequest(
|
||||
"cannot remove all tags from a node - tagged nodes must have at least one tag",
|
||||
// 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(),
|
||||
)
|
||||
}
|
||||
|
||||
for _, tag := range req.Tags {
|
||||
err := validateTag(tag)
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
_, found := s.state.GetNodeByID(types.NodeID(params.NodeID))
|
||||
if !found {
|
||||
return nil, notFound("node not found")
|
||||
}
|
||||
|
||||
node, nodeChange, err := s.state.SetNodeTags(types.NodeID(params.NodeID), req.Tags)
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error())
|
||||
}
|
||||
|
||||
s.change(nodeChange)
|
||||
|
||||
return &oas.SetTagsOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil
|
||||
}
|
||||
|
||||
// SetApprovedRoutes sets the approved subnet routes of a node, expanding exit
|
||||
// routes to cover both address families.
|
||||
func (s *Server) SetApprovedRoutes(
|
||||
_ context.Context,
|
||||
req *oas.SetApprovedRoutesReq,
|
||||
params oas.SetApprovedRoutesParams,
|
||||
) (*oas.SetApprovedRoutesOK, error) {
|
||||
var newApproved []netip.Prefix
|
||||
|
||||
for _, route := range req.Routes {
|
||||
prefix, err := netip.ParsePrefix(route)
|
||||
if err != nil {
|
||||
return nil, badRequest("parsing route: " + err.Error())
|
||||
}
|
||||
|
||||
// An exit route is annotated by both v4 and v6 default routes.
|
||||
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 := s.state.SetApprovedRoutes(types.NodeID(params.NodeID), newApproved)
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error())
|
||||
}
|
||||
|
||||
s.change(nodeChange)
|
||||
|
||||
// SubnetRoutes carries only the routes actively served from the node.
|
||||
subnetRoutes := util.PrefixesToString(s.state.GetNodePrimaryRoutes(node.ID()))
|
||||
|
||||
return &oas.SetApprovedRoutesOK{
|
||||
Node: oas.NewOptNode(oasNode(node, node.User(), subnetRoutes)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteNode deletes a node.
|
||||
func (s *Server) DeleteNode(_ context.Context, params oas.DeleteNodeParams) error {
|
||||
node, ok := s.state.GetNodeByID(types.NodeID(params.NodeID))
|
||||
if !ok {
|
||||
return notFound("node not found")
|
||||
}
|
||||
|
||||
nodeChange, err := s.state.DeleteNode(node)
|
||||
if err != nil {
|
||||
return mapStateError(err)
|
||||
}
|
||||
|
||||
s.change(nodeChange)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpireNode expires a node, or disables its expiry.
|
||||
func (s *Server) ExpireNode(
|
||||
_ context.Context,
|
||||
params oas.ExpireNodeParams,
|
||||
) (*oas.ExpireNodeOK, error) {
|
||||
_, hasExpiry := params.Expiry.Get()
|
||||
if params.DisableExpiry.Or(false) && hasExpiry {
|
||||
return nil, badRequest("cannot set both disable_expiry and expiry")
|
||||
}
|
||||
|
||||
var expiry *time.Time
|
||||
|
||||
if !params.DisableExpiry.Or(false) {
|
||||
t := time.Now()
|
||||
if v, ok := params.Expiry.Get(); ok {
|
||||
t = v
|
||||
}
|
||||
|
||||
expiry = &t
|
||||
}
|
||||
|
||||
node, nodeChange, err := s.state.SetNodeExpiry(types.NodeID(params.NodeID), expiry)
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
s.change(nodeChange)
|
||||
|
||||
return &oas.ExpireNodeOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil
|
||||
}
|
||||
|
||||
// RenameNode renames a node.
|
||||
func (s *Server) RenameNode(
|
||||
_ context.Context,
|
||||
params oas.RenameNodeParams,
|
||||
) (*oas.RenameNodeOK, error) {
|
||||
node, nodeChange, err := s.state.RenameNode(types.NodeID(params.NodeID), params.NewName)
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
s.change(nodeChange)
|
||||
|
||||
return &oas.RenameNodeOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil
|
||||
}
|
||||
|
||||
// ListNodes lists nodes, optionally filtered by user, sorted by id.
|
||||
func (s *Server) ListNodes(
|
||||
_ context.Context,
|
||||
params oas.ListNodesParams,
|
||||
) (*oas.ListNodesOK, error) {
|
||||
var nodes views.Slice[types.NodeView]
|
||||
|
||||
if params.User.Or("") != "" {
|
||||
user, err := s.state.GetUserByName(params.User.Or(""))
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
nodes = s.state.ListNodesByUser(types.UserID(user.ID))
|
||||
} else {
|
||||
nodes = s.state.ListNodes()
|
||||
}
|
||||
|
||||
return &oas.ListNodesOK{Nodes: s.nodesToOAS(nodes)}, nil
|
||||
}
|
||||
|
||||
// nodesToOAS converts a slice of node views to API nodes, presenting tagged
|
||||
// nodes as the TaggedDevices user and populating SubnetRoutes with the routes
|
||||
// actively served from each node.
|
||||
func (s *Server) nodesToOAS(nodes views.Slice[types.NodeView]) []oas.Node {
|
||||
out := make([]oas.Node, nodes.Len())
|
||||
|
||||
for index, node := range nodes.All() {
|
||||
// Tagged nodes are presented as the TaggedDevices user.
|
||||
user := node.User()
|
||||
if node.IsTagged() {
|
||||
user = types.TaggedDevices.View()
|
||||
}
|
||||
|
||||
subnetRoutes := util.PrefixesToString(
|
||||
append(s.state.GetNodePrimaryRoutes(node.ID()), node.ExitRoutes()...),
|
||||
)
|
||||
|
||||
out[index] = oasNode(node, user, subnetRoutes)
|
||||
}
|
||||
|
||||
slices.SortFunc(out, func(a, b oas.Node) int { return cmp.Compare(a.ID.Or(0), b.ID.Or(0)) })
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// BackfillNodeIPs backfills missing IP addresses for all nodes. It must be
|
||||
// explicitly confirmed.
|
||||
func (s *Server) BackfillNodeIPs(
|
||||
_ context.Context,
|
||||
params oas.BackfillNodeIPsParams,
|
||||
) (*oas.BackfillNodeIPsOK, error) {
|
||||
if !params.Confirmed.Or(false) {
|
||||
return nil, badRequest("not confirmed, aborting")
|
||||
}
|
||||
|
||||
changes, err := s.state.BackfillNodeIPs()
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
return &oas.BackfillNodeIPsOK{Changes: changes}, nil
|
||||
}
|
||||
|
||||
// DebugCreateNode caches a synthetic node registration for testing and echoes
|
||||
// back a node describing it. The real node is created later via AuthApprove.
|
||||
func (s *Server) DebugCreateNode(
|
||||
_ context.Context,
|
||||
req *oas.DebugCreateNodeReq,
|
||||
) (*oas.DebugCreateNodeOK, error) {
|
||||
user, err := s.state.GetUserByName(req.User.Or(""))
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
routes, err := util.StringToIPPrefix(req.Routes)
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error())
|
||||
}
|
||||
|
||||
registrationID, err := types.AuthIDFromString(req.Key.Or(""))
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error())
|
||||
}
|
||||
|
||||
regData := &types.RegistrationData{
|
||||
NodeKey: key.NewNode().Public(),
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
Hostname: req.Name.Or(""),
|
||||
Expiry: &time.Time{},
|
||||
}
|
||||
|
||||
s.state.SetAuthCacheEntry(registrationID, types.NewRegisterAuthRequest(regData))
|
||||
|
||||
echoNode := types.Node{
|
||||
NodeKey: regData.NodeKey,
|
||||
MachineKey: regData.MachineKey,
|
||||
Hostname: regData.Hostname,
|
||||
User: user,
|
||||
Expiry: &time.Time{},
|
||||
LastSeen: &time.Time{},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: req.Name.Or(""),
|
||||
OS: "TestOS",
|
||||
RoutableIPs: routes,
|
||||
},
|
||||
}
|
||||
|
||||
return &oas.DebugCreateNodeOK{Node: oas.NewOptNode(oasNode(echoNode.View(), echoNode.View().User(), nil))}, nil
|
||||
return types.NodeID(id), nil
|
||||
}
|
||||
|
||||
+168
-89
@@ -2,111 +2,190 @@ package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
oas "github.com/juanfont/headscale/gen/api/v1"
|
||||
"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"
|
||||
)
|
||||
|
||||
// GetPolicy returns the current ACL policy, from the database or the policy
|
||||
// file depending on the configured policy mode.
|
||||
func (s *Server) GetPolicy(_ context.Context) (*oas.GetPolicyOK, error) {
|
||||
switch s.cfg.Policy.Mode {
|
||||
case types.PolicyModeDB:
|
||||
p, err := s.state.GetPolicy()
|
||||
if err != nil {
|
||||
return nil, internalError("loading ACL from database: " + err.Error())
|
||||
}
|
||||
|
||||
return &oas.GetPolicyOK{
|
||||
Policy: oas.NewOptString(p.Data),
|
||||
UpdatedAt: oas.NewOptDateTime(p.UpdatedAt),
|
||||
}, nil
|
||||
case types.PolicyModeFile:
|
||||
absPath := util.AbsolutePathFromConfigPath(s.cfg.Policy.Path)
|
||||
|
||||
b, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return nil, internalError("reading policy from path " + absPath + ": " + err.Error())
|
||||
}
|
||||
|
||||
return &oas.GetPolicyOK{Policy: oas.NewOptString(string(b))}, nil
|
||||
}
|
||||
|
||||
return nil, internalError(
|
||||
"no supported policy mode found in configuration, policy.mode: " +
|
||||
string(s.cfg.Policy.Mode),
|
||||
)
|
||||
func init() {
|
||||
registrations = append(registrations, registerPolicy)
|
||||
}
|
||||
|
||||
// SetPolicy stores a new ACL policy (database policy mode only), validating it
|
||||
// against the live nodes and distributing the resulting changes.
|
||||
func (s *Server) SetPolicy(_ context.Context, req *oas.SetPolicyReq) (*oas.SetPolicyOK, error) {
|
||||
if s.cfg.Policy.Mode != types.PolicyModeDB {
|
||||
return nil, badRequest(types.ErrPolicyUpdateIsDisabled.Error())
|
||||
// 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
|
||||
}
|
||||
|
||||
p := req.Policy.Or("")
|
||||
|
||||
// Validate against live nodes, where they exist, before storing.
|
||||
nodes := s.state.ListNodes()
|
||||
|
||||
_, err := s.state.SetPolicy([]byte(p))
|
||||
if err != nil {
|
||||
return nil, badRequest("setting policy: " + err.Error())
|
||||
setPolicyInput struct {
|
||||
Body PolicyRequestBody
|
||||
}
|
||||
setPolicyOutput struct {
|
||||
Body PolicyResponseBody
|
||||
}
|
||||
|
||||
if nodes.Len() > 0 {
|
||||
_, err = s.state.SSHPolicy(nodes.At(0))
|
||||
if err != nil {
|
||||
return nil, badRequest("verifying SSH rules: " + err.Error())
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := s.state.SetPolicyInDB(p)
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
return nil, huma.Error500InternalServerError(fmt.Sprintf(
|
||||
"no supported policy mode found in configuration, policy.mode: %q",
|
||||
b.Cfg.Policy.Mode,
|
||||
), nil)
|
||||
})
|
||||
|
||||
// Always reload so routes are re-evaluated even when the content is unchanged.
|
||||
cs, err := s.state.ReloadPolicy()
|
||||
if err != nil {
|
||||
return nil, internalError("reloading policy: " + err.Error())
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
if len(cs) > 0 {
|
||||
s.change(cs...)
|
||||
}
|
||||
p := in.Body.Policy
|
||||
|
||||
return &oas.SetPolicyOK{
|
||||
Policy: oas.NewOptString(updated.Data),
|
||||
UpdatedAt: oas.NewOptDateTime(updated.UpdatedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CheckPolicy validates a policy against the live users and nodes without
|
||||
// storing it. Works regardless of policy mode.
|
||||
func (s *Server) CheckPolicy(_ context.Context, req *oas.CheckPolicyReq) error {
|
||||
polB := []byte(req.Policy.Or(""))
|
||||
|
||||
users, err := s.state.ListAllUsers()
|
||||
if err != nil {
|
||||
return internalError("loading users: " + err.Error())
|
||||
}
|
||||
|
||||
nodes := s.state.ListNodes()
|
||||
|
||||
pm, err := policyv2.NewPolicyManager(polB, users, nodes)
|
||||
if err != nil {
|
||||
return badRequest(err.Error())
|
||||
}
|
||||
|
||||
_, err = pm.SetPolicy(polB)
|
||||
if err != nil {
|
||||
return badRequest(err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
+296
-95
@@ -3,115 +3,316 @@ package apiv1
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
oas "github.com/juanfont/headscale/gen/api/v1"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
)
|
||||
|
||||
// CreatePreAuthKey creates a pre-auth key for a user.
|
||||
func (s *Server) CreatePreAuthKey(
|
||||
_ context.Context,
|
||||
req *oas.CreatePreAuthKeyReq,
|
||||
) (*oas.CreatePreAuthKeyOK, error) {
|
||||
var expiration time.Time
|
||||
if v, ok := req.Expiration.Get(); ok {
|
||||
expiration = v
|
||||
}
|
||||
func init() {
|
||||
registrations = append(registrations, registerPreAuthKeys)
|
||||
}
|
||||
|
||||
for _, tag := range req.AclTags {
|
||||
err := validateTag(tag)
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error())
|
||||
// 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"`
|
||||
}
|
||||
}
|
||||
|
||||
var userID *types.UserID
|
||||
|
||||
if req.User.Or(0) != 0 {
|
||||
user, err := s.state.GetUserByID(types.UserID(req.User.Or(0)))
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
userID = user.TypedID()
|
||||
}
|
||||
|
||||
preAuthKey, err := s.state.CreatePreAuthKey(
|
||||
userID,
|
||||
req.Reusable.Or(false),
|
||||
req.Ephemeral.Or(false),
|
||||
&expiration,
|
||||
req.AclTags,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
return &oas.CreatePreAuthKeyOK{
|
||||
PreAuthKey: oas.NewOptPreAuthKey(oasPreAuthKeyNew(preAuthKey)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListPreAuthKeys lists all pre-auth keys, sorted by id.
|
||||
func (s *Server) ListPreAuthKeys(_ context.Context) (*oas.ListPreAuthKeysOK, error) {
|
||||
keys, err := s.state.ListPreAuthKeys()
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
slices.SortFunc(keys, func(a, b types.PreAuthKey) int { return cmp.Compare(a.ID, b.ID) })
|
||||
|
||||
out := make([]oas.PreAuthKey, len(keys))
|
||||
for i := range keys {
|
||||
out[i] = oasPreAuthKey(keys[i].View())
|
||||
}
|
||||
|
||||
return &oas.ListPreAuthKeysOK{PreAuthKeys: out}, nil
|
||||
}
|
||||
|
||||
// ExpirePreAuthKey expires a pre-auth key.
|
||||
func (s *Server) ExpirePreAuthKey(_ context.Context, req *oas.ExpirePreAuthKeyReq) error {
|
||||
err := s.state.ExpirePreAuthKey(req.ID.Or(0))
|
||||
if err != nil {
|
||||
return mapStateError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePreAuthKey deletes a pre-auth key.
|
||||
func (s *Server) DeletePreAuthKey(_ context.Context, params oas.DeletePreAuthKeyParams) error {
|
||||
err := s.state.DeletePreAuthKey(params.ID.Or(0))
|
||||
if err != nil {
|
||||
return mapStateError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
errTagPrefix = errors.New("tag must start with the string 'tag:'")
|
||||
errTagLowercase = errors.New("tag should be lowercase")
|
||||
errTagSpaces = errors.New("tags must not contain spaces")
|
||||
)
|
||||
|
||||
// validateTag enforces the ACL tag format ("tag:" prefix, lowercase, no spaces).
|
||||
func validateTag(tag string) error {
|
||||
if !strings.HasPrefix(tag, "tag:") {
|
||||
return errTagPrefix
|
||||
type (
|
||||
expirePreAuthKeyInput struct {
|
||||
Body ExpirePreAuthKeyRequestBody
|
||||
}
|
||||
|
||||
if strings.ToLower(tag) != tag {
|
||||
return errTagLowercase
|
||||
expirePreAuthKeyOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
if len(strings.Fields(tag)) > 1 {
|
||||
return errTagSpaces
|
||||
type (
|
||||
deletePreAuthKeyInput struct {
|
||||
ID string `format:"uint64" query:"id"`
|
||||
}
|
||||
deletePreAuthKeyOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
return nil
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// Package apiv1 implements the Headscale v1 HTTP API: thin handlers that
|
||||
// adapt the ogen-generated server interface ([oas.Handler]) onto the shared
|
||||
// state layer ([state.State]). Business logic lives in the state layer; these
|
||||
// handlers only translate between HTTP request/response types and state calls.
|
||||
//
|
||||
// The package deliberately does not import the parent hscontrol package: it
|
||||
// depends only on state, types, and the generated API package, so that
|
||||
// hscontrol can mount it without an import cycle.
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
oas "github.com/juanfont/headscale/gen/api/v1"
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
)
|
||||
|
||||
// Server implements the generated [oas.Handler] and [oas.SecurityHandler].
|
||||
//
|
||||
// Any operation not implemented here is inherited from
|
||||
// [oas.UnimplementedHandler] and returns 501; every operation is implemented.
|
||||
type Server struct {
|
||||
oas.UnimplementedHandler
|
||||
|
||||
state *state.State
|
||||
cfg *types.Config
|
||||
change changeFunc
|
||||
}
|
||||
|
||||
// changeFunc distributes state changes to connected nodes. In production this
|
||||
// is [github.com/juanfont/headscale/hscontrol.Headscale.Change]; tests may pass
|
||||
// a no-op or a recorder.
|
||||
type changeFunc func(...change.Change)
|
||||
|
||||
// NewHandler builds the v1 API as an [http.Handler] ready to mount at
|
||||
// /api/v1. changeFn distributes [change.Change]s produced by mutating
|
||||
// operations; it must not be nil (pass a no-op if changes are irrelevant).
|
||||
func NewHandler(
|
||||
st *state.State,
|
||||
cfg *types.Config,
|
||||
changeFn func(...change.Change),
|
||||
) (http.Handler, error) {
|
||||
s := &Server{
|
||||
state: st,
|
||||
cfg: cfg,
|
||||
change: changeFn,
|
||||
}
|
||||
|
||||
return oas.NewServer(s, s, oas.WithErrorHandler(errorHandler))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
+210
-89
@@ -3,110 +3,231 @@ package apiv1
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
oas "github.com/juanfont/headscale/gen/api/v1"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CreateUser creates a user and distributes the resulting policy change.
|
||||
func (s *Server) CreateUser(
|
||||
_ context.Context,
|
||||
req *oas.CreateUserReq,
|
||||
) (*oas.CreateUserOK, error) {
|
||||
newUser := types.User{
|
||||
Name: req.Name.Or(""),
|
||||
DisplayName: req.DisplayName.Or(""),
|
||||
Email: req.Email.Or(""),
|
||||
ProfilePicURL: req.PictureUrl.Or(""),
|
||||
}
|
||||
|
||||
user, policyChanged, err := s.state.CreateUser(newUser)
|
||||
if err != nil {
|
||||
return nil, internalError("creating user: " + err.Error())
|
||||
}
|
||||
|
||||
s.change(policyChanged)
|
||||
|
||||
return &oas.CreateUserOK{User: oas.NewOptUser(oasUser(user.View()))}, nil
|
||||
func init() {
|
||||
registrations = append(registrations, registerUsers)
|
||||
}
|
||||
|
||||
// ListUsers lists users, optionally filtered by id, name, or email, sorted by id.
|
||||
func (s *Server) ListUsers(
|
||||
_ context.Context,
|
||||
params oas.ListUsersParams,
|
||||
) (*oas.ListUsersOK, error) {
|
||||
var (
|
||||
users []types.User
|
||||
err error
|
||||
)
|
||||
// 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 params.Name.Or("") != "":
|
||||
users, err = s.state.ListUsersWithFilter(&types.User{Name: params.Name.Or("")})
|
||||
case params.Email.Or("") != "":
|
||||
users, err = s.state.ListUsersWithFilter(&types.User{Email: params.Email.Or("")})
|
||||
case params.ID.Or(0) != 0:
|
||||
users, err = s.state.ListUsersWithFilter(
|
||||
&types.User{Model: gorm.Model{ID: uint(params.ID.Or(0))}},
|
||||
)
|
||||
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:
|
||||
users, err = s.state.ListAllUsers()
|
||||
return b.State.ListAllUsers()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
slices.SortFunc(users, func(a, b types.User) int { return cmp.Compare(a.ID, b.ID) })
|
||||
|
||||
out := make([]oas.User, len(users))
|
||||
for i := range users {
|
||||
out[i] = oasUser(users[i].View())
|
||||
}
|
||||
|
||||
return &oas.ListUsersOK{Users: out}, nil
|
||||
}
|
||||
|
||||
// RenameUser renames a user and distributes the resulting policy change.
|
||||
func (s *Server) RenameUser(
|
||||
_ context.Context,
|
||||
params oas.RenameUserParams,
|
||||
) (*oas.RenameUserOK, error) {
|
||||
oldUser, err := s.state.GetUserByID(types.UserID(params.OldID))
|
||||
func parseUserID(s string) (types.UserID, error) {
|
||||
id, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
return 0, huma.Error400BadRequest("invalid user id", err)
|
||||
}
|
||||
|
||||
_, c, err := s.state.RenameUser(types.UserID(oldUser.ID), params.NewName)
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
s.change(c)
|
||||
|
||||
newUser, err := s.state.GetUserByName(params.NewName)
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
return &oas.RenameUserOK{User: oas.NewOptUser(oasUser(newUser.View()))}, nil
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user and distributes the resulting policy change.
|
||||
func (s *Server) DeleteUser(_ context.Context, params oas.DeleteUserParams) error {
|
||||
user, err := s.state.GetUserByID(types.UserID(params.ID))
|
||||
if err != nil {
|
||||
return mapStateError(err)
|
||||
}
|
||||
|
||||
policyChanged, err := s.state.DeleteUser(types.UserID(user.ID))
|
||||
if err != nil {
|
||||
return mapStateError(err)
|
||||
}
|
||||
|
||||
s.change(policyChanged)
|
||||
|
||||
return nil
|
||||
return types.UserID(id), nil
|
||||
}
|
||||
|
||||
@@ -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-<clientID>-<secret>`, 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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user