Compare commits
101 Commits
dependabot
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72fcb93ef3 | ||
|
|
f5c779626a | ||
|
|
d227b3a135 | ||
|
|
0bcfdc29ad | ||
|
|
87c230d251 | ||
|
|
84c092a9f9 | ||
|
|
9146140217 | ||
|
|
5103b35f3c | ||
|
|
7be20912f5 | ||
|
|
e8753619de | ||
|
|
251e16d772 | ||
|
|
3f0bfe28cc | ||
|
|
82d4275c3b | ||
|
|
f3767dddf8 | ||
|
|
5c6cd62df1 | ||
|
|
56bec66a44 | ||
|
|
f0e464dc36 | ||
|
|
2c3c943acf | ||
|
|
a50bd13930 | ||
|
|
5655ef86d7 | ||
|
|
21ba197d06 | ||
|
|
9d77207ed8 | ||
|
|
cf1ad47b42 | ||
|
|
a288f04a1a | ||
|
|
5767ca5085 | ||
|
|
f67ed36fe2 | ||
|
|
506bd8c8eb | ||
|
|
daf9f36c78 | ||
|
|
616c0e895d | ||
|
|
c4600346f9 | ||
|
|
642073f4b8 | ||
|
|
87bd67318b | ||
|
|
0e1673041c | ||
|
|
f3f2d30004 | ||
|
|
c8376e44a2 | ||
|
|
5d0a6ab0e9 | ||
|
|
22ee2bfc9c | ||
|
|
1f5df017a1 | ||
|
|
bba91a89be | ||
|
|
6359511a62 | ||
|
|
d2fcd5b95b | ||
|
|
15c84b34e0 | ||
|
|
eb788cd007 | ||
|
|
705b239677 | ||
|
|
cb4d5b1906 | ||
|
|
0078eb7790 | ||
|
|
3cf2d7195a | ||
|
|
16d811b306 | ||
|
|
eec196d200 | ||
|
|
bfcd9d261d | ||
|
|
f00c412cde | ||
|
|
2010805712 | ||
|
|
c5133ee5d3 | ||
|
|
9c33cbfdc8 | ||
|
|
9b327f6b56 | ||
|
|
9368fee1c5 | ||
|
|
ed78bf4b98 | ||
|
|
db293e0698 | ||
|
|
9c4c017eac | ||
|
|
14af9b3ab1 | ||
|
|
72d5fd04a7 | ||
|
|
e86d063056 | ||
|
|
e0c9e18e22 | ||
|
|
21af106f68 | ||
|
|
7fb0f9a501 | ||
|
|
4b25976288 | ||
|
|
1c146f70e9 | ||
|
|
249630bed8 | ||
|
|
75247f82b8 | ||
|
|
665cc44094 | ||
|
|
8394e7094a | ||
|
|
da9018a0eb | ||
|
|
e3ced80278 | ||
|
|
09c9762fe0 | ||
|
|
75e24de7bd | ||
|
|
2aa5b8b68d | ||
|
|
4e77e910c5 | ||
|
|
a496864762 | ||
|
|
3ed1067a95 | ||
|
|
285c4e46a9 | ||
|
|
89285c317b | ||
|
|
d14be8d43b | ||
|
|
000d5c3b0c | ||
|
|
218a8db1b9 | ||
|
|
1dcb04ce9b | ||
|
|
299cef4e99 | ||
|
|
6d24afba1c | ||
|
|
f658a8eacd | ||
|
|
785168a7b8 | ||
|
|
3bd4ecd9cd | ||
|
|
3455d1cb59 | ||
|
|
ddd31ba774 | ||
|
|
4a8dc2d445 | ||
|
|
773a46a968 | ||
|
|
4728a2ba9e | ||
|
|
abed534628 | ||
|
|
21e3f2598d | ||
|
|
a28d9bed6d | ||
|
|
28faf8cd71 | ||
|
|
5a2ee0c391 | ||
|
|
5cd15c3656 |
@@ -52,7 +52,7 @@ go test ./integration -timeout 45m
|
||||
**Timeout Guidelines by Test Type**:
|
||||
- **Basic functionality tests**: `--timeout=900s` (15 minutes minimum)
|
||||
- **Route/ACL tests**: `--timeout=1200s` (20 minutes)
|
||||
- **HA/failover tests**: `--timeout=1800s` (30 minutes)
|
||||
- **HA/failover tests**: `--timeout=1800s` (30 minutes)
|
||||
- **Long-running tests**: `--timeout=2100s` (35 minutes)
|
||||
- **Full test suite**: `-timeout 45m` (45 minutes)
|
||||
|
||||
@@ -71,7 +71,7 @@ go run ./cmd/hi run "TestName" --timeout=60s
|
||||
- **Slow tests** (5+ min): Node expiration, HA failover
|
||||
- **Long-running tests** (10+ min): `TestNodeOnlineStatus` runs for 12 minutes
|
||||
|
||||
**CRITICAL**: Only ONE test can run at a time due to Docker port conflicts and resource constraints.
|
||||
**CONCURRENT EXECUTION**: Multiple tests CAN run simultaneously. Each test run gets a unique Run ID for isolation. See "Concurrent Execution and Run ID Isolation" section below.
|
||||
|
||||
## Test Artifacts and Log Analysis
|
||||
|
||||
@@ -98,6 +98,97 @@ When tests fail, examine artifacts in this specific order:
|
||||
4. **Client status dumps** (`*_status.json`): Network state and peer connectivity information
|
||||
5. **Database snapshots** (`.db` files): For data consistency and state persistence issues
|
||||
|
||||
## Concurrent Execution and Run ID Isolation
|
||||
|
||||
### Overview
|
||||
|
||||
The integration test system supports running multiple tests concurrently on the same Docker daemon. Each test run is isolated through a unique Run ID that ensures containers, networks, and cleanup operations don't interfere with each other.
|
||||
|
||||
### Run ID Format and Usage
|
||||
|
||||
Each test run generates a unique Run ID in the format: `YYYYMMDD-HHMMSS-{6-char-hash}`
|
||||
- Example: `20260109-104215-mdjtzx`
|
||||
|
||||
The Run ID is used for:
|
||||
- **Container naming**: `ts-{runIDShort}-{version}-{hash}` (e.g., `ts-mdjtzx-1-74-fgdyls`)
|
||||
- **Docker labels**: All containers get `hi.run-id={runID}` label
|
||||
- **Log directories**: `control_logs/{runID}/`
|
||||
- **Cleanup isolation**: Only containers with matching run ID are cleaned up
|
||||
|
||||
### Container Isolation Mechanisms
|
||||
|
||||
1. **Unique Container Names**: Each container includes the run ID for identification
|
||||
2. **Docker Labels**: `hi.run-id` and `hi.test-type` labels on all containers
|
||||
3. **Dynamic Port Allocation**: All ports use `{HostPort: "0"}` to let kernel assign free ports
|
||||
4. **Per-Run Networks**: Network names include scenario hash for isolation
|
||||
5. **Isolated Cleanup**: `killTestContainersByRunID()` only removes containers matching the run ID
|
||||
|
||||
### ⚠️ CRITICAL: Never Interfere with Other Test Runs
|
||||
|
||||
**FORBIDDEN OPERATIONS** when other tests may be running:
|
||||
|
||||
```bash
|
||||
# ❌ NEVER do global container cleanup while tests are running
|
||||
docker rm -f $(docker ps -q --filter "name=hs-")
|
||||
docker rm -f $(docker ps -q --filter "name=ts-")
|
||||
|
||||
# ❌ NEVER kill all test containers
|
||||
# This will destroy other agents' test sessions!
|
||||
|
||||
# ❌ NEVER prune all Docker resources during active tests
|
||||
docker system prune -f # Only safe when NO tests are running
|
||||
```
|
||||
|
||||
**SAFE OPERATIONS**:
|
||||
|
||||
```bash
|
||||
# ✅ Clean up only YOUR test run's containers (by run ID)
|
||||
# The test runner does this automatically via cleanup functions
|
||||
|
||||
# ✅ Clean stale (stopped/exited) containers only
|
||||
# Pre-test cleanup only removes stopped containers, not running ones
|
||||
|
||||
# ✅ Check what's running before cleanup
|
||||
docker ps --filter "name=headscale-test-suite" --format "{{.Names}}"
|
||||
```
|
||||
|
||||
### Running Concurrent Tests
|
||||
|
||||
```bash
|
||||
# Start multiple tests in parallel - each gets unique run ID
|
||||
go run ./cmd/hi run "TestPingAllByIP" &
|
||||
go run ./cmd/hi run "TestACLAllowUserDst" &
|
||||
go run ./cmd/hi run "TestOIDCAuthenticationPingAll" &
|
||||
|
||||
# Monitor running test suites
|
||||
docker ps --filter "name=headscale-test-suite" --format "table {{.Names}}\t{{.Status}}"
|
||||
```
|
||||
|
||||
### Agent Session Isolation Rules
|
||||
|
||||
When working as an agent:
|
||||
|
||||
1. **Your run ID is unique**: Each test you start gets its own run ID
|
||||
2. **Never clean up globally**: Only use run ID-specific cleanup
|
||||
3. **Check before cleanup**: Verify no other tests are running if you need to prune resources
|
||||
4. **Respect other sessions**: Other agents may have tests running concurrently
|
||||
5. **Log directories are isolated**: Your artifacts are in `control_logs/{your-run-id}/`
|
||||
|
||||
### Identifying Your Containers
|
||||
|
||||
Your test containers can be identified by:
|
||||
- The run ID in the container name
|
||||
- The `hi.run-id` Docker label
|
||||
- The test suite container: `headscale-test-suite-{your-run-id}`
|
||||
|
||||
```bash
|
||||
# List containers for a specific run ID
|
||||
docker ps --filter "label=hi.run-id=20260109-104215-mdjtzx"
|
||||
|
||||
# Get your run ID from the test output
|
||||
# Look for: "Run ID: 20260109-104215-mdjtzx"
|
||||
```
|
||||
|
||||
## Common Failure Patterns and Root Cause Analysis
|
||||
|
||||
### CRITICAL MINDSET: Code Issues vs Infrastructure Issues
|
||||
@@ -250,10 +341,10 @@ require.NotNil(t, targetNode, "should find expected node")
|
||||
- **Detection**: No progress in logs for >2 minutes during initialization
|
||||
- **Solution**: `docker system prune -f` and retry
|
||||
|
||||
3. **Docker Port Conflicts**: Multiple tests trying to use same ports
|
||||
- **Pattern**: "bind: address already in use" errors
|
||||
- **Detection**: Port binding failures in Docker logs
|
||||
- **Solution**: Only run ONE test at a time
|
||||
3. **Docker Resource Exhaustion**: Too many concurrent tests overwhelming system
|
||||
- **Pattern**: Container creation timeouts, OOM kills, slow test execution
|
||||
- **Detection**: System load high, Docker daemon slow to respond
|
||||
- **Solution**: Reduce number of concurrent tests, wait for completion before starting more
|
||||
|
||||
**CODE ISSUES (99% of failures)**:
|
||||
1. **Route Approval Process Failures**: Routes not getting approved when they should be
|
||||
@@ -273,12 +364,22 @@ require.NotNil(t, targetNode, "should find expected node")
|
||||
|
||||
### Critical Test Environment Setup
|
||||
|
||||
**Pre-Test Cleanup (MANDATORY)**:
|
||||
**Pre-Test Cleanup**:
|
||||
|
||||
The test runner automatically handles cleanup:
|
||||
- **Before test**: Removes only stale (stopped/exited) containers - does NOT affect running tests
|
||||
- **After test**: Removes only containers belonging to the specific run ID
|
||||
|
||||
```bash
|
||||
# ALWAYS run this before each test
|
||||
# Only clean old log directories if disk space is low
|
||||
rm -rf control_logs/202507*
|
||||
docker system prune -f
|
||||
df -h # Verify sufficient disk space
|
||||
|
||||
# SAFE: Clean only stale/stopped containers (does not affect running tests)
|
||||
# The test runner does this automatically via cleanupStaleTestContainers()
|
||||
|
||||
# ⚠️ DANGEROUS: Only use when NO tests are running
|
||||
docker system prune -f
|
||||
```
|
||||
|
||||
**Environment Verification**:
|
||||
@@ -286,8 +387,8 @@ df -h # Verify sufficient disk space
|
||||
# Verify system readiness
|
||||
go run ./cmd/hi doctor
|
||||
|
||||
# Check for running containers that might conflict
|
||||
docker ps
|
||||
# Check what tests are currently running (ALWAYS check before global cleanup)
|
||||
docker ps --filter "name=headscale-test-suite" --format "{{.Names}}"
|
||||
```
|
||||
|
||||
### Specific Test Categories and Known Issues
|
||||
@@ -433,7 +534,7 @@ When you understand a test's purpose through debugging, always add comprehensive
|
||||
//
|
||||
// The test verifies:
|
||||
// - Route announcements are received and tracked
|
||||
// - ACL policies control route approval correctly
|
||||
// - ACL policies control route approval correctly
|
||||
// - Only approved routes appear in peer network maps
|
||||
// - Route state persists correctly in the database
|
||||
func TestSubnetRoutes(t *testing.T) {
|
||||
@@ -535,7 +636,7 @@ var nodeKey key.NodePublic
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
|
||||
|
||||
for _, node := range nodes {
|
||||
if node.GetName() == "router" {
|
||||
routeNode = node
|
||||
@@ -550,7 +651,7 @@ assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
status, err := client.Status()
|
||||
assert.NoError(c, err)
|
||||
|
||||
|
||||
peerStatus, ok := status.Peer[nodeKey]
|
||||
assert.True(c, ok, "peer should exist in status")
|
||||
requirePeerSubnetRoutesWithCollect(c, peerStatus, expectedPrefixes)
|
||||
@@ -566,7 +667,7 @@ assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
assert.Len(c, nodes, 2)
|
||||
|
||||
|
||||
// Second unrelated external call - WRONG!
|
||||
status, err := client.Status()
|
||||
assert.NoError(c, err)
|
||||
@@ -577,7 +678,7 @@ assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
|
||||
|
||||
// NEVER do this!
|
||||
assert.EventuallyWithT(t, func(c2 *assert.CollectT) {
|
||||
status, _ := client.Status()
|
||||
@@ -666,11 +767,11 @@ When working within EventuallyWithT blocks where you need to prevent panics:
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
|
||||
|
||||
// For array bounds - use require with t to prevent panic
|
||||
assert.Len(c, nodes, 6) // Test expectation
|
||||
require.GreaterOrEqual(t, len(nodes), 3, "need at least 3 nodes to avoid panic")
|
||||
|
||||
|
||||
// For nil pointer access - use require with t before dereferencing
|
||||
assert.NotNil(c, srs1PeerStatus.PrimaryRoutes) // Test expectation
|
||||
require.NotNil(t, srs1PeerStatus.PrimaryRoutes, "primary routes must be set to avoid panic")
|
||||
@@ -681,7 +782,7 @@ assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
}, 5*time.Second, 200*time.Millisecond, "checking route state")
|
||||
```
|
||||
|
||||
**Key Principle**:
|
||||
**Key Principle**:
|
||||
- Use `assert` with `c` (*assert.CollectT) for test expectations that can be retried
|
||||
- Use `require` with `t` (*testing.T) for MUST conditions that prevent panics
|
||||
- Within EventuallyWithT, both are available - choose based on whether failure would cause a panic
|
||||
@@ -704,7 +805,7 @@ assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
status, err := client.Status()
|
||||
assert.NoError(c, err)
|
||||
|
||||
|
||||
// Check all peers have expected routes
|
||||
for _, peerKey := range status.Peers() {
|
||||
peerStatus := status.Peer[peerKey]
|
||||
@@ -756,8 +857,14 @@ assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
- **Why security focus**: Integration tests are the last line of defense against security regressions
|
||||
- **EventuallyWithT Usage**: Proper use prevents race conditions without weakening security assertions
|
||||
|
||||
6. **Concurrent Execution Awareness**: Respect run ID isolation and never interfere with other agents' test sessions. Each test run has a unique run ID - only clean up YOUR containers (by run ID label), never perform global cleanup while tests may be running.
|
||||
- **Why this matters**: Multiple agents/users may run tests concurrently on the same Docker daemon
|
||||
- **Key Rule**: NEVER use global container cleanup commands - the test runner handles cleanup automatically per run ID
|
||||
|
||||
**CRITICAL PRINCIPLE**: Test expectations are sacred contracts that define correct system behavior. When tests fail, fix the code to match the test, never change the test to match broken code. Only timing and observability improvements are allowed - business logic expectations are immutable.
|
||||
|
||||
**ISOLATION PRINCIPLE**: Each test run is isolated by its unique Run ID. Never interfere with other test sessions. The system handles cleanup automatically - manual global cleanup commands are forbidden when other tests may be running.
|
||||
|
||||
**EventuallyWithT PRINCIPLE**: Every external call to headscale server or tailscale client must be wrapped in EventuallyWithT. Follow the five key rules strictly: one external call per block, proper variable scoping, no nesting, use CollectT for assertions, and provide descriptive messages.
|
||||
|
||||
**Remember**: Test failures are usually code issues in Headscale that need to be fixed, not infrastructure problems to be ignored. Use the specific debugging workflows and failure patterns documented above to efficiently identify root causes. Infrastructure issues have very specific signatures - everything else is code-related.
|
||||
|
||||
16
.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
max_line_length = 120
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
25
.github/workflows/build.yml
vendored
@@ -5,8 +5,6 @@ on:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
@@ -17,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions: write-all
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Get changed files
|
||||
@@ -31,13 +29,12 @@ jobs:
|
||||
- '**/*.go'
|
||||
- 'integration_test/'
|
||||
- 'config-example.yaml'
|
||||
- uses: nixbuild/nix-quick-install-action@889f3180bb5f064ee9e3201428d04ae9e41d54ad # v31
|
||||
- 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',
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
@@ -57,7 +54,7 @@ jobs:
|
||||
exit $BUILD_STATUS
|
||||
|
||||
- name: Nix gosum diverging
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
if: failure() && steps.build.outcome == 'failure'
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
@@ -69,7 +66,7 @@ jobs:
|
||||
body: 'Nix build failed with wrong gosum, please update "vendorSha256" (${{ steps.build.outputs.OLD_HASH }}) for the "headscale" package in flake.nix with the new SHA: ${{ steps.build.outputs.NEW_HASH }}'
|
||||
})
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
with:
|
||||
name: headscale-linux
|
||||
@@ -84,22 +81,20 @@ jobs:
|
||||
- "GOARCH=arm64 GOOS=darwin"
|
||||
- "GOARCH=amd64 GOOS=darwin"
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: nixbuild/nix-quick-install-action@889f3180bb5f064ee9e3201428d04ae9e41d54ad # v31
|
||||
- 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',
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Run go cross compile
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
run:
|
||||
env ${{ matrix.env }} nix develop --command -- go build -o "headscale"
|
||||
run: env ${{ matrix.env }} nix develop --command -- go build -o "headscale"
|
||||
./cmd/headscale
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: "headscale-${{ matrix.env }}"
|
||||
path: "headscale"
|
||||
|
||||
4
.github/workflows/check-generated.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
check-generated:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Get changed files
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- '**/*.proto'
|
||||
- 'buf.gen.yaml'
|
||||
- 'tools/**'
|
||||
- uses: nixbuild/nix-quick-install-action@889f3180bb5f064ee9e3201428d04ae9e41d54ad # v31
|
||||
- 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'
|
||||
|
||||
7
.github/workflows/check-tests.yaml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
check-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Get changed files
|
||||
@@ -24,13 +24,12 @@ jobs:
|
||||
- '**/*.go'
|
||||
- 'integration_test/'
|
||||
- 'config-example.yaml'
|
||||
- uses: nixbuild/nix-quick-install-action@889f3180bb5f064ee9e3201428d04ae9e41d54ad # v31
|
||||
- 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',
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
|
||||
6
.github/workflows/docs-deploy.yml
vendored
@@ -21,15 +21,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: 3.x
|
||||
- name: Setup cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
|
||||
with:
|
||||
key: ${{ github.ref }}
|
||||
path: .cache
|
||||
|
||||
6
.github/workflows/docs-test.yml
vendored
@@ -11,13 +11,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Install python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: 3.x
|
||||
- name: Setup cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
|
||||
with:
|
||||
key: ${{ github.ref }}
|
||||
path: .cache
|
||||
|
||||
@@ -10,6 +10,55 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// testsToSplit defines tests that should be split into multiple CI jobs.
|
||||
// Key is the test function name, value is a list of subtest prefixes.
|
||||
// Each prefix becomes a separate CI job as "TestName/prefix".
|
||||
//
|
||||
// Example: TestAutoApproveMultiNetwork has subtests like:
|
||||
// - TestAutoApproveMultiNetwork/authkey-tag-advertiseduringup-false-pol-database
|
||||
// - TestAutoApproveMultiNetwork/webauth-user-advertiseduringup-true-pol-file
|
||||
//
|
||||
// Splitting by approver type (tag, user, group) creates 6 CI jobs with 4 tests each:
|
||||
// - TestAutoApproveMultiNetwork/authkey-tag.* (4 tests)
|
||||
// - TestAutoApproveMultiNetwork/authkey-user.* (4 tests)
|
||||
// - TestAutoApproveMultiNetwork/authkey-group.* (4 tests)
|
||||
// - TestAutoApproveMultiNetwork/webauth-tag.* (4 tests)
|
||||
// - TestAutoApproveMultiNetwork/webauth-user.* (4 tests)
|
||||
// - TestAutoApproveMultiNetwork/webauth-group.* (4 tests)
|
||||
//
|
||||
// This reduces load per CI job (4 tests instead of 12) to avoid infrastructure
|
||||
// flakiness when running many sequential Docker-based integration tests.
|
||||
var testsToSplit = map[string][]string{
|
||||
"TestAutoApproveMultiNetwork": {
|
||||
"authkey-tag",
|
||||
"authkey-user",
|
||||
"authkey-group",
|
||||
"webauth-tag",
|
||||
"webauth-user",
|
||||
"webauth-group",
|
||||
},
|
||||
}
|
||||
|
||||
// expandTests takes a list of test names and expands any that need splitting
|
||||
// into multiple subtest patterns.
|
||||
func expandTests(tests []string) []string {
|
||||
var expanded []string
|
||||
for _, test := range tests {
|
||||
if prefixes, ok := testsToSplit[test]; ok {
|
||||
// This test should be split into multiple jobs.
|
||||
// We append ".*" to each prefix because the CI runner wraps patterns
|
||||
// with ^...$ anchors. Without ".*", a pattern like "authkey$" wouldn't
|
||||
// match "authkey-tag-advertiseduringup-false-pol-database".
|
||||
for _, prefix := range prefixes {
|
||||
expanded = append(expanded, fmt.Sprintf("%s/%s.*", test, prefix))
|
||||
}
|
||||
} else {
|
||||
expanded = append(expanded, test)
|
||||
}
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
func findTests() []string {
|
||||
rgBin, err := exec.LookPath("rg")
|
||||
if err != nil {
|
||||
@@ -66,8 +115,11 @@ func updateYAML(tests []string, jobName string, testPath string) {
|
||||
func main() {
|
||||
tests := findTests()
|
||||
|
||||
quotedTests := make([]string, len(tests))
|
||||
for i, test := range tests {
|
||||
// Expand tests that should be split into multiple jobs
|
||||
expandedTests := expandTests(tests)
|
||||
|
||||
quotedTests := make([]string, len(expandedTests))
|
||||
for i, test := range expandedTests {
|
||||
quotedTests[i] = fmt.Sprintf("\"%s\"", test)
|
||||
}
|
||||
|
||||
|
||||
4
.github/workflows/gh-actions-updater.yaml
vendored
@@ -11,13 +11,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
# [Required] Access token with `workflow` scope.
|
||||
token: ${{ secrets.WORKFLOW_SECRET }}
|
||||
|
||||
- name: Run GitHub Actions Version Updater
|
||||
uses: saadmk11/github-actions-version-updater@64be81ba69383f81f2be476703ea6570c4c8686e # v0.8.1
|
||||
uses: saadmk11/github-actions-version-updater@d8781caf11d11168579c8e5e94f62b068038f442 # v0.9.0
|
||||
with:
|
||||
# [Required] Access token with `workflow` scope.
|
||||
token: ${{ secrets.WORKFLOW_SECRET }}
|
||||
|
||||
92
.github/workflows/integration-test-template.yml
vendored
@@ -28,23 +28,12 @@ jobs:
|
||||
# that triggered the build.
|
||||
HAS_TAILSCALE_SECRET: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- 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'
|
||||
- name: Tailscale
|
||||
if: ${{ env.HAS_TAILSCALE_SECRET }}
|
||||
uses: tailscale/github-action@6986d2c82a91fbac2949fe01f5bab95cf21b5102 # v3.2.2
|
||||
uses: tailscale/github-action@a392da0a182bba0e9613b6243ebd69529b1878aa # v4.1.0
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
@@ -52,31 +41,72 @@ jobs:
|
||||
- name: Setup SSH server for Actor
|
||||
if: ${{ env.HAS_TAILSCALE_SECRET }}
|
||||
uses: alexellis/setup-sshd-actor@master
|
||||
- uses: nixbuild/nix-quick-install-action@889f3180bb5f064ee9e3201428d04ae9e41d54ad # v31
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
- name: Download headscale image
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
primary-key:
|
||||
nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
name: headscale-image
|
||||
path: /tmp/artifacts
|
||||
- name: Download tailscale HEAD image
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: tailscale-head-image
|
||||
path: /tmp/artifacts
|
||||
- name: Download hi binary
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: hi-binary
|
||||
path: /tmp/artifacts
|
||||
- name: Download Go cache
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: go-cache
|
||||
path: /tmp/artifacts
|
||||
- name: Download postgres image
|
||||
if: ${{ inputs.postgres_flag == '--postgres=1' }}
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: postgres-image
|
||||
path: /tmp/artifacts
|
||||
- name: Load Docker images, Go cache, and prepare binary
|
||||
run: |
|
||||
gunzip -c /tmp/artifacts/headscale-image.tar.gz | docker load
|
||||
gunzip -c /tmp/artifacts/tailscale-head-image.tar.gz | docker load
|
||||
if [ -f /tmp/artifacts/postgres-image.tar.gz ]; then
|
||||
gunzip -c /tmp/artifacts/postgres-image.tar.gz | docker load
|
||||
fi
|
||||
chmod +x /tmp/artifacts/hi
|
||||
docker images
|
||||
# Extract Go cache to host directories for bind mounting
|
||||
mkdir -p /tmp/go-cache
|
||||
tar -xzf /tmp/artifacts/go-cache.tar.gz -C /tmp/go-cache
|
||||
ls -la /tmp/go-cache/ /tmp/go-cache/.cache/
|
||||
- name: Run Integration Test
|
||||
if: always() && steps.changed-files.outputs.files == 'true'
|
||||
run:
|
||||
nix develop --command -- hi run --stats --ts-memory-limit=300 --hs-memory-limit=1500 "^${{ inputs.test }}$" \
|
||||
env:
|
||||
HEADSCALE_INTEGRATION_HEADSCALE_IMAGE: headscale:${{ github.sha }}
|
||||
HEADSCALE_INTEGRATION_TAILSCALE_IMAGE: tailscale-head:${{ github.sha }}
|
||||
HEADSCALE_INTEGRATION_POSTGRES_IMAGE: ${{ inputs.postgres_flag == '--postgres=1' && format('postgres:{0}', github.sha) || '' }}
|
||||
HEADSCALE_INTEGRATION_GO_CACHE: /tmp/go-cache/go
|
||||
HEADSCALE_INTEGRATION_GO_BUILD_CACHE: /tmp/go-cache/.cache/go-build
|
||||
run: /tmp/artifacts/hi run --stats --ts-memory-limit=300 --hs-memory-limit=1500 "^${{ inputs.test }}$" \
|
||||
--timeout=120m \
|
||||
${{ inputs.postgres_flag }}
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: always() && steps.changed-files.outputs.files == 'true'
|
||||
# Sanitize test name for artifact upload (replace invalid characters: " : < > | * ? \ / with -)
|
||||
- name: Sanitize test name for artifacts
|
||||
if: always()
|
||||
id: sanitize
|
||||
run: echo "name=${TEST_NAME//[\":<>|*?\\\/]/-}" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
TEST_NAME: ${{ inputs.test }}
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: ${{ inputs.database_name }}-${{ inputs.test }}-logs
|
||||
name: ${{ inputs.database_name }}-${{ steps.sanitize.outputs.name }}-logs
|
||||
path: "control_logs/*/*.log"
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: always() && steps.changed-files.outputs.files == 'true'
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: ${{ inputs.database_name }}-${{ inputs.test }}-archives
|
||||
path: "control_logs/*/*.tar"
|
||||
name: ${{ inputs.database_name }}-${{ steps.sanitize.outputs.name }}-artifacts
|
||||
path: control_logs/
|
||||
- name: Setup a blocking tmux session
|
||||
if: ${{ env.HAS_TAILSCALE_SECRET }}
|
||||
uses: alexellis/block-with-tmux-action@master
|
||||
|
||||
21
.github/workflows/lint.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
golangci-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Get changed files
|
||||
@@ -24,13 +24,12 @@ jobs:
|
||||
- '**/*.go'
|
||||
- 'integration_test/'
|
||||
- 'config-example.yaml'
|
||||
- uses: nixbuild/nix-quick-install-action@889f3180bb5f064ee9e3201428d04ae9e41d54ad # v31
|
||||
- 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',
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
@@ -46,7 +45,7 @@ jobs:
|
||||
prettier-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Get changed files
|
||||
@@ -65,13 +64,12 @@ jobs:
|
||||
- '**/*.css'
|
||||
- '**/*.scss'
|
||||
- '**/*.html'
|
||||
- uses: nixbuild/nix-quick-install-action@889f3180bb5f064ee9e3201428d04ae9e41d54ad # v31
|
||||
- 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',
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
@@ -83,12 +81,11 @@ jobs:
|
||||
proto-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: nixbuild/nix-quick-install-action@889f3180bb5f064ee9e3201428d04ae9e41d54ad # v31
|
||||
- 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',
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
|
||||
55
.github/workflows/nix-module-test.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: NixOS Module Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix-module-check:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@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: |
|
||||
nix:
|
||||
- 'nix/**'
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
go:
|
||||
- 'go.*'
|
||||
- '**/*.go'
|
||||
- 'cmd/**'
|
||||
- 'hscontrol/**'
|
||||
|
||||
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true'
|
||||
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true'
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Run NixOS module tests
|
||||
if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true'
|
||||
run: |
|
||||
echo "Running NixOS module integration test..."
|
||||
nix build .#checks.x86_64-linux.headscale -L
|
||||
11
.github/workflows/release.yml
vendored
@@ -13,28 +13,27 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: nixbuild/nix-quick-install-action@889f3180bb5f064ee9e3201428d04ae9e41d54ad # v31
|
||||
- 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',
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
|
||||
8
.github/workflows/stale.yml
vendored
@@ -12,16 +12,14 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 7
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message:
|
||||
"This issue is stale because it has been open for 90 days with no
|
||||
stale-issue-message: "This issue is stale because it has been open for 90 days with no
|
||||
activity."
|
||||
close-issue-message:
|
||||
"This issue was closed because it has been inactive for 14 days
|
||||
close-issue-message: "This issue was closed because it has been inactive for 14 days
|
||||
since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
|
||||
160
.github/workflows/test-integration.yaml
vendored
@@ -7,7 +7,117 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
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.
|
||||
# sqlite: Runs all integration tests with SQLite backend.
|
||||
# postgres: Runs a subset of tests with PostgreSQL to verify database compatibility.
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
files-changed: ${{ steps.changed-files.outputs.files }}
|
||||
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/**'
|
||||
- 'config-example.yaml'
|
||||
- '.github/workflows/test-integration.yaml'
|
||||
- '.github/workflows/integration-test-template.yml'
|
||||
- 'Dockerfile.*'
|
||||
- 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: Build binaries and warm Go cache
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: |
|
||||
# Build all Go binaries in one nix shell to maximize cache reuse
|
||||
nix develop --command -- bash -c '
|
||||
go build -o hi ./cmd/hi
|
||||
CGO_ENABLED=0 GOOS=linux go build -o headscale ./cmd/headscale
|
||||
# Build integration test binary to warm the cache with all dependencies
|
||||
go test -c ./integration -o /dev/null 2>/dev/null || true
|
||||
'
|
||||
- name: Upload hi binary
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: hi-binary
|
||||
path: hi
|
||||
retention-days: 10
|
||||
- name: Package Go cache
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: |
|
||||
# Package Go module cache and build cache
|
||||
tar -czf go-cache.tar.gz -C ~ go .cache/go-build
|
||||
- name: Upload Go cache
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: go-cache
|
||||
path: go-cache.tar.gz
|
||||
retention-days: 10
|
||||
- name: Build headscale image
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: |
|
||||
docker build \
|
||||
--file Dockerfile.integration-ci \
|
||||
--tag headscale:${{ github.sha }} \
|
||||
.
|
||||
docker save headscale:${{ github.sha }} | gzip > headscale-image.tar.gz
|
||||
- name: Build tailscale HEAD image
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: |
|
||||
docker build \
|
||||
--file Dockerfile.tailscale-HEAD \
|
||||
--tag tailscale-head:${{ github.sha }} \
|
||||
.
|
||||
docker save tailscale-head:${{ github.sha }} | gzip > tailscale-head-image.tar.gz
|
||||
- name: Upload headscale image
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: headscale-image
|
||||
path: headscale-image.tar.gz
|
||||
retention-days: 10
|
||||
- name: Upload tailscale HEAD image
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: tailscale-head-image
|
||||
path: tailscale-head-image.tar.gz
|
||||
retention-days: 10
|
||||
build-postgres:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: needs.build.outputs.files-changed == 'true'
|
||||
steps:
|
||||
- name: Pull and save postgres image
|
||||
run: |
|
||||
docker pull postgres:latest
|
||||
docker tag postgres:latest postgres:${{ github.sha }}
|
||||
docker save postgres:${{ github.sha }} | gzip > postgres-image.tar.gz
|
||||
- name: Upload postgres image
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: postgres-image
|
||||
path: postgres-image.tar.gz
|
||||
retention-days: 10
|
||||
sqlite:
|
||||
needs: build
|
||||
if: needs.build.outputs.files-changed == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -25,6 +135,8 @@ jobs:
|
||||
- TestACLAutogroupTagged
|
||||
- TestACLAutogroupSelf
|
||||
- TestACLPolicyPropagationOverTime
|
||||
- TestACLTagPropagation
|
||||
- TestACLTagPropagationPortSpecific
|
||||
- TestAPIAuthenticationBypass
|
||||
- TestAPIAuthenticationBypassCurl
|
||||
- TestGRPCAuthenticationBypass
|
||||
@@ -32,13 +144,19 @@ jobs:
|
||||
- TestAuthKeyLogoutAndReloginSameUser
|
||||
- TestAuthKeyLogoutAndReloginNewUser
|
||||
- TestAuthKeyLogoutAndReloginSameUserExpiredKey
|
||||
- TestAuthKeyDeleteKey
|
||||
- TestAuthKeyLogoutAndReloginRoutesPreserved
|
||||
- TestOIDCAuthenticationPingAll
|
||||
- TestOIDCExpireNodesBasedOnTokenExpiry
|
||||
- TestOIDC024UserCreation
|
||||
- TestOIDCAuthenticationWithPKCE
|
||||
- TestOIDCReloginSameNodeNewUser
|
||||
- TestOIDCFollowUpUrl
|
||||
- TestOIDCMultipleOpenedLoginUrls
|
||||
- TestOIDCReloginSameNodeSameUser
|
||||
- TestOIDCExpiryAfterRestart
|
||||
- TestOIDCACLPolicyOnJoin
|
||||
- TestOIDCReloginSameUserRoutesPreserved
|
||||
- TestAuthWebFlowAuthenticationPingAll
|
||||
- TestAuthWebFlowLogoutAndReloginSameUser
|
||||
- TestAuthWebFlowLogoutAndReloginNewUser
|
||||
@@ -47,13 +165,11 @@ jobs:
|
||||
- TestPreAuthKeyCommandWithoutExpiry
|
||||
- TestPreAuthKeyCommandReusableEphemeral
|
||||
- TestPreAuthKeyCorrectUserLoggedInCommand
|
||||
- TestTaggedNodesCLIOutput
|
||||
- TestApiKeyCommand
|
||||
- TestNodeTagCommand
|
||||
- TestNodeAdvertiseTagCommand
|
||||
- TestNodeCommand
|
||||
- TestNodeExpireCommand
|
||||
- TestNodeRenameCommand
|
||||
- TestNodeMoveCommand
|
||||
- TestPolicyCommand
|
||||
- TestPolicyBrokenConfigCommand
|
||||
- TestDERPVerifyEndpoint
|
||||
@@ -80,7 +196,12 @@ jobs:
|
||||
- TestEnablingExitRoutes
|
||||
- TestSubnetRouterMultiNetwork
|
||||
- TestSubnetRouterMultiNetworkExitNode
|
||||
- TestAutoApproveMultiNetwork
|
||||
- TestAutoApproveMultiNetwork/authkey-tag.*
|
||||
- TestAutoApproveMultiNetwork/authkey-user.*
|
||||
- TestAutoApproveMultiNetwork/authkey-group.*
|
||||
- TestAutoApproveMultiNetwork/webauth-tag.*
|
||||
- TestAutoApproveMultiNetwork/webauth-user.*
|
||||
- TestAutoApproveMultiNetwork/webauth-group.*
|
||||
- TestSubnetRouteACLFiltering
|
||||
- TestHeadscale
|
||||
- TestTailscaleNodesJoiningHeadcale
|
||||
@@ -90,12 +211,42 @@ jobs:
|
||||
- TestSSHIsBlockedInACL
|
||||
- TestSSHUserOnlyIsolation
|
||||
- TestSSHAutogroupSelf
|
||||
- TestTagsAuthKeyWithTagRequestDifferentTag
|
||||
- TestTagsAuthKeyWithTagNoAdvertiseFlag
|
||||
- TestTagsAuthKeyWithTagCannotAddViaCLI
|
||||
- TestTagsAuthKeyWithTagCannotChangeViaCLI
|
||||
- TestTagsAuthKeyWithTagAdminOverrideReauthPreserves
|
||||
- TestTagsAuthKeyWithTagCLICannotModifyAdminTags
|
||||
- TestTagsAuthKeyWithoutTagCannotRequestTags
|
||||
- TestTagsAuthKeyWithoutTagRegisterNoTags
|
||||
- TestTagsAuthKeyWithoutTagCannotAddViaCLI
|
||||
- TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithReset
|
||||
- TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithEmptyAdvertise
|
||||
- TestTagsAuthKeyWithoutTagCLICannotReduceAdminMultiTag
|
||||
- TestTagsUserLoginOwnedTagAtRegistration
|
||||
- TestTagsUserLoginNonExistentTagAtRegistration
|
||||
- TestTagsUserLoginUnownedTagAtRegistration
|
||||
- TestTagsUserLoginAddTagViaCLIReauth
|
||||
- TestTagsUserLoginRemoveTagViaCLIReauth
|
||||
- TestTagsUserLoginCLINoOpAfterAdminAssignment
|
||||
- TestTagsUserLoginCLICannotRemoveAdminTags
|
||||
- TestTagsAuthKeyWithTagRequestNonExistentTag
|
||||
- TestTagsAuthKeyWithTagRequestUnownedTag
|
||||
- TestTagsAuthKeyWithoutTagRequestNonExistentTag
|
||||
- TestTagsAuthKeyWithoutTagRequestUnownedTag
|
||||
- TestTagsAdminAPICannotSetNonExistentTag
|
||||
- TestTagsAdminAPICanSetUnownedTag
|
||||
- TestTagsAdminAPICannotRemoveAllTags
|
||||
- TestTagsAdminAPICannotSetInvalidFormat
|
||||
uses: ./.github/workflows/integration-test-template.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
test: ${{ matrix.test }}
|
||||
postgres_flag: "--postgres=0"
|
||||
database_name: "sqlite"
|
||||
postgres:
|
||||
needs: [build, build-postgres]
|
||||
if: needs.build.outputs.files-changed == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -106,6 +257,7 @@ jobs:
|
||||
- TestPingAllByIPManyUpDown
|
||||
- TestSubnetRouterMultiNetwork
|
||||
uses: ./.github/workflows/integration-test-template.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
test: ${{ matrix.test }}
|
||||
postgres_flag: "--postgres=1"
|
||||
|
||||
7
.github/workflows/test.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
@@ -27,13 +27,12 @@ jobs:
|
||||
- 'integration_test/'
|
||||
- 'config-example.yaml'
|
||||
|
||||
- uses: nixbuild/nix-quick-install-action@889f3180bb5f064ee9e3201428d04ae9e41d54ad # v31
|
||||
- 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',
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
|
||||
1
.gitignore
vendored
@@ -2,6 +2,7 @@ ignored/
|
||||
tailscale/
|
||||
.vscode/
|
||||
.claude/
|
||||
logs/
|
||||
|
||||
*.prof
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ linters:
|
||||
- depguard
|
||||
- dupl
|
||||
- exhaustruct
|
||||
- funcorder
|
||||
- funlen
|
||||
- gochecknoglobals
|
||||
- gochecknoinits
|
||||
@@ -28,6 +29,15 @@ linters:
|
||||
- wrapcheck
|
||||
- wsl
|
||||
settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
# Forbid time.Sleep everywhere with context-appropriate alternatives
|
||||
- pattern: 'time\.Sleep'
|
||||
msg: >-
|
||||
time.Sleep is forbidden.
|
||||
In tests: use assert.EventuallyWithT for polling/waiting patterns.
|
||||
In production code: use a backoff strategy (e.g., cenkalti/backoff) or proper synchronization primitives.
|
||||
analyze-types: true
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- appendAssign
|
||||
|
||||
@@ -125,7 +125,7 @@ kos:
|
||||
# bare tells KO to only use the repository
|
||||
# for tagging and naming the container.
|
||||
bare: true
|
||||
base_image: gcr.io/distroless/base-debian12
|
||||
base_image: gcr.io/distroless/base-debian13
|
||||
build: headscale
|
||||
main: ./cmd/headscale
|
||||
env:
|
||||
@@ -154,7 +154,7 @@ kos:
|
||||
- headscale/headscale
|
||||
|
||||
bare: true
|
||||
base_image: gcr.io/distroless/base-debian12:debug
|
||||
base_image: gcr.io/distroless/base-debian13:debug
|
||||
build: headscale
|
||||
main: ./cmd/headscale
|
||||
env:
|
||||
|
||||
24
.mcp.json
@@ -3,45 +3,31 @@
|
||||
"claude-code-mcp": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@steipete/claude-code-mcp@latest"
|
||||
],
|
||||
"args": ["-y", "@steipete/claude-code-mcp@latest"],
|
||||
"env": {}
|
||||
},
|
||||
"sequential-thinking": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-sequential-thinking"
|
||||
],
|
||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"],
|
||||
"env": {}
|
||||
},
|
||||
"nixos": {
|
||||
"type": "stdio",
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-nixos"
|
||||
],
|
||||
"args": ["mcp-nixos"],
|
||||
"env": {}
|
||||
},
|
||||
"context7": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@upstash/context7-mcp"
|
||||
],
|
||||
"args": ["-y", "@upstash/context7-mcp"],
|
||||
"env": {}
|
||||
},
|
||||
"git": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@cyanheads/git-mcp-server"
|
||||
],
|
||||
"args": ["-y", "@cyanheads/git-mcp-server"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
|
||||
68
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
# prek/pre-commit configuration for headscale
|
||||
# See: https://prek.j178.dev/quickstart/
|
||||
# See: https://prek.j178.dev/builtin/
|
||||
|
||||
# Global exclusions - ignore generated code
|
||||
exclude: ^gen/
|
||||
|
||||
repos:
|
||||
# Built-in hooks from pre-commit/pre-commit-hooks
|
||||
# prek will use fast-path optimized versions automatically
|
||||
# See: https://prek.j178.dev/builtin/
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
- id: check-case-conflict
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-json
|
||||
- id: check-merge-conflict
|
||||
- id: check-symlinks
|
||||
- id: check-toml
|
||||
- id: check-xml
|
||||
- id: check-yaml
|
||||
- id: detect-private-key
|
||||
- id: end-of-file-fixer
|
||||
- id: fix-byte-order-marker
|
||||
- id: mixed-line-ending
|
||||
- id: trailing-whitespace
|
||||
|
||||
# Local hooks for project-specific tooling
|
||||
- repo: local
|
||||
hooks:
|
||||
# nixpkgs-fmt for Nix files
|
||||
- id: nixpkgs-fmt
|
||||
name: nixpkgs-fmt
|
||||
entry: nixpkgs-fmt
|
||||
language: system
|
||||
files: \.nix$
|
||||
|
||||
# Prettier for formatting
|
||||
- id: prettier
|
||||
name: prettier
|
||||
entry: prettier --write --list-different
|
||||
language: system
|
||||
exclude: ^docs/
|
||||
types_or:
|
||||
[
|
||||
javascript,
|
||||
jsx,
|
||||
ts,
|
||||
tsx,
|
||||
yaml,
|
||||
json,
|
||||
toml,
|
||||
html,
|
||||
css,
|
||||
scss,
|
||||
sass,
|
||||
markdown,
|
||||
]
|
||||
|
||||
# golangci-lint for Go code quality
|
||||
- id: golangci-lint
|
||||
name: golangci-lint
|
||||
entry: nix develop --command golangci-lint run --new-from-rev=HEAD~1 --timeout=5m --fix
|
||||
language: system
|
||||
types: [go]
|
||||
pass_filenames: false
|
||||
@@ -1,5 +1,5 @@
|
||||
.github/workflows/test-integration-v2*
|
||||
docs/about/features.md
|
||||
docs/ref/api.md
|
||||
docs/ref/configuration.md
|
||||
docs/ref/oidc.md
|
||||
docs/ref/remote-cli.md
|
||||
|
||||
650
CHANGELOG.md
532
CLAUDE.md
@@ -1,531 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
Headscale is an open-source implementation of the Tailscale control server written in Go. It provides self-hosted coordination for Tailscale networks (tailnets), managing node registration, IP allocation, policy enforcement, and DERP routing.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Quick Setup
|
||||
```bash
|
||||
# Recommended: Use Nix for dependency management
|
||||
nix develop
|
||||
|
||||
# Full development workflow
|
||||
make dev # runs fmt + lint + test + build
|
||||
```
|
||||
|
||||
### Essential Commands
|
||||
```bash
|
||||
# Build headscale binary
|
||||
make build
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
go test ./... # All unit tests
|
||||
go test -race ./... # With race detection
|
||||
|
||||
# Run specific integration test
|
||||
go run ./cmd/hi run "TestName" --postgres
|
||||
|
||||
# Code formatting and linting
|
||||
make fmt # Format all code (Go, docs, proto)
|
||||
make lint # Lint all code (Go, proto)
|
||||
make fmt-go # Format Go code only
|
||||
make lint-go # Lint Go code only
|
||||
|
||||
# Protocol buffer generation (after modifying proto/)
|
||||
make generate
|
||||
|
||||
# Clean build artifacts
|
||||
make clean
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
```bash
|
||||
# Use the hi (Headscale Integration) test runner
|
||||
go run ./cmd/hi doctor # Check system requirements
|
||||
go run ./cmd/hi run "TestPattern" # Run specific test
|
||||
go run ./cmd/hi run "TestPattern" --postgres # With PostgreSQL backend
|
||||
|
||||
# Test artifacts are saved to control_logs/ with logs and debug data
|
||||
```
|
||||
|
||||
## Project Structure & Architecture
|
||||
|
||||
### Top-Level Organization
|
||||
|
||||
```
|
||||
headscale/
|
||||
├── cmd/ # Command-line applications
|
||||
│ ├── headscale/ # Main headscale server binary
|
||||
│ └── hi/ # Headscale Integration test runner
|
||||
├── hscontrol/ # Core control plane logic
|
||||
├── integration/ # End-to-end Docker-based tests
|
||||
├── proto/ # Protocol buffer definitions
|
||||
├── gen/ # Generated code (protobuf)
|
||||
├── docs/ # Documentation
|
||||
└── packaging/ # Distribution packaging
|
||||
```
|
||||
|
||||
### Core Packages (`hscontrol/`)
|
||||
|
||||
**Main Server (`hscontrol/`)**
|
||||
- `app.go`: Application setup, dependency injection, server lifecycle
|
||||
- `handlers.go`: HTTP/gRPC API endpoints for management operations
|
||||
- `grpcv1.go`: gRPC service implementation for headscale API
|
||||
- `poll.go`: **Critical** - Handles Tailscale MapRequest/MapResponse protocol
|
||||
- `noise.go`: Noise protocol implementation for secure client communication
|
||||
- `auth.go`: Authentication flows (web, OIDC, command-line)
|
||||
- `oidc.go`: OpenID Connect integration for user authentication
|
||||
|
||||
**State Management (`hscontrol/state/`)**
|
||||
- `state.go`: Central coordinator for all subsystems (database, policy, IP allocation, DERP)
|
||||
- `node_store.go`: **Performance-critical** - In-memory cache with copy-on-write semantics
|
||||
- Thread-safe operations with deadlock detection
|
||||
- Coordinates between database persistence and real-time operations
|
||||
|
||||
**Database Layer (`hscontrol/db/`)**
|
||||
- `db.go`: Database abstraction, GORM setup, migration management
|
||||
- `node.go`: Node lifecycle, registration, expiration, IP assignment
|
||||
- `users.go`: User management, namespace isolation
|
||||
- `api_key.go`: API authentication tokens
|
||||
- `preauth_keys.go`: Pre-authentication keys for automated node registration
|
||||
- `ip.go`: IP address allocation and management
|
||||
- `policy.go`: Policy storage and retrieval
|
||||
- Schema migrations in `schema.sql` with extensive test data coverage
|
||||
|
||||
**Policy Engine (`hscontrol/policy/`)**
|
||||
- `policy.go`: Core ACL evaluation logic, HuJSON parsing
|
||||
- `v2/`: Next-generation policy system with improved filtering
|
||||
- `matcher/`: ACL rule matching and evaluation engine
|
||||
- Determines peer visibility, route approval, and network access rules
|
||||
- Supports both file-based and database-stored policies
|
||||
|
||||
**Network Management (`hscontrol/`)**
|
||||
- `derp/`: DERP (Designated Encrypted Relay for Packets) server implementation
|
||||
- NAT traversal when direct connections fail
|
||||
- Fallback relay for firewall-restricted environments
|
||||
- `mapper/`: Converts internal Headscale state to Tailscale's wire protocol format
|
||||
- `tail.go`: Tailscale-specific data structure generation
|
||||
- `routes/`: Subnet route management and primary route selection
|
||||
- `dns/`: DNS record management and MagicDNS implementation
|
||||
|
||||
**Utilities & Support (`hscontrol/`)**
|
||||
- `types/`: Core data structures, configuration, validation
|
||||
- `util/`: Helper functions for networking, DNS, key management
|
||||
- `templates/`: Client configuration templates (Apple, Windows, etc.)
|
||||
- `notifier/`: Event notification system for real-time updates
|
||||
- `metrics.go`: Prometheus metrics collection
|
||||
- `capver/`: Tailscale capability version management
|
||||
|
||||
### Key Subsystem Interactions
|
||||
|
||||
**Node Registration Flow**
|
||||
1. **Client Connection**: `noise.go` handles secure protocol handshake
|
||||
2. **Authentication**: `auth.go` validates credentials (web/OIDC/preauth)
|
||||
3. **State Creation**: `state.go` coordinates IP allocation via `db/ip.go`
|
||||
4. **Storage**: `db/node.go` persists node, `NodeStore` caches in memory
|
||||
5. **Network Setup**: `mapper/` generates initial Tailscale network map
|
||||
|
||||
**Ongoing Operations**
|
||||
1. **Poll Requests**: `poll.go` receives periodic client updates
|
||||
2. **State Updates**: `NodeStore` maintains real-time node information
|
||||
3. **Policy Application**: `policy/` evaluates ACL rules for peer relationships
|
||||
4. **Map Distribution**: `mapper/` sends network topology to all affected clients
|
||||
|
||||
**Route Management**
|
||||
1. **Advertisement**: Clients announce routes via `poll.go` Hostinfo updates
|
||||
2. **Storage**: `db/` persists routes, `NodeStore` caches for performance
|
||||
3. **Approval**: `policy/` auto-approves routes based on ACL rules
|
||||
4. **Distribution**: `routes/` selects primary routes, `mapper/` distributes to peers
|
||||
|
||||
### Command-Line Tools (`cmd/`)
|
||||
|
||||
**Main Server (`cmd/headscale/`)**
|
||||
- `headscale.go`: CLI parsing, configuration loading, server startup
|
||||
- Supports daemon mode, CLI operations (user/node management), database operations
|
||||
|
||||
**Integration Test Runner (`cmd/hi/`)**
|
||||
- `main.go`: Test execution framework with Docker orchestration
|
||||
- `run.go`: Individual test execution with artifact collection
|
||||
- `doctor.go`: System requirements validation
|
||||
- `docker.go`: Container lifecycle management
|
||||
- Essential for validating changes against real Tailscale clients
|
||||
|
||||
### Generated & External Code
|
||||
|
||||
**Protocol Buffers (`proto/` → `gen/`)**
|
||||
- Defines gRPC API for headscale management operations
|
||||
- Client libraries can generate from these definitions
|
||||
- Run `make generate` after modifying `.proto` files
|
||||
|
||||
**Integration Testing (`integration/`)**
|
||||
- `scenario.go`: Docker test environment setup
|
||||
- `tailscale.go`: Tailscale client container management
|
||||
- Individual test files for specific functionality areas
|
||||
- Real end-to-end validation with network isolation
|
||||
|
||||
### Critical Performance Paths
|
||||
|
||||
**High-Frequency Operations**
|
||||
1. **MapRequest Processing** (`poll.go`): Every 15-60 seconds per client
|
||||
2. **NodeStore Reads** (`node_store.go`): Every operation requiring node data
|
||||
3. **Policy Evaluation** (`policy/`): On every peer relationship calculation
|
||||
4. **Route Lookups** (`routes/`): During network map generation
|
||||
|
||||
**Database Write Patterns**
|
||||
- **Frequent**: Node heartbeats, endpoint updates, route changes
|
||||
- **Moderate**: User operations, policy updates, API key management
|
||||
- **Rare**: Schema migrations, bulk operations
|
||||
|
||||
### Configuration & Deployment
|
||||
|
||||
**Configuration** (`hscontrol/types/config.go`)**
|
||||
- Database connection settings (SQLite/PostgreSQL)
|
||||
- Network configuration (IP ranges, DNS settings)
|
||||
- Policy mode (file vs database)
|
||||
- DERP relay configuration
|
||||
- OIDC provider settings
|
||||
|
||||
**Key Dependencies**
|
||||
- **GORM**: Database ORM with migration support
|
||||
- **Tailscale Libraries**: Core networking and protocol code
|
||||
- **Zerolog**: Structured logging throughout the application
|
||||
- **Buf**: Protocol buffer toolchain for code generation
|
||||
|
||||
### Development Workflow Integration
|
||||
|
||||
The architecture supports incremental development:
|
||||
- **Unit Tests**: Focus on individual packages (`*_test.go` files)
|
||||
- **Integration Tests**: Validate cross-component interactions
|
||||
- **Database Tests**: Extensive migration and data integrity validation
|
||||
- **Policy Tests**: ACL rule evaluation and edge cases
|
||||
- **Performance Tests**: NodeStore and high-frequency operation validation
|
||||
|
||||
## Integration Testing System
|
||||
|
||||
### Overview
|
||||
Headscale uses Docker-based integration tests with real Tailscale clients to validate end-to-end functionality. The integration test system is complex and requires specialized knowledge for effective execution and debugging.
|
||||
|
||||
### **MANDATORY: Use the headscale-integration-tester Agent**
|
||||
|
||||
**CRITICAL REQUIREMENT**: For ANY integration test execution, analysis, troubleshooting, or validation, you MUST use the `headscale-integration-tester` agent. This agent contains specialized knowledge about:
|
||||
|
||||
- Test execution strategies and timing requirements
|
||||
- Infrastructure vs code issue distinction (99% vs 1% failure patterns)
|
||||
- Security-critical debugging rules and forbidden practices
|
||||
- Comprehensive artifact analysis workflows
|
||||
- Real-world failure patterns from HA debugging experiences
|
||||
|
||||
### Quick Reference Commands
|
||||
|
||||
```bash
|
||||
# Check system requirements (always run first)
|
||||
go run ./cmd/hi doctor
|
||||
|
||||
# Run single test (recommended for development)
|
||||
go run ./cmd/hi run "TestName"
|
||||
|
||||
# Use PostgreSQL for database-heavy tests
|
||||
go run ./cmd/hi run "TestName" --postgres
|
||||
|
||||
# Pattern matching for related tests
|
||||
go run ./cmd/hi run "TestPattern*"
|
||||
```
|
||||
|
||||
**Critical Notes**:
|
||||
- Only ONE test can run at a time (Docker port conflicts)
|
||||
- Tests generate ~100MB of logs per run in `control_logs/`
|
||||
- Clean environment before each test: `rm -rf control_logs/202507* && docker system prune -f`
|
||||
|
||||
### Test Artifacts Location
|
||||
All test runs save comprehensive debugging artifacts to `control_logs/TIMESTAMP-ID/` including server logs, client logs, database dumps, MapResponse protocol data, and Prometheus metrics.
|
||||
|
||||
**For all integration test work, use the headscale-integration-tester agent - it contains the complete knowledge needed for effective testing and debugging.**
|
||||
|
||||
## NodeStore Implementation Details
|
||||
|
||||
**Key Insight from Recent Work**: The NodeStore is a critical performance optimization that caches node data in memory while ensuring consistency with the database. When working with route advertisements or node state changes:
|
||||
|
||||
1. **Timing Considerations**: Route advertisements need time to propagate from clients to server. Use `require.EventuallyWithT()` patterns in tests instead of immediate assertions.
|
||||
|
||||
2. **Synchronization Points**: NodeStore updates happen at specific points like `poll.go:420` after Hostinfo changes. Ensure these are maintained when modifying the polling logic.
|
||||
|
||||
3. **Peer Visibility**: The NodeStore's `peersFunc` determines which nodes are visible to each other. Policy-based filtering is separate from monitoring visibility - expired nodes should remain visible for debugging but marked as expired.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Integration Test Patterns
|
||||
|
||||
#### **CRITICAL: EventuallyWithT Pattern for External Calls**
|
||||
|
||||
**All external calls in integration tests MUST be wrapped in EventuallyWithT blocks** to handle eventual consistency in distributed systems. External calls include:
|
||||
- `client.Status()` - Getting Tailscale client status
|
||||
- `client.Curl()` - Making HTTP requests through clients
|
||||
- `client.Traceroute()` - Running network diagnostics
|
||||
- `headscale.ListNodes()` - Querying headscale server state
|
||||
- Any other calls that interact with external systems or network operations
|
||||
|
||||
**Key Rules**:
|
||||
1. **Never use bare `require.NoError(t, err)` with external calls** - Always wrap in EventuallyWithT
|
||||
2. **Keep related assertions together** - If multiple assertions depend on the same external call, keep them in the same EventuallyWithT block
|
||||
3. **Split unrelated external calls** - Different external calls should be in separate EventuallyWithT blocks
|
||||
4. **Never nest EventuallyWithT calls** - Each EventuallyWithT should be at the same level
|
||||
5. **Declare shared variables at function scope** - Variables used across multiple EventuallyWithT blocks must be declared before first use
|
||||
|
||||
**Examples**:
|
||||
|
||||
```go
|
||||
// CORRECT: External call wrapped in EventuallyWithT
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
status, err := client.Status()
|
||||
assert.NoError(c, err)
|
||||
|
||||
// Related assertions using the same status call
|
||||
for _, peerKey := range status.Peers() {
|
||||
peerStatus := status.Peer[peerKey]
|
||||
assert.NotNil(c, peerStatus.PrimaryRoutes)
|
||||
requirePeerSubnetRoutesWithCollect(c, peerStatus, expectedRoutes)
|
||||
}
|
||||
}, 5*time.Second, 200*time.Millisecond, "Verifying client status and routes")
|
||||
|
||||
// INCORRECT: Bare external call without EventuallyWithT
|
||||
status, err := client.Status() // ❌ Will fail intermittently
|
||||
require.NoError(t, err)
|
||||
|
||||
// CORRECT: Separate EventuallyWithT for different external calls
|
||||
// First external call - headscale.ListNodes()
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
assert.Len(c, nodes, 2)
|
||||
requireNodeRouteCountWithCollect(c, nodes[0], 2, 2, 2)
|
||||
}, 10*time.Second, 500*time.Millisecond, "route state changes should propagate to nodes")
|
||||
|
||||
// Second external call - client.Status()
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
status, err := client.Status()
|
||||
assert.NoError(c, err)
|
||||
|
||||
for _, peerKey := range status.Peers() {
|
||||
peerStatus := status.Peer[peerKey]
|
||||
requirePeerSubnetRoutesWithCollect(c, peerStatus, []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()})
|
||||
}
|
||||
}, 10*time.Second, 500*time.Millisecond, "routes should be visible to client")
|
||||
|
||||
// INCORRECT: Multiple unrelated external calls in same EventuallyWithT
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes() // ❌ First external call
|
||||
assert.NoError(c, err)
|
||||
|
||||
status, err := client.Status() // ❌ Different external call - should be separate
|
||||
assert.NoError(c, err)
|
||||
}, 10*time.Second, 500*time.Millisecond, "mixed calls")
|
||||
|
||||
// CORRECT: Variable scoping for shared data
|
||||
var (
|
||||
srs1, srs2, srs3 *ipnstate.Status
|
||||
clientStatus *ipnstate.Status
|
||||
srs1PeerStatus *ipnstate.PeerStatus
|
||||
)
|
||||
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
srs1 = subRouter1.MustStatus() // = not :=
|
||||
srs2 = subRouter2.MustStatus()
|
||||
clientStatus = client.MustStatus()
|
||||
|
||||
srs1PeerStatus = clientStatus.Peer[srs1.Self.PublicKey]
|
||||
// assertions...
|
||||
}, 5*time.Second, 200*time.Millisecond, "checking router status")
|
||||
|
||||
// CORRECT: Wrapping client operations
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
result, err := client.Curl(weburl)
|
||||
assert.NoError(c, err)
|
||||
assert.Len(c, result, 13)
|
||||
}, 5*time.Second, 200*time.Millisecond, "Verifying HTTP connectivity")
|
||||
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
tr, err := client.Traceroute(webip)
|
||||
assert.NoError(c, err)
|
||||
assertTracerouteViaIPWithCollect(c, tr, expectedRouter.MustIPv4())
|
||||
}, 5*time.Second, 200*time.Millisecond, "Verifying network path")
|
||||
```
|
||||
|
||||
**Helper Functions**:
|
||||
- Use `requirePeerSubnetRoutesWithCollect` instead of `requirePeerSubnetRoutes` inside EventuallyWithT
|
||||
- Use `requireNodeRouteCountWithCollect` instead of `requireNodeRouteCount` inside EventuallyWithT
|
||||
- Use `assertTracerouteViaIPWithCollect` instead of `assertTracerouteViaIP` inside EventuallyWithT
|
||||
|
||||
```go
|
||||
// Node route checking by actual node properties, not array position
|
||||
var routeNode *v1.Node
|
||||
for _, node := range nodes {
|
||||
if nodeIDStr := fmt.Sprintf("%d", node.GetId()); expectedRoutes[nodeIDStr] != "" {
|
||||
routeNode = node
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Running Problematic Tests
|
||||
- Some tests require significant time (e.g., `TestNodeOnlineStatus` runs for 12 minutes)
|
||||
- Infrastructure issues like disk space can cause test failures unrelated to code changes
|
||||
- Use `--postgres` flag when testing database-heavy scenarios
|
||||
|
||||
## Quality Assurance and Testing Requirements
|
||||
|
||||
### **MANDATORY: Always Use Specialized Testing Agents**
|
||||
|
||||
**CRITICAL REQUIREMENT**: For ANY task involving testing, quality assurance, review, or validation, you MUST use the appropriate specialized agent at the END of your task list. This ensures comprehensive quality validation and prevents regressions.
|
||||
|
||||
**Required Agents for Different Task Types**:
|
||||
|
||||
1. **Integration Testing**: Use `headscale-integration-tester` agent for:
|
||||
- Running integration tests with `cmd/hi`
|
||||
- Analyzing test failures and artifacts
|
||||
- Troubleshooting Docker-based test infrastructure
|
||||
- Validating end-to-end functionality changes
|
||||
|
||||
2. **Quality Control**: Use `quality-control-enforcer` agent for:
|
||||
- Code review and validation
|
||||
- Ensuring best practices compliance
|
||||
- Preventing common pitfalls and anti-patterns
|
||||
- Validating architectural decisions
|
||||
|
||||
**Agent Usage Pattern**: Always add the appropriate agent as the FINAL step in any task list to ensure quality validation occurs after all work is complete.
|
||||
|
||||
### Integration Test Debugging Reference
|
||||
|
||||
Test artifacts are preserved in `control_logs/TIMESTAMP-ID/` including:
|
||||
- Headscale server logs (stderr/stdout)
|
||||
- Tailscale client logs and status
|
||||
- Database dumps and network captures
|
||||
- MapResponse JSON files for protocol debugging
|
||||
|
||||
**For integration test issues, ALWAYS use the headscale-integration-tester agent - do not attempt manual debugging.**
|
||||
|
||||
## EventuallyWithT Pattern for Integration Tests
|
||||
|
||||
### Overview
|
||||
EventuallyWithT is a testing pattern used to handle eventual consistency in distributed systems. In Headscale integration tests, many operations are asynchronous - clients advertise routes, the server processes them, updates propagate through the network. EventuallyWithT allows tests to wait for these operations to complete while making assertions.
|
||||
|
||||
### External Calls That Must Be Wrapped
|
||||
The following operations are **external calls** that interact with the headscale server or tailscale clients and MUST be wrapped in EventuallyWithT:
|
||||
- `headscale.ListNodes()` - Queries server state
|
||||
- `client.Status()` - Gets client network status
|
||||
- `client.Curl()` - Makes HTTP requests through the network
|
||||
- `client.Traceroute()` - Performs network diagnostics
|
||||
- `client.Execute()` when running commands that query state
|
||||
- Any operation that reads from the headscale server or tailscale client
|
||||
|
||||
### Operations That Must NOT Be Wrapped
|
||||
The following are **blocking operations** that modify state and should NOT be wrapped in EventuallyWithT:
|
||||
- `tailscale set` commands (e.g., `--advertise-routes`, `--exit-node`)
|
||||
- Any command that changes configuration or state
|
||||
- Use `client.MustStatus()` instead of `client.Status()` when you just need the ID for a blocking operation
|
||||
|
||||
### Five Key Rules for EventuallyWithT
|
||||
|
||||
1. **One External Call Per EventuallyWithT Block**
|
||||
- Each EventuallyWithT should make ONE external call (e.g., ListNodes OR Status)
|
||||
- Related assertions based on that single call can be grouped together
|
||||
- Unrelated external calls must be in separate EventuallyWithT blocks
|
||||
|
||||
2. **Variable Scoping**
|
||||
- Declare variables that need to be shared across EventuallyWithT blocks at function scope
|
||||
- Use `=` for assignment inside EventuallyWithT, not `:=` (unless the variable is only used within that block)
|
||||
- Variables declared with `:=` inside EventuallyWithT are not accessible outside
|
||||
|
||||
3. **No Nested EventuallyWithT**
|
||||
- NEVER put an EventuallyWithT inside another EventuallyWithT
|
||||
- This is a critical anti-pattern that must be avoided
|
||||
|
||||
4. **Use CollectT for Assertions**
|
||||
- Inside EventuallyWithT, use `assert` methods with the CollectT parameter
|
||||
- Helper functions called within EventuallyWithT must accept `*assert.CollectT`
|
||||
|
||||
5. **Descriptive Messages**
|
||||
- Always provide a descriptive message as the last parameter
|
||||
- Message should explain what condition is being waited for
|
||||
|
||||
### Correct Pattern Examples
|
||||
|
||||
```go
|
||||
// CORRECT: Blocking operation NOT wrapped
|
||||
for _, client := range allClients {
|
||||
status := client.MustStatus()
|
||||
command := []string{
|
||||
"tailscale",
|
||||
"set",
|
||||
"--advertise-routes=" + expectedRoutes[string(status.Self.ID)],
|
||||
}
|
||||
_, _, err = client.Execute(command)
|
||||
require.NoErrorf(t, err, "failed to advertise route: %s", err)
|
||||
}
|
||||
|
||||
// CORRECT: Single external call with related assertions
|
||||
var nodes []*v1.Node
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err = headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
assert.Len(c, nodes, 2)
|
||||
requireNodeRouteCountWithCollect(c, nodes[0], 2, 2, 2)
|
||||
}, 10*time.Second, 500*time.Millisecond, "nodes should have expected route counts")
|
||||
|
||||
// CORRECT: Separate EventuallyWithT for different external call
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
status, err := client.Status()
|
||||
assert.NoError(c, err)
|
||||
for _, peerKey := range status.Peers() {
|
||||
peerStatus := status.Peer[peerKey]
|
||||
requirePeerSubnetRoutesWithCollect(c, peerStatus, expectedPrefixes)
|
||||
}
|
||||
}, 10*time.Second, 500*time.Millisecond, "client should see expected routes")
|
||||
```
|
||||
|
||||
### Incorrect Patterns to Avoid
|
||||
|
||||
```go
|
||||
// INCORRECT: Blocking operation wrapped in EventuallyWithT
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
status, err := client.Status()
|
||||
assert.NoError(c, err)
|
||||
|
||||
// This is a blocking operation - should NOT be in EventuallyWithT!
|
||||
command := []string{
|
||||
"tailscale",
|
||||
"set",
|
||||
"--advertise-routes=" + expectedRoutes[string(status.Self.ID)],
|
||||
}
|
||||
_, _, err = client.Execute(command)
|
||||
assert.NoError(c, err)
|
||||
}, 5*time.Second, 200*time.Millisecond, "wrong pattern")
|
||||
|
||||
// INCORRECT: Multiple unrelated external calls in same EventuallyWithT
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
// First external call
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
assert.Len(c, nodes, 2)
|
||||
|
||||
// Second unrelated external call - WRONG!
|
||||
status, err := client.Status()
|
||||
assert.NoError(c, err)
|
||||
assert.NotNil(c, status)
|
||||
}, 10*time.Second, 500*time.Millisecond, "mixed operations")
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Dependencies**: Use `nix develop` for consistent toolchain (Go, buf, protobuf tools, linting)
|
||||
- **Protocol Buffers**: Changes to `proto/` require `make generate` and should be committed separately
|
||||
- **Code Style**: Enforced via golangci-lint with golines (width 88) and gofumpt formatting
|
||||
- **Database**: Supports both SQLite (development) and PostgreSQL (production/testing)
|
||||
- **Integration Tests**: Require Docker and can consume significant disk space - use headscale-integration-tester agent
|
||||
- **Performance**: NodeStore optimizations are critical for scale - be careful with changes to state management
|
||||
- **Quality Assurance**: Always use appropriate specialized agents for testing and validation tasks
|
||||
- **NEVER create gists in the user's name**: Do not use the `create_gist` tool - present information directly in the response instead
|
||||
@AGENTS.md
|
||||
|
||||
@@ -2,28 +2,43 @@
|
||||
# and are in no way endorsed by Headscale's maintainers as an
|
||||
# official nor supported release or distribution.
|
||||
|
||||
FROM docker.io/golang:1.25-trixie
|
||||
FROM docker.io/golang:1.25-trixie AS builder
|
||||
ARG VERSION=dev
|
||||
ENV GOPATH /go
|
||||
WORKDIR /go/src/headscale
|
||||
|
||||
RUN apt-get --update install --no-install-recommends --yes less jq sqlite3 dnsutils \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
RUN mkdir -p /var/run/headscale
|
||||
|
||||
# Install delve debugger
|
||||
# Install delve debugger first - rarely changes, good cache candidate
|
||||
RUN go install github.com/go-delve/delve/cmd/dlv@latest
|
||||
|
||||
# Download dependencies - only invalidated when go.mod/go.sum change
|
||||
COPY go.mod go.sum /go/src/headscale/
|
||||
RUN go mod download
|
||||
|
||||
# Copy source and build - invalidated on any source change
|
||||
COPY . .
|
||||
|
||||
# Build debug binary with debug symbols for delve
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -gcflags="all=-N -l" -o /go/bin/headscale ./cmd/headscale
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:trixie-slim
|
||||
|
||||
RUN apt-get --update install --no-install-recommends --yes \
|
||||
bash ca-certificates curl dnsutils findutils iproute2 jq less procps python3 sqlite3 \
|
||||
&& apt-get dist-clean
|
||||
|
||||
RUN mkdir -p /var/run/headscale
|
||||
|
||||
# Copy binaries from builder
|
||||
COPY --from=builder /go/bin/headscale /usr/local/bin/headscale
|
||||
COPY --from=builder /go/bin/dlv /usr/local/bin/dlv
|
||||
|
||||
# Copy source code for delve source-level debugging
|
||||
COPY --from=builder /go/src/headscale /go/src/headscale
|
||||
|
||||
WORKDIR /go/src/headscale
|
||||
|
||||
# Need to reset the entrypoint or everything will run as a busybox script
|
||||
ENTRYPOINT []
|
||||
EXPOSE 8080/tcp 40000/tcp
|
||||
CMD ["/go/bin/dlv", "--listen=0.0.0.0:40000", "--headless=true", "--api-version=2", "--accept-multiclient", "exec", "/go/bin/headscale", "--"]
|
||||
CMD ["dlv", "--listen=0.0.0.0:40000", "--headless=true", "--api-version=2", "--accept-multiclient", "exec", "/usr/local/bin/headscale", "--"]
|
||||
|
||||
17
Dockerfile.integration-ci
Normal file
@@ -0,0 +1,17 @@
|
||||
# Minimal CI image - expects pre-built headscale binary in build context
|
||||
# For local development with delve debugging, use Dockerfile.integration instead
|
||||
|
||||
FROM debian:trixie-slim
|
||||
|
||||
RUN apt-get --update install --no-install-recommends --yes \
|
||||
bash ca-certificates curl dnsutils findutils iproute2 jq less procps python3 sqlite3 \
|
||||
&& apt-get dist-clean
|
||||
|
||||
RUN mkdir -p /var/run/headscale
|
||||
|
||||
# Copy pre-built headscale binary from build context
|
||||
COPY headscale /usr/local/bin/headscale
|
||||
|
||||
ENTRYPOINT []
|
||||
EXPOSE 8080/tcp
|
||||
CMD ["/usr/local/bin/headscale"]
|
||||
@@ -37,7 +37,9 @@ RUN GOARCH=$TARGETARCH go install -tags="${BUILD_TAGS}" -ldflags="\
|
||||
-v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot
|
||||
|
||||
FROM alpine:3.22
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables curl
|
||||
# Upstream: ca-certificates ip6tables iptables iproute2
|
||||
# Tests: curl python3 (traceroute via BusyBox)
|
||||
RUN apk add --no-cache ca-certificates curl ip6tables iptables iproute2 python3
|
||||
|
||||
COPY --from=build-env /go/bin/* /usr/local/bin/
|
||||
# For compat with the previous run.sh, although ideally you should be
|
||||
|
||||
5
Makefile
@@ -64,7 +64,6 @@ fmt-go: check-deps $(GO_SOURCES)
|
||||
fmt-prettier: check-deps $(DOC_SOURCES)
|
||||
@echo "Formatting documentation and config files..."
|
||||
prettier --write '**/*.{ts,js,md,yaml,yml,sass,css,scss,html}'
|
||||
prettier --write --print-width 80 --prose-wrap always CHANGELOG.md
|
||||
|
||||
.PHONY: fmt-proto
|
||||
fmt-proto: check-deps $(PROTO_SOURCES)
|
||||
@@ -117,7 +116,7 @@ help:
|
||||
@echo ""
|
||||
@echo "Specific targets:"
|
||||
@echo " fmt-go - Format Go code only"
|
||||
@echo " fmt-prettier - Format documentation only"
|
||||
@echo " fmt-prettier - Format documentation only"
|
||||
@echo " fmt-proto - Format Protocol Buffer files only"
|
||||
@echo " lint-go - Lint Go code only"
|
||||
@echo " lint-proto - Lint Protocol Buffer files only"
|
||||
@@ -126,4 +125,4 @@ help:
|
||||
@echo " check-deps - Verify required tools are available"
|
||||
@echo ""
|
||||
@echo "Note: If not running in a nix shell, ensure dependencies are available:"
|
||||
@echo " nix develop"
|
||||
@echo " nix develop"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
@@ -63,6 +63,8 @@ and container to run Headscale.**
|
||||
|
||||
Please have a look at the [`documentation`](https://headscale.net/stable/).
|
||||
|
||||
For NixOS users, a module is available in [`nix/`](./nix/).
|
||||
|
||||
## Talks
|
||||
|
||||
- Fosdem 2023 (video): [Headscale: How we are using integration testing to reimplement Tailscale](https://fosdem.org/2023/schedule/event/goheadscale/)
|
||||
@@ -147,6 +149,7 @@ make build
|
||||
We recommend using Nix for dependency management to ensure you have all required tools. If you prefer to manage dependencies yourself, you can use Make directly:
|
||||
|
||||
**With Nix (recommended):**
|
||||
|
||||
```shell
|
||||
nix develop
|
||||
make test
|
||||
@@ -154,6 +157,7 @@ make build
|
||||
```
|
||||
|
||||
**With your own dependencies:**
|
||||
|
||||
```shell
|
||||
make test
|
||||
make build
|
||||
|
||||
@@ -10,10 +10,6 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
errPreAuthKeyMalformed = Error("key is malformed. expected 64 hex characters with `nodekey` prefix")
|
||||
)
|
||||
|
||||
// Error is used to compare errors as per https://dave.cheney.net/2016/04/07/constant-errors
|
||||
type Error string
|
||||
|
||||
|
||||
@@ -73,26 +73,6 @@ func init() {
|
||||
}
|
||||
nodeCmd.AddCommand(deleteNodeCmd)
|
||||
|
||||
moveNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
|
||||
err = moveNodeCmd.MarkFlagRequired("identifier")
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
|
||||
moveNodeCmd.Flags().Uint64P("user", "u", 0, "New user")
|
||||
|
||||
moveNodeCmd.Flags().StringP("namespace", "n", "", "User")
|
||||
moveNodeNamespaceFlag := moveNodeCmd.Flags().Lookup("namespace")
|
||||
moveNodeNamespaceFlag.Deprecated = deprecateNamespaceMessage
|
||||
moveNodeNamespaceFlag.Hidden = true
|
||||
|
||||
err = moveNodeCmd.MarkFlagRequired("user")
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
nodeCmd.AddCommand(moveNodeCmd)
|
||||
|
||||
tagCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
tagCmd.MarkFlagRequired("identifier")
|
||||
tagCmd.Flags().StringSliceP("tags", "t", []string{}, "List of tags to add to the node")
|
||||
@@ -240,10 +220,6 @@ var listNodeRoutesCmd = &cobra.Command{
|
||||
)
|
||||
}
|
||||
|
||||
if output != "" {
|
||||
SuccessOutput(response.GetNodes(), "", output)
|
||||
}
|
||||
|
||||
nodes := response.GetNodes()
|
||||
if identifier != 0 {
|
||||
for _, node := range response.GetNodes() {
|
||||
@@ -258,6 +234,11 @@ var listNodeRoutesCmd = &cobra.Command{
|
||||
return (n.GetSubnetRoutes() != nil && len(n.GetSubnetRoutes()) > 0) || (n.GetApprovedRoutes() != nil && len(n.GetApprovedRoutes()) > 0) || (n.GetAvailableRoutes() != nil && len(n.GetAvailableRoutes()) > 0)
|
||||
})
|
||||
|
||||
if output != "" {
|
||||
SuccessOutput(nodes, "", output)
|
||||
return
|
||||
}
|
||||
|
||||
tableData, err := nodeRoutesToPtables(nodes)
|
||||
if err != nil {
|
||||
ErrorOutput(err, fmt.Sprintf("Error converting to table: %s", err), output)
|
||||
@@ -455,66 +436,6 @@ var deleteNodeCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var moveNodeCmd = &cobra.Command{
|
||||
Use: "move",
|
||||
Short: "Move node to another user",
|
||||
Aliases: []string{"mv"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
user, err := cmd.Flags().GetUint64("user")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting user: %s", err),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := newHeadscaleCLIWithConfig()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
getRequest := &v1.GetNodeRequest{
|
||||
NodeId: identifier,
|
||||
}
|
||||
|
||||
_, err = client.GetNode(ctx, getRequest)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
"Error getting node: "+status.Convert(err).Message(),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
moveRequest := &v1.MoveNodeRequest{
|
||||
NodeId: identifier,
|
||||
User: user,
|
||||
}
|
||||
|
||||
moveResponse, err := client.MoveNode(ctx, moveRequest)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
"Error moving node: "+status.Convert(err).Message(),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
SuccessOutput(moveResponse.GetNode(), "Node moved to another user", output)
|
||||
},
|
||||
}
|
||||
|
||||
var backfillNodeIPsCmd = &cobra.Command{
|
||||
Use: "backfillips",
|
||||
Short: "Backfill IPs missing from nodes",
|
||||
@@ -641,23 +562,26 @@ func nodesToPtables(
|
||||
|
||||
var forcedTags string
|
||||
for _, tag := range node.GetForcedTags() {
|
||||
forcedTags += "," + tag
|
||||
forcedTags += "\n" + tag
|
||||
}
|
||||
forcedTags = strings.TrimLeft(forcedTags, ",")
|
||||
|
||||
forcedTags = strings.TrimLeft(forcedTags, "\n")
|
||||
var invalidTags string
|
||||
for _, tag := range node.GetInvalidTags() {
|
||||
if !slices.Contains(node.GetForcedTags(), tag) {
|
||||
invalidTags += "," + pterm.LightRed(tag)
|
||||
invalidTags += "\n" + pterm.LightRed(tag)
|
||||
}
|
||||
}
|
||||
invalidTags = strings.TrimLeft(invalidTags, ",")
|
||||
|
||||
invalidTags = strings.TrimLeft(invalidTags, "\n")
|
||||
var validTags string
|
||||
for _, tag := range node.GetValidTags() {
|
||||
if !slices.Contains(node.GetForcedTags(), tag) {
|
||||
validTags += "," + pterm.LightGreen(tag)
|
||||
validTags += "\n" + pterm.LightGreen(tag)
|
||||
}
|
||||
}
|
||||
validTags = strings.TrimLeft(validTags, ",")
|
||||
|
||||
validTags = strings.TrimLeft(validTags, "\n")
|
||||
|
||||
var user string
|
||||
if currentUser == "" || (currentUser == node.GetUser().GetName()) {
|
||||
@@ -719,9 +643,9 @@ func nodeRoutesToPtables(
|
||||
nodeData := []string{
|
||||
strconv.FormatUint(node.GetId(), util.Base10),
|
||||
node.GetGivenName(),
|
||||
strings.Join(node.GetApprovedRoutes(), ", "),
|
||||
strings.Join(node.GetAvailableRoutes(), ", "),
|
||||
strings.Join(node.GetSubnetRoutes(), ", "),
|
||||
strings.Join(node.GetApprovedRoutes(), "\n"),
|
||||
strings.Join(node.GetAvailableRoutes(), "\n"),
|
||||
strings.Join(node.GetSubnetRoutes(), "\n"),
|
||||
}
|
||||
tableData = append(
|
||||
tableData,
|
||||
|
||||
@@ -34,6 +34,7 @@ func init() {
|
||||
preauthkeysCmd.AddCommand(listPreAuthKeys)
|
||||
preauthkeysCmd.AddCommand(createPreAuthKeyCmd)
|
||||
preauthkeysCmd.AddCommand(expirePreAuthKeyCmd)
|
||||
preauthkeysCmd.AddCommand(deletePreAuthKeyCmd)
|
||||
createPreAuthKeyCmd.PersistentFlags().
|
||||
Bool("reusable", false, "Make the preauthkey reusable")
|
||||
createPreAuthKeyCmd.PersistentFlags().
|
||||
@@ -88,7 +89,7 @@ var listPreAuthKeys = &cobra.Command{
|
||||
tableData := pterm.TableData{
|
||||
{
|
||||
"ID",
|
||||
"Key",
|
||||
"Key/Prefix",
|
||||
"Reusable",
|
||||
"Ephemeral",
|
||||
"Used",
|
||||
@@ -106,10 +107,10 @@ var listPreAuthKeys = &cobra.Command{
|
||||
aclTags := ""
|
||||
|
||||
for _, tag := range key.GetAclTags() {
|
||||
aclTags += "," + tag
|
||||
aclTags += "\n" + tag
|
||||
}
|
||||
|
||||
aclTags = strings.TrimLeft(aclTags, ",")
|
||||
aclTags = strings.TrimLeft(aclTags, "\n")
|
||||
|
||||
tableData = append(tableData, []string{
|
||||
strconv.FormatUint(key.GetId(), 10),
|
||||
@@ -232,3 +233,43 @@ var expirePreAuthKeyCmd = &cobra.Command{
|
||||
SuccessOutput(response, "Key expired", output)
|
||||
},
|
||||
}
|
||||
|
||||
var deletePreAuthKeyCmd = &cobra.Command{
|
||||
Use: "delete KEY",
|
||||
Short: "Delete a preauthkey",
|
||||
Aliases: []string{"del", "rm", "d"},
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errMissingParameter
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
user, err := cmd.Flags().GetUint64("user")
|
||||
if err != nil {
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := newHeadscaleCLIWithConfig()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.DeletePreAuthKeyRequest{
|
||||
User: user,
|
||||
Key: args[0],
|
||||
}
|
||||
|
||||
response, err := client.DeletePreAuthKey(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot delete Pre Auth Key: %s\n", err),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
SuccessOutput(response, "Key deleted", output)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
|
||||
return ctx, client, conn, cancel
|
||||
}
|
||||
|
||||
func output(result interface{}, override string, outputFormat string) string {
|
||||
func output(result any, override string, outputFormat string) string {
|
||||
var jsonBytes []byte
|
||||
var err error
|
||||
switch outputFormat {
|
||||
@@ -158,7 +158,7 @@ func output(result interface{}, override string, outputFormat string) string {
|
||||
}
|
||||
|
||||
// SuccessOutput prints the result to stdout and exits with status code 0.
|
||||
func SuccessOutput(result interface{}, override string, outputFormat string) {
|
||||
func SuccessOutput(result any, override string, outputFormat string) {
|
||||
fmt.Println(output(result, override, outputFormat))
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
6
cmd/hi/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# hi
|
||||
|
||||
hi (headscale integration runner) is an entirely "vibe coded" wrapper around our
|
||||
[integration test suite](../integration). It essentially runs the docker
|
||||
commands for you with some added benefits of extracting resources like logs and
|
||||
databases.
|
||||
@@ -3,9 +3,13 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v5"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
@@ -14,9 +18,11 @@ import (
|
||||
)
|
||||
|
||||
// cleanupBeforeTest performs cleanup operations before running tests.
|
||||
// Only removes stale (stopped/exited) test containers to avoid interfering with concurrent test runs.
|
||||
func cleanupBeforeTest(ctx context.Context) error {
|
||||
if err := killTestContainers(ctx); err != nil {
|
||||
return fmt.Errorf("failed to kill test containers: %w", err)
|
||||
err := cleanupStaleTestContainers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clean stale test containers: %w", err)
|
||||
}
|
||||
|
||||
if err := pruneDockerNetworks(ctx); err != nil {
|
||||
@@ -26,11 +32,25 @@ func cleanupBeforeTest(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupAfterTest removes the test container after completion.
|
||||
func cleanupAfterTest(ctx context.Context, cli *client.Client, containerID string) error {
|
||||
return cli.ContainerRemove(ctx, containerID, container.RemoveOptions{
|
||||
// cleanupAfterTest removes the test container and all associated integration test containers for the run.
|
||||
func cleanupAfterTest(ctx context.Context, cli *client.Client, containerID, runID string) error {
|
||||
// Remove the main test container
|
||||
err := cli.ContainerRemove(ctx, containerID, container.RemoveOptions{
|
||||
Force: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove test container: %w", err)
|
||||
}
|
||||
|
||||
// Clean up integration test containers for this run only
|
||||
if runID != "" {
|
||||
err := killTestContainersByRunID(ctx, runID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clean up containers for run %s: %w", runID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// killTestContainers terminates and removes all test containers.
|
||||
@@ -83,30 +103,122 @@ func killTestContainers(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// killTestContainersByRunID terminates and removes all test containers for a specific run ID.
|
||||
// This function filters containers by the hi.run-id label to only affect containers
|
||||
// belonging to the specified test run, leaving other concurrent test runs untouched.
|
||||
func killTestContainersByRunID(ctx context.Context, runID string) error {
|
||||
cli, err := createDockerClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Docker client: %w", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
// Filter containers by hi.run-id label
|
||||
containers, err := cli.ContainerList(ctx, container.ListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(
|
||||
filters.Arg("label", "hi.run-id="+runID),
|
||||
),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list containers for run %s: %w", runID, err)
|
||||
}
|
||||
|
||||
removed := 0
|
||||
|
||||
for _, cont := range containers {
|
||||
// Kill the container if it's running
|
||||
if cont.State == "running" {
|
||||
_ = cli.ContainerKill(ctx, cont.ID, "KILL")
|
||||
}
|
||||
|
||||
// Remove the container with retry logic
|
||||
if removeContainerWithRetry(ctx, cli, cont.ID) {
|
||||
removed++
|
||||
}
|
||||
}
|
||||
|
||||
if removed > 0 {
|
||||
fmt.Printf("Removed %d containers for run ID %s\n", removed, runID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupStaleTestContainers removes stopped/exited test containers without affecting running tests.
|
||||
// This is useful for cleaning up leftover containers from previous crashed or interrupted test runs
|
||||
// without interfering with currently running concurrent tests.
|
||||
func cleanupStaleTestContainers(ctx context.Context) error {
|
||||
cli, err := createDockerClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Docker client: %w", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
// Only get stopped/exited containers
|
||||
containers, err := cli.ContainerList(ctx, container.ListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(
|
||||
filters.Arg("status", "exited"),
|
||||
filters.Arg("status", "dead"),
|
||||
),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list stopped containers: %w", err)
|
||||
}
|
||||
|
||||
removed := 0
|
||||
|
||||
for _, cont := range containers {
|
||||
// Only remove containers that look like test containers
|
||||
shouldRemove := false
|
||||
|
||||
for _, name := range cont.Names {
|
||||
if strings.Contains(name, "headscale-test-suite") ||
|
||||
strings.Contains(name, "hs-") ||
|
||||
strings.Contains(name, "ts-") ||
|
||||
strings.Contains(name, "derp-") {
|
||||
shouldRemove = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if shouldRemove {
|
||||
if removeContainerWithRetry(ctx, cli, cont.ID) {
|
||||
removed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if removed > 0 {
|
||||
fmt.Printf("Removed %d stale test containers\n", removed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
containerRemoveInitialInterval = 100 * time.Millisecond
|
||||
containerRemoveMaxElapsedTime = 2 * time.Second
|
||||
)
|
||||
|
||||
// removeContainerWithRetry attempts to remove a container with exponential backoff retry logic.
|
||||
func removeContainerWithRetry(ctx context.Context, cli *client.Client, containerID string) bool {
|
||||
maxRetries := 3
|
||||
baseDelay := 100 * time.Millisecond
|
||||
expBackoff := backoff.NewExponentialBackOff()
|
||||
expBackoff.InitialInterval = containerRemoveInitialInterval
|
||||
|
||||
for attempt := range maxRetries {
|
||||
_, err := backoff.Retry(ctx, func() (struct{}, error) {
|
||||
err := cli.ContainerRemove(ctx, containerID, container.RemoveOptions{
|
||||
Force: true,
|
||||
})
|
||||
if err == nil {
|
||||
return true
|
||||
if err != nil {
|
||||
return struct{}{}, err
|
||||
}
|
||||
|
||||
// If this is the last attempt, don't wait
|
||||
if attempt == maxRetries-1 {
|
||||
break
|
||||
}
|
||||
return struct{}{}, nil
|
||||
}, backoff.WithBackOff(expBackoff), backoff.WithMaxElapsedTime(containerRemoveMaxElapsedTime))
|
||||
|
||||
// Wait with exponential backoff
|
||||
delay := baseDelay * time.Duration(1<<attempt)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
return false
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// pruneDockerNetworks removes unused Docker networks.
|
||||
@@ -205,3 +317,110 @@ func cleanCacheVolume(ctx context.Context) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupSuccessfulTestArtifacts removes artifacts from successful test runs to save disk space.
|
||||
// This function removes large artifacts that are mainly useful for debugging failures:
|
||||
// - Database dumps (.db files)
|
||||
// - Profile data (pprof directories)
|
||||
// - MapResponse data (mapresponses directories)
|
||||
// - Prometheus metrics files
|
||||
//
|
||||
// It preserves:
|
||||
// - Log files (.log) which are small and useful for verification.
|
||||
func cleanupSuccessfulTestArtifacts(logsDir string, verbose bool) error {
|
||||
entries, err := os.ReadDir(logsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read logs directory: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
removedFiles, removedDirs int
|
||||
totalSize int64
|
||||
)
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
fullPath := filepath.Join(logsDir, name)
|
||||
|
||||
if entry.IsDir() {
|
||||
// Remove pprof and mapresponses directories (typically large)
|
||||
// These directories contain artifacts from all containers in the test run
|
||||
if name == "pprof" || name == "mapresponses" {
|
||||
size, sizeErr := getDirSize(fullPath)
|
||||
if sizeErr == nil {
|
||||
totalSize += size
|
||||
}
|
||||
|
||||
err := os.RemoveAll(fullPath)
|
||||
if err != nil {
|
||||
if verbose {
|
||||
log.Printf("Warning: failed to remove directory %s: %v", name, err)
|
||||
}
|
||||
} else {
|
||||
removedDirs++
|
||||
|
||||
if verbose {
|
||||
log.Printf("Removed directory: %s/", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Only process test-related files (headscale and tailscale)
|
||||
if !strings.HasPrefix(name, "hs-") && !strings.HasPrefix(name, "ts-") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove database, metrics, and status files, but keep logs
|
||||
shouldRemove := strings.HasSuffix(name, ".db") ||
|
||||
strings.HasSuffix(name, "_metrics.txt") ||
|
||||
strings.HasSuffix(name, "_status.json")
|
||||
|
||||
if shouldRemove {
|
||||
info, infoErr := entry.Info()
|
||||
if infoErr == nil {
|
||||
totalSize += info.Size()
|
||||
}
|
||||
|
||||
err := os.Remove(fullPath)
|
||||
if err != nil {
|
||||
if verbose {
|
||||
log.Printf("Warning: failed to remove file %s: %v", name, err)
|
||||
}
|
||||
} else {
|
||||
removedFiles++
|
||||
|
||||
if verbose {
|
||||
log.Printf("Removed file: %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if removedFiles > 0 || removedDirs > 0 {
|
||||
const bytesPerMB = 1024 * 1024
|
||||
log.Printf("Cleaned up %d files and %d directories (freed ~%.2f MB)",
|
||||
removedFiles, removedDirs, float64(totalSize)/bytesPerMB)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDirSize calculates the total size of a directory.
|
||||
func getDirSize(path string) (int64, error) {
|
||||
var size int64
|
||||
|
||||
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
size += info.Size()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return size, err
|
||||
}
|
||||
|
||||
162
cmd/hi/docker.go
@@ -89,6 +89,9 @@ func runTestContainer(ctx context.Context, config *RunConfig) error {
|
||||
}
|
||||
|
||||
log.Printf("Starting test: %s", config.TestPattern)
|
||||
log.Printf("Run ID: %s", runID)
|
||||
log.Printf("Monitor with: docker logs -f %s", containerName)
|
||||
log.Printf("Logs directory: %s", logsDir)
|
||||
|
||||
// Start stats collection for container resource monitoring (if enabled)
|
||||
var statsCollector *StatsCollector
|
||||
@@ -149,11 +152,27 @@ func runTestContainer(ctx context.Context, config *RunConfig) error {
|
||||
shouldCleanup := config.CleanAfter && (!config.KeepOnFailure || exitCode == 0)
|
||||
if shouldCleanup {
|
||||
if config.Verbose {
|
||||
log.Printf("Running post-test cleanup...")
|
||||
log.Printf("Running post-test cleanup for run %s...", runID)
|
||||
}
|
||||
if cleanErr := cleanupAfterTest(ctx, cli, resp.ID); cleanErr != nil && config.Verbose {
|
||||
|
||||
cleanErr := cleanupAfterTest(ctx, cli, resp.ID, runID)
|
||||
|
||||
if cleanErr != nil && config.Verbose {
|
||||
log.Printf("Warning: post-test cleanup failed: %v", cleanErr)
|
||||
}
|
||||
|
||||
// Clean up artifacts from successful tests to save disk space in CI
|
||||
if exitCode == 0 {
|
||||
if config.Verbose {
|
||||
log.Printf("Test succeeded, cleaning up artifacts to save disk space...")
|
||||
}
|
||||
|
||||
cleanErr := cleanupSuccessfulTestArtifacts(logsDir, config.Verbose)
|
||||
|
||||
if cleanErr != nil && config.Verbose {
|
||||
log.Printf("Warning: artifact cleanup failed: %v", cleanErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -202,6 +221,28 @@ func createGoTestContainer(ctx context.Context, cli *client.Client, config *RunC
|
||||
fmt.Sprintf("HEADSCALE_INTEGRATION_POSTGRES=%d", boolToInt(config.UsePostgres)),
|
||||
"HEADSCALE_INTEGRATION_RUN_ID=" + runID,
|
||||
}
|
||||
|
||||
// Pass through CI environment variable for CI detection
|
||||
if ci := os.Getenv("CI"); ci != "" {
|
||||
env = append(env, "CI="+ci)
|
||||
}
|
||||
|
||||
// Pass through all HEADSCALE_INTEGRATION_* environment variables
|
||||
for _, e := range os.Environ() {
|
||||
if strings.HasPrefix(e, "HEADSCALE_INTEGRATION_") {
|
||||
// Skip the ones we already set explicitly
|
||||
if strings.HasPrefix(e, "HEADSCALE_INTEGRATION_POSTGRES=") ||
|
||||
strings.HasPrefix(e, "HEADSCALE_INTEGRATION_RUN_ID=") {
|
||||
continue
|
||||
}
|
||||
|
||||
env = append(env, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Set GOCACHE to a known location (used by both bind mount and volume cases)
|
||||
env = append(env, "GOCACHE=/cache/go-build")
|
||||
|
||||
containerConfig := &container.Config{
|
||||
Image: "golang:" + config.GoVersion,
|
||||
Cmd: goTestCmd,
|
||||
@@ -221,20 +262,43 @@ func createGoTestContainer(ctx context.Context, cli *client.Client, config *RunC
|
||||
log.Printf("Using Docker socket: %s", dockerSocketPath)
|
||||
}
|
||||
|
||||
binds := []string{
|
||||
fmt.Sprintf("%s:%s", projectRoot, projectRoot),
|
||||
dockerSocketPath + ":/var/run/docker.sock",
|
||||
logsDir + ":/tmp/control",
|
||||
}
|
||||
|
||||
// Use bind mounts for Go cache if provided via environment variables,
|
||||
// otherwise fall back to Docker volumes for local development
|
||||
var mounts []mount.Mount
|
||||
|
||||
goCache := os.Getenv("HEADSCALE_INTEGRATION_GO_CACHE")
|
||||
goBuildCache := os.Getenv("HEADSCALE_INTEGRATION_GO_BUILD_CACHE")
|
||||
|
||||
if goCache != "" {
|
||||
binds = append(binds, goCache+":/go")
|
||||
} else {
|
||||
mounts = append(mounts, mount.Mount{
|
||||
Type: mount.TypeVolume,
|
||||
Source: "hs-integration-go-cache",
|
||||
Target: "/go",
|
||||
})
|
||||
}
|
||||
|
||||
if goBuildCache != "" {
|
||||
binds = append(binds, goBuildCache+":/cache/go-build")
|
||||
} else {
|
||||
mounts = append(mounts, mount.Mount{
|
||||
Type: mount.TypeVolume,
|
||||
Source: "hs-integration-go-build-cache",
|
||||
Target: "/cache/go-build",
|
||||
})
|
||||
}
|
||||
|
||||
hostConfig := &container.HostConfig{
|
||||
AutoRemove: false, // We'll remove manually for better control
|
||||
Binds: []string{
|
||||
fmt.Sprintf("%s:%s", projectRoot, projectRoot),
|
||||
dockerSocketPath + ":/var/run/docker.sock",
|
||||
logsDir + ":/tmp/control",
|
||||
},
|
||||
Mounts: []mount.Mount{
|
||||
{
|
||||
Type: mount.TypeVolume,
|
||||
Source: "hs-integration-go-cache",
|
||||
Target: "/go",
|
||||
},
|
||||
},
|
||||
Binds: binds,
|
||||
Mounts: mounts,
|
||||
}
|
||||
|
||||
return cli.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, containerName)
|
||||
@@ -357,10 +421,10 @@ func boolToInt(b bool) int {
|
||||
|
||||
// DockerContext represents Docker context information.
|
||||
type DockerContext struct {
|
||||
Name string `json:"Name"`
|
||||
Metadata map[string]interface{} `json:"Metadata"`
|
||||
Endpoints map[string]interface{} `json:"Endpoints"`
|
||||
Current bool `json:"Current"`
|
||||
Name string `json:"Name"`
|
||||
Metadata map[string]any `json:"Metadata"`
|
||||
Endpoints map[string]any `json:"Endpoints"`
|
||||
Current bool `json:"Current"`
|
||||
}
|
||||
|
||||
// createDockerClient creates a Docker client with context detection.
|
||||
@@ -375,7 +439,7 @@ func createDockerClient() (*client.Client, error) {
|
||||
|
||||
if contextInfo != nil {
|
||||
if endpoints, ok := contextInfo.Endpoints["docker"]; ok {
|
||||
if endpointMap, ok := endpoints.(map[string]interface{}); ok {
|
||||
if endpointMap, ok := endpoints.(map[string]any); ok {
|
||||
if host, ok := endpointMap["Host"].(string); ok {
|
||||
if runConfig.Verbose {
|
||||
log.Printf("Using Docker host from context '%s': %s", contextInfo.Name, host)
|
||||
@@ -701,63 +765,3 @@ func extractContainerFiles(ctx context.Context, cli *client.Client, containerID,
|
||||
// This function is kept for potential future use or other file types
|
||||
return nil
|
||||
}
|
||||
|
||||
// logExtractionError logs extraction errors with appropriate level based on error type.
|
||||
func logExtractionError(artifactType, containerName string, err error, verbose bool) {
|
||||
if errors.Is(err, ErrFileNotFoundInTar) {
|
||||
// File not found is expected and only logged in verbose mode
|
||||
if verbose {
|
||||
log.Printf("No %s found in container %s", artifactType, containerName)
|
||||
}
|
||||
} else {
|
||||
// Other errors are actual failures and should be logged as warnings
|
||||
log.Printf("Warning: failed to extract %s from %s: %v", artifactType, containerName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// extractSingleFile copies a single file from a container.
|
||||
func extractSingleFile(ctx context.Context, cli *client.Client, containerID, sourcePath, fileName, logsDir string, verbose bool) error {
|
||||
tarReader, _, err := cli.CopyFromContainer(ctx, containerID, sourcePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy %s from container: %w", sourcePath, err)
|
||||
}
|
||||
defer tarReader.Close()
|
||||
|
||||
// Extract the single file from the tar
|
||||
filePath := filepath.Join(logsDir, fileName)
|
||||
if err := extractFileFromTar(tarReader, filepath.Base(sourcePath), filePath); err != nil {
|
||||
return fmt.Errorf("failed to extract file from tar: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
log.Printf("Extracted %s from %s", fileName, containerID[:12])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractDirectory copies a directory from a container and extracts its contents.
|
||||
func extractDirectory(ctx context.Context, cli *client.Client, containerID, sourcePath, dirName, logsDir string, verbose bool) error {
|
||||
tarReader, _, err := cli.CopyFromContainer(ctx, containerID, sourcePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy %s from container: %w", sourcePath, err)
|
||||
}
|
||||
defer tarReader.Close()
|
||||
|
||||
// Create target directory
|
||||
targetDir := filepath.Join(logsDir, dirName)
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", targetDir, err)
|
||||
}
|
||||
|
||||
// Extract the directory from the tar
|
||||
if err := extractDirectoryFromTar(tarReader, targetDir); err != nil {
|
||||
return fmt.Errorf("failed to extract directory from tar: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
log.Printf("Extracted %s/ from %s", dirName, containerID[:12])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ type RunConfig struct {
|
||||
FailFast bool `flag:"failfast,default=true,Stop on first test failure"`
|
||||
UsePostgres bool `flag:"postgres,default=false,Use PostgreSQL instead of SQLite"`
|
||||
GoVersion string `flag:"go-version,Go version to use (auto-detected from go.mod)"`
|
||||
CleanBefore bool `flag:"clean-before,default=true,Clean resources before test"`
|
||||
CleanBefore bool `flag:"clean-before,default=true,Clean stale resources before test"`
|
||||
CleanAfter bool `flag:"clean-after,default=true,Clean resources after test"`
|
||||
KeepOnFailure bool `flag:"keep-on-failure,default=false,Keep containers on test failure"`
|
||||
LogsDir string `flag:"logs-dir,default=control_logs,Control logs directory"`
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrFileNotFoundInTar indicates a file was not found in the tar archive.
|
||||
var ErrFileNotFoundInTar = errors.New("file not found in tar")
|
||||
|
||||
// extractFileFromTar extracts a single file from a tar reader.
|
||||
func extractFileFromTar(tarReader io.Reader, fileName, outputPath string) error {
|
||||
tr := tar.NewReader(tarReader)
|
||||
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read tar header: %w", err)
|
||||
}
|
||||
|
||||
// Check if this is the file we're looking for
|
||||
if filepath.Base(header.Name) == fileName {
|
||||
if header.Typeflag == tar.TypeReg {
|
||||
// Create the output file
|
||||
outFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
// Copy file contents
|
||||
if _, err := io.Copy(outFile, tr); err != nil {
|
||||
return fmt.Errorf("failed to copy file contents: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: %s", ErrFileNotFoundInTar, fileName)
|
||||
}
|
||||
|
||||
// extractDirectoryFromTar extracts all files from a tar reader to a target directory.
|
||||
func extractDirectoryFromTar(tarReader io.Reader, targetDir string) error {
|
||||
tr := tar.NewReader(tarReader)
|
||||
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read tar header: %w", err)
|
||||
}
|
||||
|
||||
// Clean the path to prevent directory traversal
|
||||
cleanName := filepath.Clean(header.Name)
|
||||
if strings.Contains(cleanName, "..") {
|
||||
continue // Skip potentially dangerous paths
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(targetDir, cleanName)
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
// Create directory
|
||||
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", targetPath, err)
|
||||
}
|
||||
case tar.TypeReg:
|
||||
// Ensure parent directories exist
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directories for %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
// Create file
|
||||
outFile, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(outFile, tr); err != nil {
|
||||
outFile.Close()
|
||||
return fmt.Errorf("failed to copy file contents: %w", err)
|
||||
}
|
||||
outFile.Close()
|
||||
|
||||
// Set file permissions
|
||||
if err := os.Chmod(targetPath, os.FileMode(header.Mode)); err != nil {
|
||||
return fmt.Errorf("failed to set file permissions: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -407,3 +407,23 @@ logtail:
|
||||
# default static port 41641. This option is intended as a workaround for some buggy
|
||||
# firewall devices. See https://tailscale.com/kb/1181/firewalls/ for more information.
|
||||
randomize_client_port: false
|
||||
|
||||
# Taildrop configuration
|
||||
# Taildrop is the file sharing feature of Tailscale, allowing nodes to send files to each other.
|
||||
# https://tailscale.com/kb/1106/taildrop/
|
||||
taildrop:
|
||||
# Enable or disable Taildrop for all nodes.
|
||||
# When enabled, nodes can send files to other nodes owned by the same user.
|
||||
# Tagged devices and cross-user transfers are not permitted by Tailscale clients.
|
||||
enabled: true
|
||||
# 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.
|
||||
#
|
||||
# tuning:
|
||||
# # NodeStore write batching configuration.
|
||||
# # The NodeStore batches write operations before rebuilding peer relationships,
|
||||
# # which is computationally expensive. Batching reduces rebuild frequency.
|
||||
# #
|
||||
# # node_store_batch_size: 100
|
||||
# # node_store_batch_timeout: 500ms
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# If you plan to somehow use headscale, please deploy your own DERP infra: https://tailscale.com/kb/1118/custom-derp-servers/
|
||||
regions:
|
||||
1: null # Disable DERP region with ID 1
|
||||
1: null # Disable DERP region with ID 1
|
||||
900:
|
||||
regionid: 900
|
||||
regioncode: custom
|
||||
|
||||
@@ -157,7 +157,7 @@ indicates which part of the policy is invalid. Follow these steps to fix your po
|
||||
!!! warning "Full server configuration required"
|
||||
|
||||
The above commands to get/set the policy require a complete server configuration file including database settings. A
|
||||
minimal config to [control Headscale via remote CLI](../ref/remote-cli.md) is not sufficient. You may use `headscale
|
||||
minimal config to [control Headscale via remote CLI](../ref/api.md#grpc) is not sufficient. You may use `headscale
|
||||
-c /path/to/config.yaml` to specify the path to an alternative configuration file.
|
||||
|
||||
## How can I avoid to send logs to Tailscale Inc?
|
||||
|
||||
BIN
docs/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 1280 640"><circle cx="141.023" cy="338.36" r="117.472" style="fill:#f8b5cb" transform="matrix(.997276 0 0 1.00556 10.0024 -14.823)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(1.01749 0 0 1 -3.15847 0)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(1.01749 0 0 1 -3.15847 115.914)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(1.01749 0 0 1 148.43 115.914)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(1.01749 0 0 1 148.851 0)"/><circle cx="805.557" cy="336.915" r="118.199" style="fill:#8d8d8d" transform="matrix(.99196 0 0 1 3.36978 -10.2458)"/><circle cx="805.557" cy="336.915" r="118.199" style="fill:#8d8d8d" transform="matrix(.99196 0 0 1 255.633 -10.2458)"/><path d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z" style="fill:#303030"/><path d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z" style="fill:#303030" transform="matrix(-1 0 0 1 1857.19 0)"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 1280 640"><circle cx="141.023" cy="338.36" r="117.472" style="fill:#f8b5cb" transform="matrix(.997276 0 0 1.00556 10.0024 -14.823)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(1.01749 0 0 1 -3.15847 0)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(1.01749 0 0 1 -3.15847 115.914)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(1.01749 0 0 1 148.43 115.914)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(1.01749 0 0 1 148.851 0)"/><circle cx="805.557" cy="336.915" r="118.199" style="fill:#8d8d8d" transform="matrix(.99196 0 0 1 3.36978 -10.2458)"/><circle cx="805.557" cy="336.915" r="118.199" style="fill:#8d8d8d" transform="matrix(.99196 0 0 1 255.633 -10.2458)"/><path d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z" style="fill:#303030"/><path d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z" style="fill:#303030" transform="matrix(-1 0 0 1 1857.19 0)"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
@@ -65,7 +65,7 @@ servers.
|
||||
- billing.internal
|
||||
- router.internal
|
||||
|
||||

|
||||

|
||||
|
||||
When [registering the servers](../usage/getting-started.md#register-a-node) we
|
||||
will need to add the flag `--advertise-tags=tag:<tag1>,tag:<tag2>`, and the user
|
||||
|
||||
128
docs/ref/api.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# API
|
||||
Headscale provides a [HTTP REST API](#rest-api) and a [gRPC interface](#grpc) which may be used to integrate a [web
|
||||
interface](integration/web-ui.md), [remote control Headscale](#setup-remote-control) or provide a base for custom
|
||||
integration and tooling.
|
||||
|
||||
Both interfaces require a valid API key before use. To create an API key, log into your Headscale server and generate
|
||||
one with the default expiration of 90 days:
|
||||
|
||||
```shell
|
||||
headscale apikeys create
|
||||
```
|
||||
|
||||
Copy the output of the command and save it for later. Please note that you can not retrieve an API key again. If the API
|
||||
key is lost, expire the old one, and create a new one.
|
||||
|
||||
To list the API keys currently associated with the server:
|
||||
|
||||
```shell
|
||||
headscale apikeys list
|
||||
```
|
||||
|
||||
and to expire an API key:
|
||||
|
||||
```shell
|
||||
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`
|
||||
- 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.
|
||||
|
||||
=== "Get details for all users"
|
||||
|
||||
```console
|
||||
curl -H "Authorization: Bearer <API_KEY>" \
|
||||
https://headscale.example.com/api/v1/user
|
||||
```
|
||||
|
||||
=== "Get details for user 'bob'"
|
||||
|
||||
```console
|
||||
curl -H "Authorization: Bearer <API_KEY>" \
|
||||
https://headscale.example.com/api/v1/user?name=bob
|
||||
```
|
||||
|
||||
=== "Register a node"
|
||||
|
||||
```console
|
||||
curl -H "Authorization: Bearer <API_KEY>" \
|
||||
-d user=<USER> -d key=<KEY> \
|
||||
https://headscale.example.com/api/v1/node/register
|
||||
```
|
||||
|
||||
## gRPC
|
||||
|
||||
The gRPC interface can be used to control a Headscale instance from a remote machine with the `headscale` binary.
|
||||
|
||||
### Prerequisite
|
||||
|
||||
- A workstation to run `headscale` (any supported platform, e.g. Linux).
|
||||
- A Headscale server with gRPC enabled.
|
||||
- Connections to the gRPC port (default: `50443`) are allowed.
|
||||
- Remote access requires an encrypted connection via TLS.
|
||||
- An [API key](#api) to authenticate with the Headscale server.
|
||||
|
||||
### Setup remote control
|
||||
|
||||
1. Download the [`headscale` binary from GitHub's release page](https://github.com/juanfont/headscale/releases). Make
|
||||
sure to use the same version as on the server.
|
||||
|
||||
1. Put the binary somewhere in your `PATH`, e.g. `/usr/local/bin/headscale`
|
||||
|
||||
1. Make `headscale` executable: `chmod +x /usr/local/bin/headscale`
|
||||
|
||||
1. [Create an API key](#api) on the Headscale server.
|
||||
|
||||
1. Provide the connection parameters for the remote Headscale server either via a minimal YAML configuration file or
|
||||
via environment variables:
|
||||
|
||||
=== "Minimal YAML configuration file"
|
||||
|
||||
```yaml title="config.yaml"
|
||||
cli:
|
||||
address: <HEADSCALE_ADDRESS>:<PORT>
|
||||
api_key: <API_KEY>
|
||||
```
|
||||
|
||||
=== "Environment variables"
|
||||
|
||||
```shell
|
||||
export HEADSCALE_CLI_ADDRESS="<HEADSCALE_ADDRESS>:<PORT>"
|
||||
export HEADSCALE_CLI_API_KEY="<API_KEY>"
|
||||
```
|
||||
|
||||
This instructs the `headscale` binary to connect to a remote instance at `<HEADSCALE_ADDRESS>:<PORT>`, instead of
|
||||
connecting to the local instance.
|
||||
|
||||
1. Test the connection by listing all nodes:
|
||||
|
||||
```shell
|
||||
headscale nodes list
|
||||
```
|
||||
|
||||
You should now be able to see a list of your nodes from your workstation, and you can
|
||||
now control the Headscale server from your workstation.
|
||||
|
||||
### Behind a proxy
|
||||
|
||||
It's possible to run the gRPC remote endpoint behind a reverse proxy, like Nginx, and have it run on the _same_ port as Headscale.
|
||||
|
||||
While this is _not a supported_ feature, an example on how this can be set up on
|
||||
[NixOS is shown here](https://github.com/kradalby/dotfiles/blob/4489cdbb19cddfbfae82cd70448a38fde5a76711/machines/headscale.oracldn/headscale.nix#L61-L91).
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- Make sure you have the _same_ Headscale version on your server and workstation.
|
||||
- Ensure that connections to the gRPC port are allowed.
|
||||
- Verify that your TLS certificate is valid and trusted.
|
||||
- If you don't have access to a trusted certificate (e.g. from Let's Encrypt), either:
|
||||
- Add your self-signed certificate to the trust store of your OS _or_
|
||||
- Disable certificate verification by either setting `cli.insecure: true` in the configuration file or by setting
|
||||
`HEADSCALE_CLI_INSECURE=1` via an environment variable. We do **not** recommend to disable certificate validation.
|
||||
@@ -7,10 +7,16 @@
|
||||
|
||||
This page collects third-party tools, client libraries, and scripts related to headscale.
|
||||
|
||||
| Name | Repository Link | Description |
|
||||
| --------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| tailscale-manager | [Github](https://github.com/singlestore-labs/tailscale-manager) | Dynamically manage Tailscale route advertisements |
|
||||
| headscalebacktosqlite | [Github](https://github.com/bigbozza/headscalebacktosqlite) | Migrate headscale from PostgreSQL back to SQLite |
|
||||
| headscale-pf | [Github](https://github.com/YouSysAdmin/headscale-pf) | Populates user groups based on user groups in Jumpcloud or Authentik |
|
||||
| headscale-client-go | [Github](https://github.com/hibare/headscale-client-go) | A Go client implementation for the Headscale HTTP API. |
|
||||
| headscale-zabbix | [Github](https://github.com/dblanque/headscale-zabbix) | A Zabbix Monitoring Template for the Headscale Service. |
|
||||
- [headscale-operator](https://github.com/infradohq/headscale-operator) - Headscale Kubernetes Operator
|
||||
- [tailscale-manager](https://github.com/singlestore-labs/tailscale-manager) - Dynamically manage Tailscale route
|
||||
advertisements
|
||||
- [headscalebacktosqlite](https://github.com/bigbozza/headscalebacktosqlite) - Migrate headscale from PostgreSQL back to
|
||||
SQLite
|
||||
- [headscale-pf](https://github.com/YouSysAdmin/headscale-pf) - Populates user groups based on user groups in Jumpcloud
|
||||
or Authentik
|
||||
- [headscale-client-go](https://github.com/hibare/headscale-client-go) - A Go client implementation for the Headscale
|
||||
HTTP API.
|
||||
- [headscale-zabbix](https://github.com/dblanque/headscale-zabbix) - A Zabbix Monitoring Template for the Headscale
|
||||
Service.
|
||||
- [tailscale-exporter](https://github.com/adinhodovic/tailscale-exporter) - A Prometheus exporter for Headscale that
|
||||
provides network-level metrics using the Headscale API.
|
||||
|
||||
@@ -7,14 +7,17 @@
|
||||
|
||||
Headscale doesn't provide a built-in web interface but users may pick one from the available options.
|
||||
|
||||
| Name | Repository Link | Description |
|
||||
| ---------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| headscale-ui | [Github](https://github.com/gurucomputing/headscale-ui) | A web frontend for the headscale Tailscale-compatible coordination server |
|
||||
| HeadscaleUi | [GitHub](https://github.com/simcu/headscale-ui) | A static headscale admin ui, no backend environment required |
|
||||
| Headplane | [GitHub](https://github.com/tale/headplane) | An advanced Tailscale inspired frontend for headscale |
|
||||
| headscale-admin | [Github](https://github.com/GoodiesHQ/headscale-admin) | Headscale-Admin is meant to be a simple, modern web interface for headscale |
|
||||
| ouroboros | [Github](https://github.com/yellowsink/ouroboros) | Ouroboros is designed for users to manage their own devices, rather than for admins |
|
||||
| unraid-headscale-admin | [Github](https://github.com/ich777/unraid-headscale-admin) | A simple headscale admin UI for Unraid, it offers Local (`docker exec`) and API Mode |
|
||||
| headscale-console | [Github](https://github.com/rickli-cloud/headscale-console) | WebAssembly-based client supporting SSH, VNC and RDP with optional self-service capabilities |
|
||||
- [headscale-ui](https://github.com/gurucomputing/headscale-ui) - A web frontend for the headscale Tailscale-compatible
|
||||
coordination server
|
||||
- [HeadscaleUi](https://github.com/simcu/headscale-ui) - A static headscale admin ui, no backend environment required
|
||||
- [Headplane](https://github.com/tale/headplane) - An advanced Tailscale inspired frontend for headscale
|
||||
- [headscale-admin](https://github.com/GoodiesHQ/headscale-admin) - Headscale-Admin is meant to be a simple, modern web
|
||||
interface for headscale
|
||||
- [ouroboros](https://github.com/yellowsink/ouroboros) - Ouroboros is designed for users to manage their own devices,
|
||||
rather than for admins
|
||||
- [unraid-headscale-admin](https://github.com/ich777/unraid-headscale-admin) - A simple headscale admin UI for Unraid,
|
||||
it offers Local (`docker exec`) and API Mode
|
||||
- [headscale-console](https://github.com/rickli-cloud/headscale-console) - WebAssembly-based client supporting SSH, VNC
|
||||
and RDP with optional self-service capabilities
|
||||
|
||||
You can ask for support on our [Discord server](https://discord.gg/c84AZQhmpx) in the "web-interfaces" channel.
|
||||
|
||||
@@ -305,5 +305,13 @@ Entra ID is: `https://login.microsoftonline.com/<tenant-UUID>/v2.0`. The followi
|
||||
- `domain_hint: example.com` to use your own domain
|
||||
- `prompt: select_account` to force an account picker during login
|
||||
|
||||
Groups for the [allowed groups filter](#authorize-users-with-filters) need to be specified with their group ID instead
|
||||
When using Microsoft Entra ID together with the [allowed groups filter](#authorize-users-with-filters), configure the
|
||||
Headscale OIDC scope without the `groups` claim, for example:
|
||||
|
||||
```yaml
|
||||
oidc:
|
||||
scope: ["openid", "profile", "email"]
|
||||
```
|
||||
|
||||
Groups for the [allowed groups filter](#authorize-users-with-filters) need to be specified with their group ID(UUID) instead
|
||||
of the group name.
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# Controlling headscale with remote CLI
|
||||
|
||||
This documentation has the goal of showing a user how-to control a headscale instance
|
||||
from a remote machine with the `headscale` command line binary.
|
||||
|
||||
## Prerequisite
|
||||
|
||||
- A workstation to run `headscale` (any supported platform, e.g. Linux).
|
||||
- A headscale server with gRPC enabled.
|
||||
- Connections to the gRPC port (default: `50443`) are allowed.
|
||||
- Remote access requires an encrypted connection via TLS.
|
||||
- An API key to authenticate with the headscale server.
|
||||
|
||||
## Create an API key
|
||||
|
||||
We need to create an API key to authenticate with the remote headscale server when using it from our workstation.
|
||||
|
||||
To create an API key, log into your headscale server and generate a key:
|
||||
|
||||
```shell
|
||||
headscale apikeys create --expiration 90d
|
||||
```
|
||||
|
||||
Copy the output of the command and save it for later. Please note that you can not retrieve a key again,
|
||||
if the key is lost, expire the old one, and create a new key.
|
||||
|
||||
To list the keys currently associated with the server:
|
||||
|
||||
```shell
|
||||
headscale apikeys list
|
||||
```
|
||||
|
||||
and to expire a key:
|
||||
|
||||
```shell
|
||||
headscale apikeys expire --prefix "<PREFIX>"
|
||||
```
|
||||
|
||||
## Download and configure headscale
|
||||
|
||||
1. Download the [`headscale` binary from GitHub's release page](https://github.com/juanfont/headscale/releases). Make
|
||||
sure to use the same version as on the server.
|
||||
|
||||
1. Put the binary somewhere in your `PATH`, e.g. `/usr/local/bin/headscale`
|
||||
|
||||
1. Make `headscale` executable:
|
||||
|
||||
```shell
|
||||
chmod +x /usr/local/bin/headscale
|
||||
```
|
||||
|
||||
1. Provide the connection parameters for the remote headscale server either via a minimal YAML configuration file or via
|
||||
environment variables:
|
||||
|
||||
=== "Minimal YAML configuration file"
|
||||
|
||||
```yaml title="config.yaml"
|
||||
cli:
|
||||
address: <HEADSCALE_ADDRESS>:<PORT>
|
||||
api_key: <API_KEY_FROM_PREVIOUS_STEP>
|
||||
```
|
||||
|
||||
=== "Environment variables"
|
||||
|
||||
```shell
|
||||
export HEADSCALE_CLI_ADDRESS="<HEADSCALE_ADDRESS>:<PORT>"
|
||||
export HEADSCALE_CLI_API_KEY="<API_KEY_FROM_PREVIOUS_STEP>"
|
||||
```
|
||||
|
||||
This instructs the `headscale` binary to connect to a remote instance at `<HEADSCALE_ADDRESS>:<PORT>`, instead of
|
||||
connecting to the local instance.
|
||||
|
||||
1. Test the connection
|
||||
|
||||
Let us run the headscale command to verify that we can connect by listing our nodes:
|
||||
|
||||
```shell
|
||||
headscale nodes list
|
||||
```
|
||||
|
||||
You should now be able to see a list of your nodes from your workstation, and you can
|
||||
now control the headscale server from your workstation.
|
||||
|
||||
## Behind a proxy
|
||||
|
||||
It is possible to run the gRPC remote endpoint behind a reverse proxy, like Nginx, and have it run on the _same_ port as headscale.
|
||||
|
||||
While this is _not a supported_ feature, an example on how this can be set up on
|
||||
[NixOS is shown here](https://github.com/kradalby/dotfiles/blob/4489cdbb19cddfbfae82cd70448a38fde5a76711/machines/headscale.oracldn/headscale.nix#L61-L91).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Make sure you have the _same_ headscale version on your server and workstation.
|
||||
- Ensure that connections to the gRPC port are allowed.
|
||||
- Verify that your TLS certificate is valid and trusted.
|
||||
- If you don't have access to a trusted certificate (e.g. from Let's Encrypt), either:
|
||||
- Add your self-signed certificate to the trust store of your OS _or_
|
||||
- Disable certificate verification by either setting `cli.insecure: true` in the configuration file or by setting
|
||||
`HEADSCALE_CLI_INSECURE=1` via an environment variable. We do **not** recommend to disable certificate validation.
|
||||
@@ -216,6 +216,39 @@ nodes.
|
||||
}
|
||||
```
|
||||
|
||||
### Restrict access to exit nodes per user or group
|
||||
|
||||
A user can use _any_ of the available exit nodes with `autogroup:internet`. Alternatively, the ACL snippet below assigns
|
||||
each user a specific exit node while hiding all other exit nodes. The user `alice` can only use exit node `exit1` while
|
||||
user `bob` can only use exit node `exit2`.
|
||||
|
||||
```json title="Assign each user a dedicated exit node"
|
||||
{
|
||||
"hosts": {
|
||||
"exit1": "100.64.0.1/32",
|
||||
"exit2": "100.64.0.2/32"
|
||||
},
|
||||
"acls": [
|
||||
{
|
||||
"action": "accept",
|
||||
"src": ["alice@"],
|
||||
"dst": ["exit1:*"]
|
||||
},
|
||||
{
|
||||
"action": "accept",
|
||||
"src": ["bob@"],
|
||||
"dst": ["exit2:*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
!!! warning
|
||||
|
||||
- The above implementation is Headscale specific and will likely be removed once [support for
|
||||
`via`](https://github.com/juanfont/headscale/issues/2409) is available.
|
||||
- Beware that a user can also connect to any port of the exit node itself.
|
||||
|
||||
### Automatically approve an exit node with auto approvers
|
||||
|
||||
The initial setup of an exit node usually requires manual approval on the control server before it can be used by a node
|
||||
|
||||
@@ -18,10 +18,10 @@ Registry](https://github.com/juanfont/headscale/pkgs/container/headscale). The c
|
||||
|
||||
## Configure and run headscale
|
||||
|
||||
1. Create a directory on the Docker host to store headscale's [configuration](../../ref/configuration.md) and the [SQLite](https://www.sqlite.org/) database:
|
||||
1. Create a directory on the container host to store headscale's [configuration](../../ref/configuration.md) and the [SQLite](https://www.sqlite.org/) database:
|
||||
|
||||
```shell
|
||||
mkdir -p ./headscale/{config,lib,run}
|
||||
mkdir -p ./headscale/{config,lib}
|
||||
cd ./headscale
|
||||
```
|
||||
|
||||
@@ -34,9 +34,10 @@ Registry](https://github.com/juanfont/headscale/pkgs/container/headscale). The c
|
||||
docker run \
|
||||
--name headscale \
|
||||
--detach \
|
||||
--volume "$(pwd)/config:/etc/headscale" \
|
||||
--read-only \
|
||||
--tmpfs /var/run/headscale \
|
||||
--volume "$(pwd)/config:/etc/headscale:ro" \
|
||||
--volume "$(pwd)/lib:/var/lib/headscale" \
|
||||
--volume "$(pwd)/run:/var/run/headscale" \
|
||||
--publish 127.0.0.1:8080:8080 \
|
||||
--publish 127.0.0.1:9090:9090 \
|
||||
--health-cmd "CMD headscale health" \
|
||||
@@ -57,15 +58,17 @@ Registry](https://github.com/juanfont/headscale/pkgs/container/headscale). The c
|
||||
image: docker.io/headscale/headscale:<VERSION>
|
||||
restart: unless-stopped
|
||||
container_name: headscale
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /var/run/headscale
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:9090:9090"
|
||||
volumes:
|
||||
# Please set <HEADSCALE_PATH> to the absolute path
|
||||
# of the previously created headscale directory.
|
||||
- <HEADSCALE_PATH>/config:/etc/headscale
|
||||
- <HEADSCALE_PATH>/config:/etc/headscale:ro
|
||||
- <HEADSCALE_PATH>/lib:/var/lib/headscale
|
||||
- <HEADSCALE_PATH>/run:/var/run/headscale
|
||||
command: serve
|
||||
healthcheck:
|
||||
test: ["CMD", "headscale", "health"]
|
||||
@@ -88,45 +91,10 @@ Registry](https://github.com/juanfont/headscale/pkgs/container/headscale). The c
|
||||
Verify headscale is available:
|
||||
|
||||
```shell
|
||||
curl http://127.0.0.1:9090/metrics
|
||||
curl http://127.0.0.1:8080/health
|
||||
```
|
||||
|
||||
1. Create a headscale user:
|
||||
|
||||
```shell
|
||||
docker exec -it headscale \
|
||||
headscale users create myfirstuser
|
||||
```
|
||||
|
||||
### Register a machine (normal login)
|
||||
|
||||
On a client machine, execute the `tailscale up` command to login:
|
||||
|
||||
```shell
|
||||
tailscale up --login-server YOUR_HEADSCALE_URL
|
||||
```
|
||||
|
||||
To register a machine when running headscale in a container, take the headscale command and pass it to the container:
|
||||
|
||||
```shell
|
||||
docker exec -it headscale \
|
||||
headscale nodes register --user myfirstuser --key <YOUR_MACHINE_KEY>
|
||||
```
|
||||
|
||||
### Register a machine using a pre authenticated key
|
||||
|
||||
Generate a key using the command line for the user with ID 1:
|
||||
|
||||
```shell
|
||||
docker exec -it headscale \
|
||||
headscale preauthkeys create --user 1 --reusable --expiration 24h
|
||||
```
|
||||
|
||||
This will return a pre-authenticated key that can be used to connect a node to headscale with the `tailscale up` command:
|
||||
|
||||
```shell
|
||||
tailscale up --login-server <YOUR_HEADSCALE_URL> --authkey <YOUR_AUTH_KEY>
|
||||
```
|
||||
Continue on the [getting started page](../../usage/getting-started.md) to register your first machine.
|
||||
|
||||
## Debugging headscale running in Docker
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ distributions are Ubuntu 22.04 or newer, Debian 12 or newer.
|
||||
sudo systemctl status headscale
|
||||
```
|
||||
|
||||
Continue on the [getting started page](../../usage/getting-started.md) to register your first machine.
|
||||
|
||||
## Using standalone binaries (advanced)
|
||||
|
||||
!!! warning "Advanced"
|
||||
@@ -115,3 +117,5 @@ managed by systemd.
|
||||
```shell
|
||||
systemctl status headscale
|
||||
```
|
||||
|
||||
Continue on the [getting started page](../../usage/getting-started.md) to register your first machine.
|
||||
|
||||
@@ -28,7 +28,7 @@ The ports in use vary with the intended scenario and enabled features. Some of t
|
||||
- STUN, required if the [embedded DERP server](../ref/derp.md) is enabled
|
||||
- tcp/50443
|
||||
- Expose publicly: yes
|
||||
- Only required if the gRPC interface is used to [remote-control Headscale](../ref/remote-cli.md).
|
||||
- Only required if the gRPC interface is used to [remote-control Headscale](../ref/api.md#grpc).
|
||||
- tcp/9090
|
||||
- Expose publicly: no
|
||||
- [Metrics and debug endpoint](../ref/debug.md#metrics-and-debug-endpoint)
|
||||
|
||||
@@ -9,8 +9,8 @@ This page helps you get started with headscale and provides a few usage examples
|
||||
installation instructions.
|
||||
* The configuration file exists and is adjusted to suit your environment, see
|
||||
[Configuration](../ref/configuration.md) for details.
|
||||
* Headscale is reachable from the Internet. Verify this by opening client specific setup instructions in your
|
||||
browser, e.g. https://headscale.example.com/windows
|
||||
* Headscale is reachable from the Internet. Verify this by visiting the health endpoint:
|
||||
https://headscale.example.com/health
|
||||
* The Tailscale client is installed, see [Client and operating system support](../about/clients.md) for more
|
||||
information.
|
||||
|
||||
@@ -41,6 +41,23 @@ options, run:
|
||||
headscale <COMMAND> --help
|
||||
```
|
||||
|
||||
!!! note "Manage headscale from another local user"
|
||||
|
||||
By default only the user `headscale` or `root` will have the necessary permissions to access the unix socket
|
||||
(`/var/run/headscale/headscale.sock`) that is used to communicate with the service. In order to be able to
|
||||
communicate with the headscale service you have to make sure the unix socket is accessible by the user that runs
|
||||
the commands. In general you can achieve this by any of the following methods:
|
||||
|
||||
* using `sudo`
|
||||
* run the commands as user `headscale`
|
||||
* add your user to the `headscale` group
|
||||
|
||||
To verify you can run the following command using your preferred method:
|
||||
|
||||
```shell
|
||||
headscale users list
|
||||
```
|
||||
|
||||
## Manage headscale users
|
||||
|
||||
In headscale, a node (also known as machine or device) is always assigned to a
|
||||
|
||||
6
flake.lock
generated
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1760533177,
|
||||
"narHash": "sha256-OwM1sFustLHx+xmTymhucZuNhtq98fHIbfO8Swm5L8A=",
|
||||
"lastModified": 1766840161,
|
||||
"narHash": "sha256-Ss/LHpJJsng8vz1Pe33RSGIWUOcqM1fjrehjUkdrWio=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "35f590344ff791e6b1d6d6b8f3523467c9217caf",
|
||||
"rev": "3edc4a30ed3903fdf6f90c837f961fa6b49582d1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
435
flake.nix
@@ -6,239 +6,230 @@
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
...
|
||||
}: let
|
||||
headscaleVersion = self.shortRev or self.dirtyShortRev;
|
||||
commitHash = self.rev or self.dirtyRev;
|
||||
in
|
||||
outputs =
|
||||
{ self
|
||||
, nixpkgs
|
||||
, flake-utils
|
||||
, ...
|
||||
}:
|
||||
let
|
||||
headscaleVersion = self.shortRev or self.dirtyShortRev;
|
||||
commitHash = self.rev or self.dirtyRev;
|
||||
in
|
||||
{
|
||||
overlay = _: prev: let
|
||||
pkgs = nixpkgs.legacyPackages.${prev.system};
|
||||
buildGo = pkgs.buildGo125Module;
|
||||
vendorHash = "sha256-VOi4PGZ8I+2MiwtzxpKc/4smsL5KcH/pHVkjJfAFPJ0=";
|
||||
in {
|
||||
headscale = buildGo {
|
||||
pname = "headscale";
|
||||
version = headscaleVersion;
|
||||
src = pkgs.lib.cleanSource self;
|
||||
|
||||
# Only run unit tests when testing a build
|
||||
checkFlags = ["-short"];
|
||||
|
||||
# When updating go.mod or go.sum, a new sha will need to be calculated,
|
||||
# update this if you have a mismatch after doing a change to those files.
|
||||
inherit vendorHash;
|
||||
|
||||
subPackages = ["cmd/headscale"];
|
||||
|
||||
ldflags = [
|
||||
"-s"
|
||||
"-w"
|
||||
"-X github.com/juanfont/headscale/hscontrol/types.Version=${headscaleVersion}"
|
||||
"-X github.com/juanfont/headscale/hscontrol/types.GitCommitHash=${commitHash}"
|
||||
];
|
||||
};
|
||||
|
||||
hi = buildGo {
|
||||
pname = "hi";
|
||||
version = headscaleVersion;
|
||||
src = pkgs.lib.cleanSource self;
|
||||
|
||||
checkFlags = ["-short"];
|
||||
inherit vendorHash;
|
||||
|
||||
subPackages = ["cmd/hi"];
|
||||
};
|
||||
|
||||
protoc-gen-grpc-gateway = buildGo rec {
|
||||
pname = "grpc-gateway";
|
||||
version = "2.24.0";
|
||||
|
||||
src = pkgs.fetchFromGitHub {
|
||||
owner = "grpc-ecosystem";
|
||||
repo = "grpc-gateway";
|
||||
rev = "v${version}";
|
||||
sha256 = "sha256-lUEoqXJF1k4/il9bdDTinkUV5L869njZNYqObG/mHyA=";
|
||||
};
|
||||
|
||||
vendorHash = "sha256-Ttt7bPKU+TMKRg5550BS6fsPwYp0QJqcZ7NLrhttSdw=";
|
||||
|
||||
nativeBuildInputs = [pkgs.installShellFiles];
|
||||
|
||||
subPackages = ["protoc-gen-grpc-gateway" "protoc-gen-openapiv2"];
|
||||
};
|
||||
|
||||
protobuf-language-server = buildGo rec {
|
||||
pname = "protobuf-language-server";
|
||||
version = "2546944";
|
||||
|
||||
src = pkgs.fetchFromGitHub {
|
||||
owner = "lasorda";
|
||||
repo = "protobuf-language-server";
|
||||
rev = "${version}";
|
||||
sha256 = "sha256-Cbr3ktT86RnwUntOiDKRpNTClhdyrKLTQG2ZEd6fKDc=";
|
||||
};
|
||||
|
||||
vendorHash = "sha256-PfT90dhfzJZabzLTb1D69JCO+kOh2khrlpF5mCDeypk=";
|
||||
|
||||
subPackages = ["."];
|
||||
};
|
||||
|
||||
# Upstream does not override buildGoModule properly,
|
||||
# importing a specific module, so comment out for now.
|
||||
# golangci-lint = prev.golangci-lint.override {
|
||||
# buildGoModule = buildGo;
|
||||
# };
|
||||
# golangci-lint-langserver = prev.golangci-lint.override {
|
||||
# buildGoModule = buildGo;
|
||||
# };
|
||||
|
||||
# The package uses buildGo125Module, not the convention.
|
||||
# goreleaser = prev.goreleaser.override {
|
||||
# buildGoModule = buildGo;
|
||||
# };
|
||||
|
||||
gotestsum = prev.gotestsum.override {
|
||||
buildGoModule = buildGo;
|
||||
};
|
||||
|
||||
gotests = prev.gotests.override {
|
||||
buildGoModule = buildGo;
|
||||
};
|
||||
|
||||
gofumpt = prev.gofumpt.override {
|
||||
buildGoModule = buildGo;
|
||||
};
|
||||
|
||||
# gopls = prev.gopls.override {
|
||||
# buildGoModule = buildGo;
|
||||
# };
|
||||
# NixOS module
|
||||
nixosModules = rec {
|
||||
headscale = import ./nix/module.nix;
|
||||
default = headscale;
|
||||
};
|
||||
|
||||
overlay = _: prev:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${prev.system};
|
||||
buildGo = pkgs.buildGo125Module;
|
||||
vendorHash = "sha256-VOi4PGZ8I+2MiwtzxpKc/4smsL5KcH/pHVkjJfAFPJ0=";
|
||||
in
|
||||
{
|
||||
headscale = buildGo {
|
||||
pname = "headscale";
|
||||
version = headscaleVersion;
|
||||
src = pkgs.lib.cleanSource self;
|
||||
|
||||
# Only run unit tests when testing a build
|
||||
checkFlags = [ "-short" ];
|
||||
|
||||
# When updating go.mod or go.sum, a new sha will need to be calculated,
|
||||
# update this if you have a mismatch after doing a change to those files.
|
||||
inherit vendorHash;
|
||||
|
||||
subPackages = [ "cmd/headscale" ];
|
||||
|
||||
meta = {
|
||||
mainProgram = "headscale";
|
||||
};
|
||||
};
|
||||
|
||||
hi = buildGo {
|
||||
pname = "hi";
|
||||
version = headscaleVersion;
|
||||
src = pkgs.lib.cleanSource self;
|
||||
|
||||
checkFlags = [ "-short" ];
|
||||
inherit vendorHash;
|
||||
|
||||
subPackages = [ "cmd/hi" ];
|
||||
};
|
||||
|
||||
protoc-gen-grpc-gateway = buildGo rec {
|
||||
pname = "grpc-gateway";
|
||||
version = "2.24.0";
|
||||
|
||||
src = pkgs.fetchFromGitHub {
|
||||
owner = "grpc-ecosystem";
|
||||
repo = "grpc-gateway";
|
||||
rev = "v${version}";
|
||||
sha256 = "sha256-lUEoqXJF1k4/il9bdDTinkUV5L869njZNYqObG/mHyA=";
|
||||
};
|
||||
|
||||
vendorHash = "sha256-Ttt7bPKU+TMKRg5550BS6fsPwYp0QJqcZ7NLrhttSdw=";
|
||||
|
||||
nativeBuildInputs = [ pkgs.installShellFiles ];
|
||||
|
||||
subPackages = [ "protoc-gen-grpc-gateway" "protoc-gen-openapiv2" ];
|
||||
};
|
||||
|
||||
protobuf-language-server = buildGo rec {
|
||||
pname = "protobuf-language-server";
|
||||
version = "2546944";
|
||||
|
||||
src = pkgs.fetchFromGitHub {
|
||||
owner = "lasorda";
|
||||
repo = "protobuf-language-server";
|
||||
rev = "${version}";
|
||||
sha256 = "sha256-Cbr3ktT86RnwUntOiDKRpNTClhdyrKLTQG2ZEd6fKDc=";
|
||||
};
|
||||
|
||||
vendorHash = "sha256-PfT90dhfzJZabzLTb1D69JCO+kOh2khrlpF5mCDeypk=";
|
||||
|
||||
subPackages = [ "." ];
|
||||
};
|
||||
|
||||
# Upstream does not override buildGoModule properly,
|
||||
# importing a specific module, so comment out for now.
|
||||
# golangci-lint = prev.golangci-lint.override {
|
||||
# buildGoModule = buildGo;
|
||||
# };
|
||||
# golangci-lint-langserver = prev.golangci-lint.override {
|
||||
# buildGoModule = buildGo;
|
||||
# };
|
||||
|
||||
# The package uses buildGo125Module, not the convention.
|
||||
# goreleaser = prev.goreleaser.override {
|
||||
# buildGoModule = buildGo;
|
||||
# };
|
||||
|
||||
gotestsum = prev.gotestsum.override {
|
||||
buildGoModule = buildGo;
|
||||
};
|
||||
|
||||
gotests = prev.gotests.override {
|
||||
buildGoModule = buildGo;
|
||||
};
|
||||
|
||||
gofumpt = prev.gofumpt.override {
|
||||
buildGoModule = buildGo;
|
||||
};
|
||||
|
||||
# gopls = prev.gopls.override {
|
||||
# buildGoModule = buildGo;
|
||||
# };
|
||||
};
|
||||
}
|
||||
// flake-utils.lib.eachDefaultSystem
|
||||
(system: let
|
||||
pkgs = import nixpkgs {
|
||||
overlays = [self.overlay];
|
||||
inherit system;
|
||||
};
|
||||
buildDeps = with pkgs; [git go_1_25 gnumake];
|
||||
devDeps = with pkgs;
|
||||
buildDeps
|
||||
++ [
|
||||
golangci-lint
|
||||
golangci-lint-langserver
|
||||
golines
|
||||
nodePackages.prettier
|
||||
goreleaser
|
||||
nfpm
|
||||
gotestsum
|
||||
gotests
|
||||
gofumpt
|
||||
gopls
|
||||
ksh
|
||||
ko
|
||||
yq-go
|
||||
ripgrep
|
||||
postgresql
|
||||
|
||||
# 'dot' is needed for pprof graphs
|
||||
# go tool pprof -http=: <source>
|
||||
graphviz
|
||||
|
||||
# Protobuf dependencies
|
||||
protobuf
|
||||
protoc-gen-go
|
||||
protoc-gen-go-grpc
|
||||
protoc-gen-grpc-gateway
|
||||
buf
|
||||
clang-tools # clang-format
|
||||
protobuf-language-server
|
||||
|
||||
# Add hi to make it even easier to use ci runner.
|
||||
hi
|
||||
]
|
||||
++ lib.optional pkgs.stdenv.isLinux [traceroute];
|
||||
|
||||
# Add entry to build a docker image with headscale
|
||||
# caveat: only works on Linux
|
||||
#
|
||||
# Usage:
|
||||
# nix build .#headscale-docker
|
||||
# docker load < result
|
||||
headscale-docker = pkgs.dockerTools.buildLayeredImage {
|
||||
name = "headscale";
|
||||
tag = headscaleVersion;
|
||||
contents = [pkgs.headscale];
|
||||
config.Entrypoint = [(pkgs.headscale + "/bin/headscale")];
|
||||
};
|
||||
in rec {
|
||||
# `nix develop`
|
||||
devShell = pkgs.mkShell {
|
||||
buildInputs =
|
||||
devDeps
|
||||
(system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
overlays = [ self.overlay ];
|
||||
inherit system;
|
||||
};
|
||||
buildDeps = with pkgs; [ git go_1_25 gnumake ];
|
||||
devDeps = with pkgs;
|
||||
buildDeps
|
||||
++ [
|
||||
(pkgs.writeShellScriptBin
|
||||
"nix-vendor-sri"
|
||||
''
|
||||
set -eu
|
||||
golangci-lint
|
||||
golangci-lint-langserver
|
||||
golines
|
||||
nodePackages.prettier
|
||||
nixpkgs-fmt
|
||||
goreleaser
|
||||
nfpm
|
||||
gotestsum
|
||||
gotests
|
||||
gofumpt
|
||||
gopls
|
||||
ksh
|
||||
ko
|
||||
yq-go
|
||||
ripgrep
|
||||
postgresql
|
||||
prek
|
||||
|
||||
OUT=$(mktemp -d -t nar-hash-XXXXXX)
|
||||
rm -rf "$OUT"
|
||||
# 'dot' is needed for pprof graphs
|
||||
# go tool pprof -http=: <source>
|
||||
graphviz
|
||||
|
||||
go mod vendor -o "$OUT"
|
||||
go run tailscale.com/cmd/nardump --sri "$OUT"
|
||||
rm -rf "$OUT"
|
||||
'')
|
||||
# Protobuf dependencies
|
||||
protobuf
|
||||
protoc-gen-go
|
||||
protoc-gen-go-grpc
|
||||
protoc-gen-grpc-gateway
|
||||
buf
|
||||
clang-tools # clang-format
|
||||
protobuf-language-server
|
||||
]
|
||||
++ lib.optional pkgs.stdenv.isLinux [ traceroute ];
|
||||
|
||||
(pkgs.writeShellScriptBin
|
||||
"go-mod-update-all"
|
||||
''
|
||||
cat go.mod | ${pkgs.silver-searcher}/bin/ag "\t" | ${pkgs.silver-searcher}/bin/ag -v indirect | ${pkgs.gawk}/bin/awk '{print $1}' | ${pkgs.findutils}/bin/xargs go get -u
|
||||
go mod tidy
|
||||
'')
|
||||
];
|
||||
# Add entry to build a docker image with headscale
|
||||
# caveat: only works on Linux
|
||||
#
|
||||
# Usage:
|
||||
# nix build .#headscale-docker
|
||||
# docker load < result
|
||||
headscale-docker = pkgs.dockerTools.buildLayeredImage {
|
||||
name = "headscale";
|
||||
tag = headscaleVersion;
|
||||
contents = [ pkgs.headscale ];
|
||||
config.Entrypoint = [ (pkgs.headscale + "/bin/headscale") ];
|
||||
};
|
||||
in
|
||||
rec {
|
||||
# `nix develop`
|
||||
devShell = pkgs.mkShell {
|
||||
buildInputs =
|
||||
devDeps
|
||||
++ [
|
||||
(pkgs.writeShellScriptBin
|
||||
"nix-vendor-sri"
|
||||
''
|
||||
set -eu
|
||||
|
||||
shellHook = ''
|
||||
export PATH="$PWD/result/bin:$PATH"
|
||||
'';
|
||||
};
|
||||
OUT=$(mktemp -d -t nar-hash-XXXXXX)
|
||||
rm -rf "$OUT"
|
||||
|
||||
# `nix build`
|
||||
packages = with pkgs; {
|
||||
inherit headscale;
|
||||
inherit headscale-docker;
|
||||
};
|
||||
defaultPackage = pkgs.headscale;
|
||||
go mod vendor -o "$OUT"
|
||||
go run tailscale.com/cmd/nardump --sri "$OUT"
|
||||
rm -rf "$OUT"
|
||||
'')
|
||||
|
||||
# `nix run`
|
||||
apps.headscale = flake-utils.lib.mkApp {
|
||||
drv = packages.headscale;
|
||||
};
|
||||
apps.default = apps.headscale;
|
||||
|
||||
checks = {
|
||||
format =
|
||||
pkgs.runCommand "check-format"
|
||||
{
|
||||
buildInputs = with pkgs; [
|
||||
gnumake
|
||||
nixpkgs-fmt
|
||||
golangci-lint
|
||||
nodePackages.prettier
|
||||
golines
|
||||
clang-tools
|
||||
(pkgs.writeShellScriptBin
|
||||
"go-mod-update-all"
|
||||
''
|
||||
cat go.mod | ${pkgs.silver-searcher}/bin/ag "\t" | ${pkgs.silver-searcher}/bin/ag -v indirect | ${pkgs.gawk}/bin/awk '{print $1}' | ${pkgs.findutils}/bin/xargs go get -u
|
||||
go mod tidy
|
||||
'')
|
||||
];
|
||||
} ''
|
||||
${pkgs.nixpkgs-fmt}/bin/nixpkgs-fmt ${./.}
|
||||
${pkgs.golangci-lint}/bin/golangci-lint run --fix --timeout 10m
|
||||
${pkgs.nodePackages.prettier}/bin/prettier --write '**/**.{ts,js,md,yaml,yml,sass,css,scss,html}'
|
||||
${pkgs.golines}/bin/golines --max-len=88 --base-formatter=gofumpt -w ${./.}
|
||||
${pkgs.clang-tools}/bin/clang-format -i ${./.}
|
||||
|
||||
shellHook = ''
|
||||
export PATH="$PWD/result/bin:$PATH"
|
||||
export CGO_ENABLED=0
|
||||
'';
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
# `nix build`
|
||||
packages = with pkgs; {
|
||||
inherit headscale;
|
||||
inherit headscale-docker;
|
||||
};
|
||||
defaultPackage = pkgs.headscale;
|
||||
|
||||
# `nix run`
|
||||
apps.headscale = flake-utils.lib.mkApp {
|
||||
drv = packages.headscale;
|
||||
};
|
||||
apps.default = apps.headscale;
|
||||
|
||||
checks = {
|
||||
headscale = pkgs.testers.nixosTest (import ./nix/tests/headscale.nix);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc (unknown)
|
||||
// source: headscale/v1/apikey.proto
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc (unknown)
|
||||
// source: headscale/v1/device.proto
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc (unknown)
|
||||
// source: headscale/v1/headscale.proto
|
||||
|
||||
@@ -109,7 +109,7 @@ const file_headscale_v1_headscale_proto_rawDesc = "" +
|
||||
"\x1cheadscale/v1/headscale.proto\x12\fheadscale.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17headscale/v1/user.proto\x1a\x1dheadscale/v1/preauthkey.proto\x1a\x17headscale/v1/node.proto\x1a\x19headscale/v1/apikey.proto\x1a\x19headscale/v1/policy.proto\"\x0f\n" +
|
||||
"\rHealthRequest\"E\n" +
|
||||
"\x0eHealthResponse\x123\n" +
|
||||
"\x15database_connectivity\x18\x01 \x01(\bR\x14databaseConnectivity2\x80\x17\n" +
|
||||
"\x15database_connectivity\x18\x01 \x01(\bR\x14databaseConnectivity2\x8c\x17\n" +
|
||||
"\x10HeadscaleService\x12h\n" +
|
||||
"\n" +
|
||||
"CreateUser\x12\x1f.headscale.v1.CreateUserRequest\x1a .headscale.v1.CreateUserResponse\"\x17\x82\xd3\xe4\x93\x02\x11:\x01*\"\f/api/v1/user\x12\x80\x01\n" +
|
||||
@@ -119,7 +119,8 @@ const file_headscale_v1_headscale_proto_rawDesc = "" +
|
||||
"DeleteUser\x12\x1f.headscale.v1.DeleteUserRequest\x1a .headscale.v1.DeleteUserResponse\"\x19\x82\xd3\xe4\x93\x02\x13*\x11/api/v1/user/{id}\x12b\n" +
|
||||
"\tListUsers\x12\x1e.headscale.v1.ListUsersRequest\x1a\x1f.headscale.v1.ListUsersResponse\"\x14\x82\xd3\xe4\x93\x02\x0e\x12\f/api/v1/user\x12\x80\x01\n" +
|
||||
"\x10CreatePreAuthKey\x12%.headscale.v1.CreatePreAuthKeyRequest\x1a&.headscale.v1.CreatePreAuthKeyResponse\"\x1d\x82\xd3\xe4\x93\x02\x17:\x01*\"\x12/api/v1/preauthkey\x12\x87\x01\n" +
|
||||
"\x10ExpirePreAuthKey\x12%.headscale.v1.ExpirePreAuthKeyRequest\x1a&.headscale.v1.ExpirePreAuthKeyResponse\"$\x82\xd3\xe4\x93\x02\x1e:\x01*\"\x19/api/v1/preauthkey/expire\x12z\n" +
|
||||
"\x10ExpirePreAuthKey\x12%.headscale.v1.ExpirePreAuthKeyRequest\x1a&.headscale.v1.ExpirePreAuthKeyResponse\"$\x82\xd3\xe4\x93\x02\x1e:\x01*\"\x19/api/v1/preauthkey/expire\x12}\n" +
|
||||
"\x10DeletePreAuthKey\x12%.headscale.v1.DeletePreAuthKeyRequest\x1a&.headscale.v1.DeletePreAuthKeyResponse\"\x1a\x82\xd3\xe4\x93\x02\x14*\x12/api/v1/preauthkey\x12z\n" +
|
||||
"\x0fListPreAuthKeys\x12$.headscale.v1.ListPreAuthKeysRequest\x1a%.headscale.v1.ListPreAuthKeysResponse\"\x1a\x82\xd3\xe4\x93\x02\x14\x12\x12/api/v1/preauthkey\x12}\n" +
|
||||
"\x0fDebugCreateNode\x12$.headscale.v1.DebugCreateNodeRequest\x1a%.headscale.v1.DebugCreateNodeResponse\"\x1d\x82\xd3\xe4\x93\x02\x17:\x01*\"\x12/api/v1/debug/node\x12f\n" +
|
||||
"\aGetNode\x12\x1c.headscale.v1.GetNodeRequest\x1a\x1d.headscale.v1.GetNodeResponse\"\x1e\x82\xd3\xe4\x93\x02\x18\x12\x16/api/v1/node/{node_id}\x12n\n" +
|
||||
@@ -132,8 +133,7 @@ const file_headscale_v1_headscale_proto_rawDesc = "" +
|
||||
"ExpireNode\x12\x1f.headscale.v1.ExpireNodeRequest\x1a .headscale.v1.ExpireNodeResponse\"%\x82\xd3\xe4\x93\x02\x1f\"\x1d/api/v1/node/{node_id}/expire\x12\x81\x01\n" +
|
||||
"\n" +
|
||||
"RenameNode\x12\x1f.headscale.v1.RenameNodeRequest\x1a .headscale.v1.RenameNodeResponse\"0\x82\xd3\xe4\x93\x02*\"(/api/v1/node/{node_id}/rename/{new_name}\x12b\n" +
|
||||
"\tListNodes\x12\x1e.headscale.v1.ListNodesRequest\x1a\x1f.headscale.v1.ListNodesResponse\"\x14\x82\xd3\xe4\x93\x02\x0e\x12\f/api/v1/node\x12q\n" +
|
||||
"\bMoveNode\x12\x1d.headscale.v1.MoveNodeRequest\x1a\x1e.headscale.v1.MoveNodeResponse\"&\x82\xd3\xe4\x93\x02 :\x01*\"\x1b/api/v1/node/{node_id}/user\x12\x80\x01\n" +
|
||||
"\tListNodes\x12\x1e.headscale.v1.ListNodesRequest\x1a\x1f.headscale.v1.ListNodesResponse\"\x14\x82\xd3\xe4\x93\x02\x0e\x12\f/api/v1/node\x12\x80\x01\n" +
|
||||
"\x0fBackfillNodeIPs\x12$.headscale.v1.BackfillNodeIPsRequest\x1a%.headscale.v1.BackfillNodeIPsResponse\" \x82\xd3\xe4\x93\x02\x1a\"\x18/api/v1/node/backfillips\x12p\n" +
|
||||
"\fCreateApiKey\x12!.headscale.v1.CreateApiKeyRequest\x1a\".headscale.v1.CreateApiKeyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\"\x0e/api/v1/apikey\x12w\n" +
|
||||
"\fExpireApiKey\x12!.headscale.v1.ExpireApiKeyRequest\x1a\".headscale.v1.ExpireApiKeyResponse\" \x82\xd3\xe4\x93\x02\x1a:\x01*\"\x15/api/v1/apikey/expire\x12j\n" +
|
||||
@@ -165,17 +165,17 @@ var file_headscale_v1_headscale_proto_goTypes = []any{
|
||||
(*ListUsersRequest)(nil), // 5: headscale.v1.ListUsersRequest
|
||||
(*CreatePreAuthKeyRequest)(nil), // 6: headscale.v1.CreatePreAuthKeyRequest
|
||||
(*ExpirePreAuthKeyRequest)(nil), // 7: headscale.v1.ExpirePreAuthKeyRequest
|
||||
(*ListPreAuthKeysRequest)(nil), // 8: headscale.v1.ListPreAuthKeysRequest
|
||||
(*DebugCreateNodeRequest)(nil), // 9: headscale.v1.DebugCreateNodeRequest
|
||||
(*GetNodeRequest)(nil), // 10: headscale.v1.GetNodeRequest
|
||||
(*SetTagsRequest)(nil), // 11: headscale.v1.SetTagsRequest
|
||||
(*SetApprovedRoutesRequest)(nil), // 12: headscale.v1.SetApprovedRoutesRequest
|
||||
(*RegisterNodeRequest)(nil), // 13: headscale.v1.RegisterNodeRequest
|
||||
(*DeleteNodeRequest)(nil), // 14: headscale.v1.DeleteNodeRequest
|
||||
(*ExpireNodeRequest)(nil), // 15: headscale.v1.ExpireNodeRequest
|
||||
(*RenameNodeRequest)(nil), // 16: headscale.v1.RenameNodeRequest
|
||||
(*ListNodesRequest)(nil), // 17: headscale.v1.ListNodesRequest
|
||||
(*MoveNodeRequest)(nil), // 18: headscale.v1.MoveNodeRequest
|
||||
(*DeletePreAuthKeyRequest)(nil), // 8: headscale.v1.DeletePreAuthKeyRequest
|
||||
(*ListPreAuthKeysRequest)(nil), // 9: headscale.v1.ListPreAuthKeysRequest
|
||||
(*DebugCreateNodeRequest)(nil), // 10: headscale.v1.DebugCreateNodeRequest
|
||||
(*GetNodeRequest)(nil), // 11: headscale.v1.GetNodeRequest
|
||||
(*SetTagsRequest)(nil), // 12: headscale.v1.SetTagsRequest
|
||||
(*SetApprovedRoutesRequest)(nil), // 13: headscale.v1.SetApprovedRoutesRequest
|
||||
(*RegisterNodeRequest)(nil), // 14: headscale.v1.RegisterNodeRequest
|
||||
(*DeleteNodeRequest)(nil), // 15: headscale.v1.DeleteNodeRequest
|
||||
(*ExpireNodeRequest)(nil), // 16: headscale.v1.ExpireNodeRequest
|
||||
(*RenameNodeRequest)(nil), // 17: headscale.v1.RenameNodeRequest
|
||||
(*ListNodesRequest)(nil), // 18: headscale.v1.ListNodesRequest
|
||||
(*BackfillNodeIPsRequest)(nil), // 19: headscale.v1.BackfillNodeIPsRequest
|
||||
(*CreateApiKeyRequest)(nil), // 20: headscale.v1.CreateApiKeyRequest
|
||||
(*ExpireApiKeyRequest)(nil), // 21: headscale.v1.ExpireApiKeyRequest
|
||||
@@ -189,17 +189,17 @@ var file_headscale_v1_headscale_proto_goTypes = []any{
|
||||
(*ListUsersResponse)(nil), // 29: headscale.v1.ListUsersResponse
|
||||
(*CreatePreAuthKeyResponse)(nil), // 30: headscale.v1.CreatePreAuthKeyResponse
|
||||
(*ExpirePreAuthKeyResponse)(nil), // 31: headscale.v1.ExpirePreAuthKeyResponse
|
||||
(*ListPreAuthKeysResponse)(nil), // 32: headscale.v1.ListPreAuthKeysResponse
|
||||
(*DebugCreateNodeResponse)(nil), // 33: headscale.v1.DebugCreateNodeResponse
|
||||
(*GetNodeResponse)(nil), // 34: headscale.v1.GetNodeResponse
|
||||
(*SetTagsResponse)(nil), // 35: headscale.v1.SetTagsResponse
|
||||
(*SetApprovedRoutesResponse)(nil), // 36: headscale.v1.SetApprovedRoutesResponse
|
||||
(*RegisterNodeResponse)(nil), // 37: headscale.v1.RegisterNodeResponse
|
||||
(*DeleteNodeResponse)(nil), // 38: headscale.v1.DeleteNodeResponse
|
||||
(*ExpireNodeResponse)(nil), // 39: headscale.v1.ExpireNodeResponse
|
||||
(*RenameNodeResponse)(nil), // 40: headscale.v1.RenameNodeResponse
|
||||
(*ListNodesResponse)(nil), // 41: headscale.v1.ListNodesResponse
|
||||
(*MoveNodeResponse)(nil), // 42: headscale.v1.MoveNodeResponse
|
||||
(*DeletePreAuthKeyResponse)(nil), // 32: headscale.v1.DeletePreAuthKeyResponse
|
||||
(*ListPreAuthKeysResponse)(nil), // 33: headscale.v1.ListPreAuthKeysResponse
|
||||
(*DebugCreateNodeResponse)(nil), // 34: headscale.v1.DebugCreateNodeResponse
|
||||
(*GetNodeResponse)(nil), // 35: headscale.v1.GetNodeResponse
|
||||
(*SetTagsResponse)(nil), // 36: headscale.v1.SetTagsResponse
|
||||
(*SetApprovedRoutesResponse)(nil), // 37: headscale.v1.SetApprovedRoutesResponse
|
||||
(*RegisterNodeResponse)(nil), // 38: headscale.v1.RegisterNodeResponse
|
||||
(*DeleteNodeResponse)(nil), // 39: headscale.v1.DeleteNodeResponse
|
||||
(*ExpireNodeResponse)(nil), // 40: headscale.v1.ExpireNodeResponse
|
||||
(*RenameNodeResponse)(nil), // 41: headscale.v1.RenameNodeResponse
|
||||
(*ListNodesResponse)(nil), // 42: headscale.v1.ListNodesResponse
|
||||
(*BackfillNodeIPsResponse)(nil), // 43: headscale.v1.BackfillNodeIPsResponse
|
||||
(*CreateApiKeyResponse)(nil), // 44: headscale.v1.CreateApiKeyResponse
|
||||
(*ExpireApiKeyResponse)(nil), // 45: headscale.v1.ExpireApiKeyResponse
|
||||
@@ -215,17 +215,17 @@ var file_headscale_v1_headscale_proto_depIdxs = []int32{
|
||||
5, // 3: headscale.v1.HeadscaleService.ListUsers:input_type -> headscale.v1.ListUsersRequest
|
||||
6, // 4: headscale.v1.HeadscaleService.CreatePreAuthKey:input_type -> headscale.v1.CreatePreAuthKeyRequest
|
||||
7, // 5: headscale.v1.HeadscaleService.ExpirePreAuthKey:input_type -> headscale.v1.ExpirePreAuthKeyRequest
|
||||
8, // 6: headscale.v1.HeadscaleService.ListPreAuthKeys:input_type -> headscale.v1.ListPreAuthKeysRequest
|
||||
9, // 7: headscale.v1.HeadscaleService.DebugCreateNode:input_type -> headscale.v1.DebugCreateNodeRequest
|
||||
10, // 8: headscale.v1.HeadscaleService.GetNode:input_type -> headscale.v1.GetNodeRequest
|
||||
11, // 9: headscale.v1.HeadscaleService.SetTags:input_type -> headscale.v1.SetTagsRequest
|
||||
12, // 10: headscale.v1.HeadscaleService.SetApprovedRoutes:input_type -> headscale.v1.SetApprovedRoutesRequest
|
||||
13, // 11: headscale.v1.HeadscaleService.RegisterNode:input_type -> headscale.v1.RegisterNodeRequest
|
||||
14, // 12: headscale.v1.HeadscaleService.DeleteNode:input_type -> headscale.v1.DeleteNodeRequest
|
||||
15, // 13: headscale.v1.HeadscaleService.ExpireNode:input_type -> headscale.v1.ExpireNodeRequest
|
||||
16, // 14: headscale.v1.HeadscaleService.RenameNode:input_type -> headscale.v1.RenameNodeRequest
|
||||
17, // 15: headscale.v1.HeadscaleService.ListNodes:input_type -> headscale.v1.ListNodesRequest
|
||||
18, // 16: headscale.v1.HeadscaleService.MoveNode:input_type -> headscale.v1.MoveNodeRequest
|
||||
8, // 6: headscale.v1.HeadscaleService.DeletePreAuthKey:input_type -> headscale.v1.DeletePreAuthKeyRequest
|
||||
9, // 7: headscale.v1.HeadscaleService.ListPreAuthKeys:input_type -> headscale.v1.ListPreAuthKeysRequest
|
||||
10, // 8: headscale.v1.HeadscaleService.DebugCreateNode:input_type -> headscale.v1.DebugCreateNodeRequest
|
||||
11, // 9: headscale.v1.HeadscaleService.GetNode:input_type -> headscale.v1.GetNodeRequest
|
||||
12, // 10: headscale.v1.HeadscaleService.SetTags:input_type -> headscale.v1.SetTagsRequest
|
||||
13, // 11: headscale.v1.HeadscaleService.SetApprovedRoutes:input_type -> headscale.v1.SetApprovedRoutesRequest
|
||||
14, // 12: headscale.v1.HeadscaleService.RegisterNode:input_type -> headscale.v1.RegisterNodeRequest
|
||||
15, // 13: headscale.v1.HeadscaleService.DeleteNode:input_type -> headscale.v1.DeleteNodeRequest
|
||||
16, // 14: headscale.v1.HeadscaleService.ExpireNode:input_type -> headscale.v1.ExpireNodeRequest
|
||||
17, // 15: headscale.v1.HeadscaleService.RenameNode:input_type -> headscale.v1.RenameNodeRequest
|
||||
18, // 16: headscale.v1.HeadscaleService.ListNodes:input_type -> headscale.v1.ListNodesRequest
|
||||
19, // 17: headscale.v1.HeadscaleService.BackfillNodeIPs:input_type -> headscale.v1.BackfillNodeIPsRequest
|
||||
20, // 18: headscale.v1.HeadscaleService.CreateApiKey:input_type -> headscale.v1.CreateApiKeyRequest
|
||||
21, // 19: headscale.v1.HeadscaleService.ExpireApiKey:input_type -> headscale.v1.ExpireApiKeyRequest
|
||||
@@ -240,17 +240,17 @@ var file_headscale_v1_headscale_proto_depIdxs = []int32{
|
||||
29, // 28: headscale.v1.HeadscaleService.ListUsers:output_type -> headscale.v1.ListUsersResponse
|
||||
30, // 29: headscale.v1.HeadscaleService.CreatePreAuthKey:output_type -> headscale.v1.CreatePreAuthKeyResponse
|
||||
31, // 30: headscale.v1.HeadscaleService.ExpirePreAuthKey:output_type -> headscale.v1.ExpirePreAuthKeyResponse
|
||||
32, // 31: headscale.v1.HeadscaleService.ListPreAuthKeys:output_type -> headscale.v1.ListPreAuthKeysResponse
|
||||
33, // 32: headscale.v1.HeadscaleService.DebugCreateNode:output_type -> headscale.v1.DebugCreateNodeResponse
|
||||
34, // 33: headscale.v1.HeadscaleService.GetNode:output_type -> headscale.v1.GetNodeResponse
|
||||
35, // 34: headscale.v1.HeadscaleService.SetTags:output_type -> headscale.v1.SetTagsResponse
|
||||
36, // 35: headscale.v1.HeadscaleService.SetApprovedRoutes:output_type -> headscale.v1.SetApprovedRoutesResponse
|
||||
37, // 36: headscale.v1.HeadscaleService.RegisterNode:output_type -> headscale.v1.RegisterNodeResponse
|
||||
38, // 37: headscale.v1.HeadscaleService.DeleteNode:output_type -> headscale.v1.DeleteNodeResponse
|
||||
39, // 38: headscale.v1.HeadscaleService.ExpireNode:output_type -> headscale.v1.ExpireNodeResponse
|
||||
40, // 39: headscale.v1.HeadscaleService.RenameNode:output_type -> headscale.v1.RenameNodeResponse
|
||||
41, // 40: headscale.v1.HeadscaleService.ListNodes:output_type -> headscale.v1.ListNodesResponse
|
||||
42, // 41: headscale.v1.HeadscaleService.MoveNode:output_type -> headscale.v1.MoveNodeResponse
|
||||
32, // 31: headscale.v1.HeadscaleService.DeletePreAuthKey:output_type -> headscale.v1.DeletePreAuthKeyResponse
|
||||
33, // 32: headscale.v1.HeadscaleService.ListPreAuthKeys:output_type -> headscale.v1.ListPreAuthKeysResponse
|
||||
34, // 33: headscale.v1.HeadscaleService.DebugCreateNode:output_type -> headscale.v1.DebugCreateNodeResponse
|
||||
35, // 34: headscale.v1.HeadscaleService.GetNode:output_type -> headscale.v1.GetNodeResponse
|
||||
36, // 35: headscale.v1.HeadscaleService.SetTags:output_type -> headscale.v1.SetTagsResponse
|
||||
37, // 36: headscale.v1.HeadscaleService.SetApprovedRoutes:output_type -> headscale.v1.SetApprovedRoutesResponse
|
||||
38, // 37: headscale.v1.HeadscaleService.RegisterNode:output_type -> headscale.v1.RegisterNodeResponse
|
||||
39, // 38: headscale.v1.HeadscaleService.DeleteNode:output_type -> headscale.v1.DeleteNodeResponse
|
||||
40, // 39: headscale.v1.HeadscaleService.ExpireNode:output_type -> headscale.v1.ExpireNodeResponse
|
||||
41, // 40: headscale.v1.HeadscaleService.RenameNode:output_type -> headscale.v1.RenameNodeResponse
|
||||
42, // 41: headscale.v1.HeadscaleService.ListNodes:output_type -> headscale.v1.ListNodesResponse
|
||||
43, // 42: headscale.v1.HeadscaleService.BackfillNodeIPs:output_type -> headscale.v1.BackfillNodeIPsResponse
|
||||
44, // 43: headscale.v1.HeadscaleService.CreateApiKey:output_type -> headscale.v1.CreateApiKeyResponse
|
||||
45, // 44: headscale.v1.HeadscaleService.ExpireApiKey:output_type -> headscale.v1.ExpireApiKeyResponse
|
||||
|
||||
@@ -227,6 +227,38 @@ func local_request_HeadscaleService_ExpirePreAuthKey_0(ctx context.Context, mars
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
var filter_HeadscaleService_DeletePreAuthKey_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)}
|
||||
|
||||
func request_HeadscaleService_DeletePreAuthKey_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq DeletePreAuthKeyRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := req.ParseForm(); err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_DeletePreAuthKey_0); err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.DeletePreAuthKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_HeadscaleService_DeletePreAuthKey_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq DeletePreAuthKeyRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := req.ParseForm(); err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_DeletePreAuthKey_0); err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.DeletePreAuthKey(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
var filter_HeadscaleService_ListPreAuthKeys_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)}
|
||||
|
||||
func request_HeadscaleService_ListPreAuthKeys_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
@@ -605,48 +637,6 @@ func local_request_HeadscaleService_ListNodes_0(ctx context.Context, marshaler r
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_HeadscaleService_MoveNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq MoveNodeRequest
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
val, ok := pathParams["node_id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id")
|
||||
}
|
||||
protoReq.NodeId, err = runtime.Uint64(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err)
|
||||
}
|
||||
msg, err := client.MoveNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_HeadscaleService_MoveNode_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq MoveNodeRequest
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
val, ok := pathParams["node_id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id")
|
||||
}
|
||||
protoReq.NodeId, err = runtime.Uint64(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err)
|
||||
}
|
||||
msg, err := server.MoveNode(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
var filter_HeadscaleService_BackfillNodeIPs_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)}
|
||||
|
||||
func request_HeadscaleService_BackfillNodeIPs_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
@@ -967,6 +957,26 @@ func RegisterHeadscaleServiceHandlerServer(ctx context.Context, mux *runtime.Ser
|
||||
}
|
||||
forward_HeadscaleService_ExpirePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeletePreAuthKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeletePreAuthKey", runtime.WithHTTPPathPattern("/api/v1/preauthkey"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_HeadscaleService_DeletePreAuthKey_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_HeadscaleService_DeletePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodGet, pattern_HeadscaleService_ListPreAuthKeys_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -1167,26 +1177,6 @@ func RegisterHeadscaleServiceHandlerServer(ctx context.Context, mux *runtime.Ser
|
||||
}
|
||||
forward_HeadscaleService_ListNodes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_HeadscaleService_MoveNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/MoveNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/user"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_HeadscaleService_MoveNode_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_HeadscaleService_MoveNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_HeadscaleService_BackfillNodeIPs_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -1489,6 +1479,23 @@ func RegisterHeadscaleServiceHandlerClient(ctx context.Context, mux *runtime.Ser
|
||||
}
|
||||
forward_HeadscaleService_ExpirePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeletePreAuthKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeletePreAuthKey", runtime.WithHTTPPathPattern("/api/v1/preauthkey"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_HeadscaleService_DeletePreAuthKey_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_HeadscaleService_DeletePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodGet, pattern_HeadscaleService_ListPreAuthKeys_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -1659,23 +1666,6 @@ func RegisterHeadscaleServiceHandlerClient(ctx context.Context, mux *runtime.Ser
|
||||
}
|
||||
forward_HeadscaleService_ListNodes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_HeadscaleService_MoveNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/MoveNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/user"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_HeadscaleService_MoveNode_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_HeadscaleService_MoveNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_HeadscaleService_BackfillNodeIPs_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -1822,6 +1812,7 @@ var (
|
||||
pattern_HeadscaleService_ListUsers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "user"}, ""))
|
||||
pattern_HeadscaleService_CreatePreAuthKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "preauthkey"}, ""))
|
||||
pattern_HeadscaleService_ExpirePreAuthKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "preauthkey", "expire"}, ""))
|
||||
pattern_HeadscaleService_DeletePreAuthKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "preauthkey"}, ""))
|
||||
pattern_HeadscaleService_ListPreAuthKeys_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "preauthkey"}, ""))
|
||||
pattern_HeadscaleService_DebugCreateNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "debug", "node"}, ""))
|
||||
pattern_HeadscaleService_GetNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "node", "node_id"}, ""))
|
||||
@@ -1832,7 +1823,6 @@ var (
|
||||
pattern_HeadscaleService_ExpireNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"api", "v1", "node", "node_id", "expire"}, ""))
|
||||
pattern_HeadscaleService_RenameNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4, 1, 0, 4, 1, 5, 5}, []string{"api", "v1", "node", "node_id", "rename", "new_name"}, ""))
|
||||
pattern_HeadscaleService_ListNodes_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "node"}, ""))
|
||||
pattern_HeadscaleService_MoveNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"api", "v1", "node", "node_id", "user"}, ""))
|
||||
pattern_HeadscaleService_BackfillNodeIPs_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "node", "backfillips"}, ""))
|
||||
pattern_HeadscaleService_CreateApiKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "apikey"}, ""))
|
||||
pattern_HeadscaleService_ExpireApiKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "apikey", "expire"}, ""))
|
||||
@@ -1850,6 +1840,7 @@ var (
|
||||
forward_HeadscaleService_ListUsers_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_CreatePreAuthKey_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_ExpirePreAuthKey_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_DeletePreAuthKey_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_ListPreAuthKeys_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_DebugCreateNode_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_GetNode_0 = runtime.ForwardResponseMessage
|
||||
@@ -1860,7 +1851,6 @@ var (
|
||||
forward_HeadscaleService_ExpireNode_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_RenameNode_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_ListNodes_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_MoveNode_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_BackfillNodeIPs_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_CreateApiKey_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_ExpireApiKey_0 = runtime.ForwardResponseMessage
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc-gen-go-grpc v1.6.0
|
||||
// - protoc (unknown)
|
||||
// source: headscale/v1/headscale.proto
|
||||
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
HeadscaleService_ListUsers_FullMethodName = "/headscale.v1.HeadscaleService/ListUsers"
|
||||
HeadscaleService_CreatePreAuthKey_FullMethodName = "/headscale.v1.HeadscaleService/CreatePreAuthKey"
|
||||
HeadscaleService_ExpirePreAuthKey_FullMethodName = "/headscale.v1.HeadscaleService/ExpirePreAuthKey"
|
||||
HeadscaleService_DeletePreAuthKey_FullMethodName = "/headscale.v1.HeadscaleService/DeletePreAuthKey"
|
||||
HeadscaleService_ListPreAuthKeys_FullMethodName = "/headscale.v1.HeadscaleService/ListPreAuthKeys"
|
||||
HeadscaleService_DebugCreateNode_FullMethodName = "/headscale.v1.HeadscaleService/DebugCreateNode"
|
||||
HeadscaleService_GetNode_FullMethodName = "/headscale.v1.HeadscaleService/GetNode"
|
||||
@@ -35,7 +36,6 @@ const (
|
||||
HeadscaleService_ExpireNode_FullMethodName = "/headscale.v1.HeadscaleService/ExpireNode"
|
||||
HeadscaleService_RenameNode_FullMethodName = "/headscale.v1.HeadscaleService/RenameNode"
|
||||
HeadscaleService_ListNodes_FullMethodName = "/headscale.v1.HeadscaleService/ListNodes"
|
||||
HeadscaleService_MoveNode_FullMethodName = "/headscale.v1.HeadscaleService/MoveNode"
|
||||
HeadscaleService_BackfillNodeIPs_FullMethodName = "/headscale.v1.HeadscaleService/BackfillNodeIPs"
|
||||
HeadscaleService_CreateApiKey_FullMethodName = "/headscale.v1.HeadscaleService/CreateApiKey"
|
||||
HeadscaleService_ExpireApiKey_FullMethodName = "/headscale.v1.HeadscaleService/ExpireApiKey"
|
||||
@@ -58,6 +58,7 @@ type HeadscaleServiceClient interface {
|
||||
// --- PreAuthKeys start ---
|
||||
CreatePreAuthKey(ctx context.Context, in *CreatePreAuthKeyRequest, opts ...grpc.CallOption) (*CreatePreAuthKeyResponse, error)
|
||||
ExpirePreAuthKey(ctx context.Context, in *ExpirePreAuthKeyRequest, opts ...grpc.CallOption) (*ExpirePreAuthKeyResponse, error)
|
||||
DeletePreAuthKey(ctx context.Context, in *DeletePreAuthKeyRequest, opts ...grpc.CallOption) (*DeletePreAuthKeyResponse, error)
|
||||
ListPreAuthKeys(ctx context.Context, in *ListPreAuthKeysRequest, opts ...grpc.CallOption) (*ListPreAuthKeysResponse, error)
|
||||
// --- Node start ---
|
||||
DebugCreateNode(ctx context.Context, in *DebugCreateNodeRequest, opts ...grpc.CallOption) (*DebugCreateNodeResponse, error)
|
||||
@@ -69,7 +70,6 @@ type HeadscaleServiceClient interface {
|
||||
ExpireNode(ctx context.Context, in *ExpireNodeRequest, opts ...grpc.CallOption) (*ExpireNodeResponse, error)
|
||||
RenameNode(ctx context.Context, in *RenameNodeRequest, opts ...grpc.CallOption) (*RenameNodeResponse, error)
|
||||
ListNodes(ctx context.Context, in *ListNodesRequest, opts ...grpc.CallOption) (*ListNodesResponse, error)
|
||||
MoveNode(ctx context.Context, in *MoveNodeRequest, opts ...grpc.CallOption) (*MoveNodeResponse, error)
|
||||
BackfillNodeIPs(ctx context.Context, in *BackfillNodeIPsRequest, opts ...grpc.CallOption) (*BackfillNodeIPsResponse, error)
|
||||
// --- ApiKeys start ---
|
||||
CreateApiKey(ctx context.Context, in *CreateApiKeyRequest, opts ...grpc.CallOption) (*CreateApiKeyResponse, error)
|
||||
@@ -151,6 +151,16 @@ func (c *headscaleServiceClient) ExpirePreAuthKey(ctx context.Context, in *Expir
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *headscaleServiceClient) DeletePreAuthKey(ctx context.Context, in *DeletePreAuthKeyRequest, opts ...grpc.CallOption) (*DeletePreAuthKeyResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(DeletePreAuthKeyResponse)
|
||||
err := c.cc.Invoke(ctx, HeadscaleService_DeletePreAuthKey_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *headscaleServiceClient) ListPreAuthKeys(ctx context.Context, in *ListPreAuthKeysRequest, opts ...grpc.CallOption) (*ListPreAuthKeysResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListPreAuthKeysResponse)
|
||||
@@ -251,16 +261,6 @@ func (c *headscaleServiceClient) ListNodes(ctx context.Context, in *ListNodesReq
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *headscaleServiceClient) MoveNode(ctx context.Context, in *MoveNodeRequest, opts ...grpc.CallOption) (*MoveNodeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(MoveNodeResponse)
|
||||
err := c.cc.Invoke(ctx, HeadscaleService_MoveNode_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *headscaleServiceClient) BackfillNodeIPs(ctx context.Context, in *BackfillNodeIPsRequest, opts ...grpc.CallOption) (*BackfillNodeIPsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(BackfillNodeIPsResponse)
|
||||
@@ -353,6 +353,7 @@ type HeadscaleServiceServer interface {
|
||||
// --- PreAuthKeys start ---
|
||||
CreatePreAuthKey(context.Context, *CreatePreAuthKeyRequest) (*CreatePreAuthKeyResponse, error)
|
||||
ExpirePreAuthKey(context.Context, *ExpirePreAuthKeyRequest) (*ExpirePreAuthKeyResponse, error)
|
||||
DeletePreAuthKey(context.Context, *DeletePreAuthKeyRequest) (*DeletePreAuthKeyResponse, error)
|
||||
ListPreAuthKeys(context.Context, *ListPreAuthKeysRequest) (*ListPreAuthKeysResponse, error)
|
||||
// --- Node start ---
|
||||
DebugCreateNode(context.Context, *DebugCreateNodeRequest) (*DebugCreateNodeResponse, error)
|
||||
@@ -364,7 +365,6 @@ type HeadscaleServiceServer interface {
|
||||
ExpireNode(context.Context, *ExpireNodeRequest) (*ExpireNodeResponse, error)
|
||||
RenameNode(context.Context, *RenameNodeRequest) (*RenameNodeResponse, error)
|
||||
ListNodes(context.Context, *ListNodesRequest) (*ListNodesResponse, error)
|
||||
MoveNode(context.Context, *MoveNodeRequest) (*MoveNodeResponse, error)
|
||||
BackfillNodeIPs(context.Context, *BackfillNodeIPsRequest) (*BackfillNodeIPsResponse, error)
|
||||
// --- ApiKeys start ---
|
||||
CreateApiKey(context.Context, *CreateApiKeyRequest) (*CreateApiKeyResponse, error)
|
||||
@@ -387,79 +387,79 @@ type HeadscaleServiceServer interface {
|
||||
type UnimplementedHeadscaleServiceServer struct{}
|
||||
|
||||
func (UnimplementedHeadscaleServiceServer) CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateUser not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method CreateUser not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) RenameUser(context.Context, *RenameUserRequest) (*RenameUserResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RenameUser not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method RenameUser not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) DeleteUser(context.Context, *DeleteUserRequest) (*DeleteUserResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DeleteUser not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method DeleteUser not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListUsers not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method ListUsers not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) CreatePreAuthKey(context.Context, *CreatePreAuthKeyRequest) (*CreatePreAuthKeyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreatePreAuthKey not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method CreatePreAuthKey not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) ExpirePreAuthKey(context.Context, *ExpirePreAuthKeyRequest) (*ExpirePreAuthKeyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ExpirePreAuthKey not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method ExpirePreAuthKey not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) DeletePreAuthKey(context.Context, *DeletePreAuthKeyRequest) (*DeletePreAuthKeyResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method DeletePreAuthKey not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) ListPreAuthKeys(context.Context, *ListPreAuthKeysRequest) (*ListPreAuthKeysResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListPreAuthKeys not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method ListPreAuthKeys not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) DebugCreateNode(context.Context, *DebugCreateNodeRequest) (*DebugCreateNodeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DebugCreateNode not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method DebugCreateNode not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) GetNode(context.Context, *GetNodeRequest) (*GetNodeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetNode not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method GetNode not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) SetTags(context.Context, *SetTagsRequest) (*SetTagsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetTags not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method SetTags not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) SetApprovedRoutes(context.Context, *SetApprovedRoutesRequest) (*SetApprovedRoutesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetApprovedRoutes not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method SetApprovedRoutes not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) RegisterNode(context.Context, *RegisterNodeRequest) (*RegisterNodeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RegisterNode not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method RegisterNode not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) DeleteNode(context.Context, *DeleteNodeRequest) (*DeleteNodeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DeleteNode not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method DeleteNode not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) ExpireNode(context.Context, *ExpireNodeRequest) (*ExpireNodeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ExpireNode not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method ExpireNode not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) RenameNode(context.Context, *RenameNodeRequest) (*RenameNodeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RenameNode not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method RenameNode not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) ListNodes(context.Context, *ListNodesRequest) (*ListNodesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListNodes not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) MoveNode(context.Context, *MoveNodeRequest) (*MoveNodeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method MoveNode not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method ListNodes not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) BackfillNodeIPs(context.Context, *BackfillNodeIPsRequest) (*BackfillNodeIPsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method BackfillNodeIPs not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method BackfillNodeIPs not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) CreateApiKey(context.Context, *CreateApiKeyRequest) (*CreateApiKeyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateApiKey not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method CreateApiKey not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) ExpireApiKey(context.Context, *ExpireApiKeyRequest) (*ExpireApiKeyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ExpireApiKey not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method ExpireApiKey not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) ListApiKeys(context.Context, *ListApiKeysRequest) (*ListApiKeysResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListApiKeys not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method ListApiKeys not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) DeleteApiKey(context.Context, *DeleteApiKeyRequest) (*DeleteApiKeyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DeleteApiKey not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method DeleteApiKey not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) GetPolicy(context.Context, *GetPolicyRequest) (*GetPolicyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetPolicy not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method GetPolicy not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetPolicy not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method SetPolicy not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Health not implemented")
|
||||
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) mustEmbedUnimplementedHeadscaleServiceServer() {}
|
||||
func (UnimplementedHeadscaleServiceServer) testEmbeddedByValue() {}
|
||||
@@ -472,7 +472,7 @@ type UnsafeHeadscaleServiceServer interface {
|
||||
}
|
||||
|
||||
func RegisterHeadscaleServiceServer(s grpc.ServiceRegistrar, srv HeadscaleServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedHeadscaleServiceServer was
|
||||
// If the following call panics, it indicates UnimplementedHeadscaleServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
@@ -590,6 +590,24 @@ func _HeadscaleService_ExpirePreAuthKey_Handler(srv interface{}, ctx context.Con
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _HeadscaleService_DeletePreAuthKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DeletePreAuthKeyRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(HeadscaleServiceServer).DeletePreAuthKey(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: HeadscaleService_DeletePreAuthKey_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(HeadscaleServiceServer).DeletePreAuthKey(ctx, req.(*DeletePreAuthKeyRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _HeadscaleService_ListPreAuthKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListPreAuthKeysRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -770,24 +788,6 @@ func _HeadscaleService_ListNodes_Handler(srv interface{}, ctx context.Context, d
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _HeadscaleService_MoveNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(MoveNodeRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(HeadscaleServiceServer).MoveNode(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: HeadscaleService_MoveNode_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(HeadscaleServiceServer).MoveNode(ctx, req.(*MoveNodeRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _HeadscaleService_BackfillNodeIPs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(BackfillNodeIPsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -963,6 +963,10 @@ var HeadscaleService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ExpirePreAuthKey",
|
||||
Handler: _HeadscaleService_ExpirePreAuthKey_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DeletePreAuthKey",
|
||||
Handler: _HeadscaleService_DeletePreAuthKey_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListPreAuthKeys",
|
||||
Handler: _HeadscaleService_ListPreAuthKeys_Handler,
|
||||
@@ -1003,10 +1007,6 @@ var HeadscaleService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ListNodes",
|
||||
Handler: _HeadscaleService_ListNodes_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "MoveNode",
|
||||
Handler: _HeadscaleService_MoveNode_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BackfillNodeIPs",
|
||||
Handler: _HeadscaleService_BackfillNodeIPs_Handler,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc (unknown)
|
||||
// source: headscale/v1/node.proto
|
||||
|
||||
@@ -1006,102 +1006,6 @@ func (x *ListNodesResponse) GetNodes() []*Node {
|
||||
return nil
|
||||
}
|
||||
|
||||
type MoveNodeRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"`
|
||||
User uint64 `protobuf:"varint,2,opt,name=user,proto3" json:"user,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *MoveNodeRequest) Reset() {
|
||||
*x = MoveNodeRequest{}
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[17]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *MoveNodeRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*MoveNodeRequest) ProtoMessage() {}
|
||||
|
||||
func (x *MoveNodeRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[17]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use MoveNodeRequest.ProtoReflect.Descriptor instead.
|
||||
func (*MoveNodeRequest) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_node_proto_rawDescGZIP(), []int{17}
|
||||
}
|
||||
|
||||
func (x *MoveNodeRequest) GetNodeId() uint64 {
|
||||
if x != nil {
|
||||
return x.NodeId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *MoveNodeRequest) GetUser() uint64 {
|
||||
if x != nil {
|
||||
return x.User
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type MoveNodeResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *MoveNodeResponse) Reset() {
|
||||
*x = MoveNodeResponse{}
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[18]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *MoveNodeResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*MoveNodeResponse) ProtoMessage() {}
|
||||
|
||||
func (x *MoveNodeResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[18]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use MoveNodeResponse.ProtoReflect.Descriptor instead.
|
||||
func (*MoveNodeResponse) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_node_proto_rawDescGZIP(), []int{18}
|
||||
}
|
||||
|
||||
func (x *MoveNodeResponse) GetNode() *Node {
|
||||
if x != nil {
|
||||
return x.Node
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type DebugCreateNodeRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
|
||||
@@ -1114,7 +1018,7 @@ type DebugCreateNodeRequest struct {
|
||||
|
||||
func (x *DebugCreateNodeRequest) Reset() {
|
||||
*x = DebugCreateNodeRequest{}
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[19]
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[17]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1126,7 +1030,7 @@ func (x *DebugCreateNodeRequest) String() string {
|
||||
func (*DebugCreateNodeRequest) ProtoMessage() {}
|
||||
|
||||
func (x *DebugCreateNodeRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[19]
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[17]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1139,7 +1043,7 @@ func (x *DebugCreateNodeRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use DebugCreateNodeRequest.ProtoReflect.Descriptor instead.
|
||||
func (*DebugCreateNodeRequest) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_node_proto_rawDescGZIP(), []int{19}
|
||||
return file_headscale_v1_node_proto_rawDescGZIP(), []int{17}
|
||||
}
|
||||
|
||||
func (x *DebugCreateNodeRequest) GetUser() string {
|
||||
@@ -1179,7 +1083,7 @@ type DebugCreateNodeResponse struct {
|
||||
|
||||
func (x *DebugCreateNodeResponse) Reset() {
|
||||
*x = DebugCreateNodeResponse{}
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[20]
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[18]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1191,7 +1095,7 @@ func (x *DebugCreateNodeResponse) String() string {
|
||||
func (*DebugCreateNodeResponse) ProtoMessage() {}
|
||||
|
||||
func (x *DebugCreateNodeResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[20]
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[18]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1204,7 +1108,7 @@ func (x *DebugCreateNodeResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use DebugCreateNodeResponse.ProtoReflect.Descriptor instead.
|
||||
func (*DebugCreateNodeResponse) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_node_proto_rawDescGZIP(), []int{20}
|
||||
return file_headscale_v1_node_proto_rawDescGZIP(), []int{18}
|
||||
}
|
||||
|
||||
func (x *DebugCreateNodeResponse) GetNode() *Node {
|
||||
@@ -1223,7 +1127,7 @@ type BackfillNodeIPsRequest struct {
|
||||
|
||||
func (x *BackfillNodeIPsRequest) Reset() {
|
||||
*x = BackfillNodeIPsRequest{}
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[21]
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[19]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1235,7 +1139,7 @@ func (x *BackfillNodeIPsRequest) String() string {
|
||||
func (*BackfillNodeIPsRequest) ProtoMessage() {}
|
||||
|
||||
func (x *BackfillNodeIPsRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[21]
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[19]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1248,7 +1152,7 @@ func (x *BackfillNodeIPsRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use BackfillNodeIPsRequest.ProtoReflect.Descriptor instead.
|
||||
func (*BackfillNodeIPsRequest) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_node_proto_rawDescGZIP(), []int{21}
|
||||
return file_headscale_v1_node_proto_rawDescGZIP(), []int{19}
|
||||
}
|
||||
|
||||
func (x *BackfillNodeIPsRequest) GetConfirmed() bool {
|
||||
@@ -1267,7 +1171,7 @@ type BackfillNodeIPsResponse struct {
|
||||
|
||||
func (x *BackfillNodeIPsResponse) Reset() {
|
||||
*x = BackfillNodeIPsResponse{}
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[22]
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[20]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1279,7 +1183,7 @@ func (x *BackfillNodeIPsResponse) String() string {
|
||||
func (*BackfillNodeIPsResponse) ProtoMessage() {}
|
||||
|
||||
func (x *BackfillNodeIPsResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[22]
|
||||
mi := &file_headscale_v1_node_proto_msgTypes[20]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1292,7 +1196,7 @@ func (x *BackfillNodeIPsResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use BackfillNodeIPsResponse.ProtoReflect.Descriptor instead.
|
||||
func (*BackfillNodeIPsResponse) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_node_proto_rawDescGZIP(), []int{22}
|
||||
return file_headscale_v1_node_proto_rawDescGZIP(), []int{20}
|
||||
}
|
||||
|
||||
func (x *BackfillNodeIPsResponse) GetChanges() []string {
|
||||
@@ -1371,12 +1275,7 @@ const file_headscale_v1_node_proto_rawDesc = "" +
|
||||
"\x10ListNodesRequest\x12\x12\n" +
|
||||
"\x04user\x18\x01 \x01(\tR\x04user\"=\n" +
|
||||
"\x11ListNodesResponse\x12(\n" +
|
||||
"\x05nodes\x18\x01 \x03(\v2\x12.headscale.v1.NodeR\x05nodes\">\n" +
|
||||
"\x0fMoveNodeRequest\x12\x17\n" +
|
||||
"\anode_id\x18\x01 \x01(\x04R\x06nodeId\x12\x12\n" +
|
||||
"\x04user\x18\x02 \x01(\x04R\x04user\":\n" +
|
||||
"\x10MoveNodeResponse\x12&\n" +
|
||||
"\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"j\n" +
|
||||
"\x05nodes\x18\x01 \x03(\v2\x12.headscale.v1.NodeR\x05nodes\"j\n" +
|
||||
"\x16DebugCreateNodeRequest\x12\x12\n" +
|
||||
"\x04user\x18\x01 \x01(\tR\x04user\x12\x10\n" +
|
||||
"\x03key\x18\x02 \x01(\tR\x03key\x12\x12\n" +
|
||||
@@ -1407,7 +1306,7 @@ func file_headscale_v1_node_proto_rawDescGZIP() []byte {
|
||||
}
|
||||
|
||||
var file_headscale_v1_node_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_headscale_v1_node_proto_msgTypes = make([]protoimpl.MessageInfo, 23)
|
||||
var file_headscale_v1_node_proto_msgTypes = make([]protoimpl.MessageInfo, 21)
|
||||
var file_headscale_v1_node_proto_goTypes = []any{
|
||||
(RegisterMethod)(0), // 0: headscale.v1.RegisterMethod
|
||||
(*Node)(nil), // 1: headscale.v1.Node
|
||||
@@ -1427,38 +1326,35 @@ var file_headscale_v1_node_proto_goTypes = []any{
|
||||
(*RenameNodeResponse)(nil), // 15: headscale.v1.RenameNodeResponse
|
||||
(*ListNodesRequest)(nil), // 16: headscale.v1.ListNodesRequest
|
||||
(*ListNodesResponse)(nil), // 17: headscale.v1.ListNodesResponse
|
||||
(*MoveNodeRequest)(nil), // 18: headscale.v1.MoveNodeRequest
|
||||
(*MoveNodeResponse)(nil), // 19: headscale.v1.MoveNodeResponse
|
||||
(*DebugCreateNodeRequest)(nil), // 20: headscale.v1.DebugCreateNodeRequest
|
||||
(*DebugCreateNodeResponse)(nil), // 21: headscale.v1.DebugCreateNodeResponse
|
||||
(*BackfillNodeIPsRequest)(nil), // 22: headscale.v1.BackfillNodeIPsRequest
|
||||
(*BackfillNodeIPsResponse)(nil), // 23: headscale.v1.BackfillNodeIPsResponse
|
||||
(*User)(nil), // 24: headscale.v1.User
|
||||
(*timestamppb.Timestamp)(nil), // 25: google.protobuf.Timestamp
|
||||
(*PreAuthKey)(nil), // 26: headscale.v1.PreAuthKey
|
||||
(*DebugCreateNodeRequest)(nil), // 18: headscale.v1.DebugCreateNodeRequest
|
||||
(*DebugCreateNodeResponse)(nil), // 19: headscale.v1.DebugCreateNodeResponse
|
||||
(*BackfillNodeIPsRequest)(nil), // 20: headscale.v1.BackfillNodeIPsRequest
|
||||
(*BackfillNodeIPsResponse)(nil), // 21: headscale.v1.BackfillNodeIPsResponse
|
||||
(*User)(nil), // 22: headscale.v1.User
|
||||
(*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp
|
||||
(*PreAuthKey)(nil), // 24: headscale.v1.PreAuthKey
|
||||
}
|
||||
var file_headscale_v1_node_proto_depIdxs = []int32{
|
||||
24, // 0: headscale.v1.Node.user:type_name -> headscale.v1.User
|
||||
25, // 1: headscale.v1.Node.last_seen:type_name -> google.protobuf.Timestamp
|
||||
25, // 2: headscale.v1.Node.expiry:type_name -> google.protobuf.Timestamp
|
||||
26, // 3: headscale.v1.Node.pre_auth_key:type_name -> headscale.v1.PreAuthKey
|
||||
25, // 4: headscale.v1.Node.created_at:type_name -> google.protobuf.Timestamp
|
||||
22, // 0: headscale.v1.Node.user:type_name -> headscale.v1.User
|
||||
23, // 1: headscale.v1.Node.last_seen:type_name -> google.protobuf.Timestamp
|
||||
23, // 2: headscale.v1.Node.expiry:type_name -> google.protobuf.Timestamp
|
||||
24, // 3: headscale.v1.Node.pre_auth_key:type_name -> headscale.v1.PreAuthKey
|
||||
23, // 4: headscale.v1.Node.created_at:type_name -> google.protobuf.Timestamp
|
||||
0, // 5: headscale.v1.Node.register_method:type_name -> headscale.v1.RegisterMethod
|
||||
1, // 6: headscale.v1.RegisterNodeResponse.node:type_name -> headscale.v1.Node
|
||||
1, // 7: headscale.v1.GetNodeResponse.node:type_name -> headscale.v1.Node
|
||||
1, // 8: headscale.v1.SetTagsResponse.node:type_name -> headscale.v1.Node
|
||||
1, // 9: headscale.v1.SetApprovedRoutesResponse.node:type_name -> headscale.v1.Node
|
||||
25, // 10: headscale.v1.ExpireNodeRequest.expiry:type_name -> google.protobuf.Timestamp
|
||||
23, // 10: headscale.v1.ExpireNodeRequest.expiry:type_name -> google.protobuf.Timestamp
|
||||
1, // 11: headscale.v1.ExpireNodeResponse.node:type_name -> headscale.v1.Node
|
||||
1, // 12: headscale.v1.RenameNodeResponse.node:type_name -> headscale.v1.Node
|
||||
1, // 13: headscale.v1.ListNodesResponse.nodes:type_name -> headscale.v1.Node
|
||||
1, // 14: headscale.v1.MoveNodeResponse.node:type_name -> headscale.v1.Node
|
||||
1, // 15: headscale.v1.DebugCreateNodeResponse.node:type_name -> headscale.v1.Node
|
||||
16, // [16:16] is the sub-list for method output_type
|
||||
16, // [16:16] is the sub-list for method input_type
|
||||
16, // [16:16] is the sub-list for extension type_name
|
||||
16, // [16:16] is the sub-list for extension extendee
|
||||
0, // [0:16] is the sub-list for field type_name
|
||||
1, // 14: headscale.v1.DebugCreateNodeResponse.node:type_name -> headscale.v1.Node
|
||||
15, // [15:15] is the sub-list for method output_type
|
||||
15, // [15:15] is the sub-list for method input_type
|
||||
15, // [15:15] is the sub-list for extension type_name
|
||||
15, // [15:15] is the sub-list for extension extendee
|
||||
0, // [0:15] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_headscale_v1_node_proto_init() }
|
||||
@@ -1474,7 +1370,7 @@ func file_headscale_v1_node_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_node_proto_rawDesc), len(file_headscale_v1_node_proto_rawDesc)),
|
||||
NumEnums: 1,
|
||||
NumMessages: 23,
|
||||
NumMessages: 21,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc (unknown)
|
||||
// source: headscale/v1/policy.proto
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc (unknown)
|
||||
// source: headscale/v1/preauthkey.proto
|
||||
|
||||
@@ -338,6 +338,94 @@ func (*ExpirePreAuthKeyResponse) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
type DeletePreAuthKeyRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
User uint64 `protobuf:"varint,1,opt,name=user,proto3" json:"user,omitempty"`
|
||||
Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *DeletePreAuthKeyRequest) Reset() {
|
||||
*x = DeletePreAuthKeyRequest{}
|
||||
mi := &file_headscale_v1_preauthkey_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *DeletePreAuthKeyRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*DeletePreAuthKeyRequest) ProtoMessage() {}
|
||||
|
||||
func (x *DeletePreAuthKeyRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_preauthkey_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use DeletePreAuthKeyRequest.ProtoReflect.Descriptor instead.
|
||||
func (*DeletePreAuthKeyRequest) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *DeletePreAuthKeyRequest) GetUser() uint64 {
|
||||
if x != nil {
|
||||
return x.User
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *DeletePreAuthKeyRequest) GetKey() string {
|
||||
if x != nil {
|
||||
return x.Key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type DeletePreAuthKeyResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *DeletePreAuthKeyResponse) Reset() {
|
||||
*x = DeletePreAuthKeyResponse{}
|
||||
mi := &file_headscale_v1_preauthkey_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *DeletePreAuthKeyResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*DeletePreAuthKeyResponse) ProtoMessage() {}
|
||||
|
||||
func (x *DeletePreAuthKeyResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_preauthkey_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use DeletePreAuthKeyResponse.ProtoReflect.Descriptor instead.
|
||||
func (*DeletePreAuthKeyResponse) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
type ListPreAuthKeysRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
User uint64 `protobuf:"varint,1,opt,name=user,proto3" json:"user,omitempty"`
|
||||
@@ -347,7 +435,7 @@ type ListPreAuthKeysRequest struct {
|
||||
|
||||
func (x *ListPreAuthKeysRequest) Reset() {
|
||||
*x = ListPreAuthKeysRequest{}
|
||||
mi := &file_headscale_v1_preauthkey_proto_msgTypes[5]
|
||||
mi := &file_headscale_v1_preauthkey_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -359,7 +447,7 @@ func (x *ListPreAuthKeysRequest) String() string {
|
||||
func (*ListPreAuthKeysRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ListPreAuthKeysRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_preauthkey_proto_msgTypes[5]
|
||||
mi := &file_headscale_v1_preauthkey_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -372,7 +460,7 @@ func (x *ListPreAuthKeysRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ListPreAuthKeysRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ListPreAuthKeysRequest) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{5}
|
||||
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *ListPreAuthKeysRequest) GetUser() uint64 {
|
||||
@@ -391,7 +479,7 @@ type ListPreAuthKeysResponse struct {
|
||||
|
||||
func (x *ListPreAuthKeysResponse) Reset() {
|
||||
*x = ListPreAuthKeysResponse{}
|
||||
mi := &file_headscale_v1_preauthkey_proto_msgTypes[6]
|
||||
mi := &file_headscale_v1_preauthkey_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -403,7 +491,7 @@ func (x *ListPreAuthKeysResponse) String() string {
|
||||
func (*ListPreAuthKeysResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ListPreAuthKeysResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_preauthkey_proto_msgTypes[6]
|
||||
mi := &file_headscale_v1_preauthkey_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -416,7 +504,7 @@ func (x *ListPreAuthKeysResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ListPreAuthKeysResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ListPreAuthKeysResponse) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{6}
|
||||
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *ListPreAuthKeysResponse) GetPreAuthKeys() []*PreAuthKey {
|
||||
@@ -459,7 +547,11 @@ const file_headscale_v1_preauthkey_proto_rawDesc = "" +
|
||||
"\x17ExpirePreAuthKeyRequest\x12\x12\n" +
|
||||
"\x04user\x18\x01 \x01(\x04R\x04user\x12\x10\n" +
|
||||
"\x03key\x18\x02 \x01(\tR\x03key\"\x1a\n" +
|
||||
"\x18ExpirePreAuthKeyResponse\",\n" +
|
||||
"\x18ExpirePreAuthKeyResponse\"?\n" +
|
||||
"\x17DeletePreAuthKeyRequest\x12\x12\n" +
|
||||
"\x04user\x18\x01 \x01(\x04R\x04user\x12\x10\n" +
|
||||
"\x03key\x18\x02 \x01(\tR\x03key\"\x1a\n" +
|
||||
"\x18DeletePreAuthKeyResponse\",\n" +
|
||||
"\x16ListPreAuthKeysRequest\x12\x12\n" +
|
||||
"\x04user\x18\x01 \x01(\x04R\x04user\"W\n" +
|
||||
"\x17ListPreAuthKeysResponse\x12<\n" +
|
||||
@@ -477,30 +569,32 @@ func file_headscale_v1_preauthkey_proto_rawDescGZIP() []byte {
|
||||
return file_headscale_v1_preauthkey_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_headscale_v1_preauthkey_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
|
||||
var file_headscale_v1_preauthkey_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
|
||||
var file_headscale_v1_preauthkey_proto_goTypes = []any{
|
||||
(*PreAuthKey)(nil), // 0: headscale.v1.PreAuthKey
|
||||
(*CreatePreAuthKeyRequest)(nil), // 1: headscale.v1.CreatePreAuthKeyRequest
|
||||
(*CreatePreAuthKeyResponse)(nil), // 2: headscale.v1.CreatePreAuthKeyResponse
|
||||
(*ExpirePreAuthKeyRequest)(nil), // 3: headscale.v1.ExpirePreAuthKeyRequest
|
||||
(*ExpirePreAuthKeyResponse)(nil), // 4: headscale.v1.ExpirePreAuthKeyResponse
|
||||
(*ListPreAuthKeysRequest)(nil), // 5: headscale.v1.ListPreAuthKeysRequest
|
||||
(*ListPreAuthKeysResponse)(nil), // 6: headscale.v1.ListPreAuthKeysResponse
|
||||
(*User)(nil), // 7: headscale.v1.User
|
||||
(*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp
|
||||
(*DeletePreAuthKeyRequest)(nil), // 5: headscale.v1.DeletePreAuthKeyRequest
|
||||
(*DeletePreAuthKeyResponse)(nil), // 6: headscale.v1.DeletePreAuthKeyResponse
|
||||
(*ListPreAuthKeysRequest)(nil), // 7: headscale.v1.ListPreAuthKeysRequest
|
||||
(*ListPreAuthKeysResponse)(nil), // 8: headscale.v1.ListPreAuthKeysResponse
|
||||
(*User)(nil), // 9: headscale.v1.User
|
||||
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
|
||||
}
|
||||
var file_headscale_v1_preauthkey_proto_depIdxs = []int32{
|
||||
7, // 0: headscale.v1.PreAuthKey.user:type_name -> headscale.v1.User
|
||||
8, // 1: headscale.v1.PreAuthKey.expiration:type_name -> google.protobuf.Timestamp
|
||||
8, // 2: headscale.v1.PreAuthKey.created_at:type_name -> google.protobuf.Timestamp
|
||||
8, // 3: headscale.v1.CreatePreAuthKeyRequest.expiration:type_name -> google.protobuf.Timestamp
|
||||
0, // 4: headscale.v1.CreatePreAuthKeyResponse.pre_auth_key:type_name -> headscale.v1.PreAuthKey
|
||||
0, // 5: headscale.v1.ListPreAuthKeysResponse.pre_auth_keys:type_name -> headscale.v1.PreAuthKey
|
||||
6, // [6:6] is the sub-list for method output_type
|
||||
6, // [6:6] is the sub-list for method input_type
|
||||
6, // [6:6] is the sub-list for extension type_name
|
||||
6, // [6:6] is the sub-list for extension extendee
|
||||
0, // [0:6] is the sub-list for field type_name
|
||||
9, // 0: headscale.v1.PreAuthKey.user:type_name -> headscale.v1.User
|
||||
10, // 1: headscale.v1.PreAuthKey.expiration:type_name -> google.protobuf.Timestamp
|
||||
10, // 2: headscale.v1.PreAuthKey.created_at:type_name -> google.protobuf.Timestamp
|
||||
10, // 3: headscale.v1.CreatePreAuthKeyRequest.expiration:type_name -> google.protobuf.Timestamp
|
||||
0, // 4: headscale.v1.CreatePreAuthKeyResponse.pre_auth_key:type_name -> headscale.v1.PreAuthKey
|
||||
0, // 5: headscale.v1.ListPreAuthKeysResponse.pre_auth_keys:type_name -> headscale.v1.PreAuthKey
|
||||
6, // [6:6] is the sub-list for method output_type
|
||||
6, // [6:6] is the sub-list for method input_type
|
||||
6, // [6:6] is the sub-list for extension type_name
|
||||
6, // [6:6] is the sub-list for extension extendee
|
||||
0, // [0:6] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_headscale_v1_preauthkey_proto_init() }
|
||||
@@ -515,7 +609,7 @@ func file_headscale_v1_preauthkey_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_preauthkey_proto_rawDesc), len(file_headscale_v1_preauthkey_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 7,
|
||||
NumMessages: 9,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc (unknown)
|
||||
// source: headscale/v1/user.proto
|
||||
|
||||
|
||||
@@ -496,45 +496,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/node/{nodeId}/user": {
|
||||
"post": {
|
||||
"operationId": "HeadscaleService_MoveNode",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1MoveNodeResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "nodeId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"format": "uint64"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/HeadscaleServiceMoveNodeBody"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"HeadscaleService"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/policy": {
|
||||
"get": {
|
||||
"summary": "--- Policy start ---",
|
||||
@@ -618,6 +579,41 @@
|
||||
"HeadscaleService"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "HeadscaleService_DeletePreAuthKey",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1DeletePreAuthKeyResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "user",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"format": "uint64"
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"HeadscaleService"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"summary": "--- PreAuthKeys start ---",
|
||||
"operationId": "HeadscaleService_CreatePreAuthKey",
|
||||
@@ -826,15 +822,6 @@
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"HeadscaleServiceMoveNodeBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user": {
|
||||
"type": "string",
|
||||
"format": "uint64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"HeadscaleServiceSetApprovedRoutesBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1029,6 +1016,9 @@
|
||||
"v1DeleteNodeResponse": {
|
||||
"type": "object"
|
||||
},
|
||||
"v1DeletePreAuthKeyResponse": {
|
||||
"type": "object"
|
||||
},
|
||||
"v1DeleteUserResponse": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -1142,14 +1132,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1MoveNodeResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {
|
||||
"$ref": "#/definitions/v1Node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1Node": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
6
go.sum
@@ -124,8 +124,6 @@ github.com/creachadair/command v0.2.0 h1:qTA9cMMhZePAxFoNdnk6F6nn94s1qPndIg9hJbq
|
||||
github.com/creachadair/command v0.2.0/go.mod h1:j+Ar+uYnFsHpkMeV9kGj6lJ45y9u2xqtg8FYy6cm+0o=
|
||||
github.com/creachadair/flax v0.0.5 h1:zt+CRuXQASxwQ68e9GHAOnEgAU29nF0zYMHOCrL5wzE=
|
||||
github.com/creachadair/flax v0.0.5/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8=
|
||||
github.com/creachadair/mds v0.25.2 h1:xc0S0AfDq5GX9KUR5sLvi5XjA61/P6S5e0xFs1vA18Q=
|
||||
github.com/creachadair/mds v0.25.2/go.mod h1:+s4CFteFRj4eq2KcGHW8Wei3u9NyzSPzNV32EvjyK/Q=
|
||||
github.com/creachadair/mds v0.25.10 h1:9k9JB35D1xhOCFl0liBhagBBp8fWWkKZrA7UXsfoHtA=
|
||||
github.com/creachadair/mds v0.25.10/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs=
|
||||
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
||||
@@ -278,8 +276,6 @@ 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.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE=
|
||||
github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@@ -463,8 +459,6 @@ github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+y
|
||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
|
||||
github.com/tailscale/setec v0.0.0-20250305161714-445cadbbca3d h1:mnqtPWYyvNiPU9l9tzO2YbHXU/xV664XthZYA26lOiE=
|
||||
github.com/tailscale/setec v0.0.0-20250305161714-445cadbbca3d/go.mod h1:9BzmlFc3OLqLzLTF/5AY+BMs+clxMqyhSGzgXIm8mNI=
|
||||
github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694 h1:95eIP97c88cqAFU/8nURjgI9xxPbD+Ci6mY/a79BI/w=
|
||||
github.com/tailscale/squibble v0.0.0-20250108170732-a4ca58afa694/go.mod h1:veguaG8tVg1H/JG5RfpoUW41I+O8ClPElo/fTYr8mMk=
|
||||
github.com/tailscale/squibble v0.0.0-20251030164342-4d5df9caa993 h1:FyiiAvDAxpB0DrW2GW3KOVfi3YFOtsQUEeFWbf55JJU=
|
||||
github.com/tailscale/squibble v0.0.0-20251030164342-4d5df9caa993/go.mod h1:xJkMmR3t+thnUQhA3Q4m2VSlS5pcOq+CIjmU/xfKKx4=
|
||||
github.com/tailscale/tailsql v0.0.0-20250421235516-02f85f087b97 h1:JJkDnrAhHvOCttk8z9xeZzcDlzzkRA7+Duxj9cwOyxk=
|
||||
|
||||
111
hscontrol/app.go
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
_ "net/http/pprof" // nolint
|
||||
@@ -270,7 +271,7 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
|
||||
return
|
||||
|
||||
case <-expireTicker.C:
|
||||
var expiredNodeChanges []change.ChangeSet
|
||||
var expiredNodeChanges []change.Change
|
||||
var changed bool
|
||||
|
||||
lastExpiryCheck, expiredNodeChanges, changed = h.state.ExpireExpiredNodes(lastExpiryCheck)
|
||||
@@ -304,7 +305,7 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
|
||||
}
|
||||
h.state.SetDERPMap(derpMap)
|
||||
|
||||
h.Change(change.DERPSet)
|
||||
h.Change(change.DERPMap())
|
||||
|
||||
case records, ok := <-extraRecordsUpdate:
|
||||
if !ok {
|
||||
@@ -312,7 +313,7 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
|
||||
}
|
||||
h.cfg.TailcfgDNSConfig.ExtraRecords = records
|
||||
|
||||
h.Change(change.ExtraRecordsSet)
|
||||
h.Change(change.ExtraRecords())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -476,8 +477,8 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *mux.Router {
|
||||
apiRouter := router.PathPrefix("/api").Subrouter()
|
||||
apiRouter.Use(h.httpAuthenticationMiddleware)
|
||||
apiRouter.PathPrefix("/v1/").HandlerFunc(grpcMux.ServeHTTP)
|
||||
|
||||
router.PathPrefix("/").HandlerFunc(notFoundHandler)
|
||||
router.HandleFunc("/favicon.ico", FaviconHandler)
|
||||
router.PathPrefix("/").HandlerFunc(BlankHandler)
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -729,16 +730,27 @@ func (h *Headscale) Serve() error {
|
||||
log.Info().
|
||||
Msgf("listening and serving HTTP on: %s", h.cfg.Addr)
|
||||
|
||||
debugHTTPListener, err := net.Listen("tcp", h.cfg.MetricsAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to bind to TCP address: %w", err)
|
||||
// Only start debug/metrics server if address is configured
|
||||
var debugHTTPServer *http.Server
|
||||
|
||||
var debugHTTPListener net.Listener
|
||||
|
||||
if h.cfg.MetricsAddr != "" {
|
||||
debugHTTPListener, err = (&net.ListenConfig{}).Listen(ctx, "tcp", h.cfg.MetricsAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to bind to TCP address: %w", err)
|
||||
}
|
||||
|
||||
debugHTTPServer = h.debugHTTPServer()
|
||||
|
||||
errorGroup.Go(func() error { return debugHTTPServer.Serve(debugHTTPListener) })
|
||||
|
||||
log.Info().
|
||||
Msgf("listening and serving debug and metrics on: %s", h.cfg.MetricsAddr)
|
||||
} else {
|
||||
log.Info().Msg("metrics server disabled (metrics_listen_addr is empty)")
|
||||
}
|
||||
|
||||
debugHTTPServer := h.debugHTTPServer()
|
||||
errorGroup.Go(func() error { return debugHTTPServer.Serve(debugHTTPListener) })
|
||||
|
||||
log.Info().
|
||||
Msgf("listening and serving debug and metrics on: %s", h.cfg.MetricsAddr)
|
||||
|
||||
var tailsqlContext context.Context
|
||||
if tailsqlEnabled {
|
||||
@@ -794,16 +806,25 @@ func (h *Headscale) Serve() error {
|
||||
h.ephemeralGC.Close()
|
||||
|
||||
// Gracefully shut down servers
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
shutdownCtx, cancel := context.WithTimeout(
|
||||
context.WithoutCancel(ctx),
|
||||
types.HTTPShutdownTimeout,
|
||||
)
|
||||
info("shutting down debug http server")
|
||||
if err := debugHTTPServer.Shutdown(ctx); err != nil {
|
||||
log.Error().Err(err).Msg("failed to shutdown prometheus http")
|
||||
defer cancel()
|
||||
|
||||
if debugHTTPServer != nil {
|
||||
info("shutting down debug http server")
|
||||
|
||||
err := debugHTTPServer.Shutdown(shutdownCtx)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to shutdown prometheus http")
|
||||
}
|
||||
}
|
||||
|
||||
info("shutting down main http server")
|
||||
if err := httpServer.Shutdown(ctx); err != nil {
|
||||
|
||||
err := httpServer.Shutdown(shutdownCtx)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to shutdown http")
|
||||
}
|
||||
|
||||
@@ -829,7 +850,10 @@ func (h *Headscale) Serve() error {
|
||||
|
||||
// Close network listeners
|
||||
info("closing network listeners")
|
||||
debugHTTPListener.Close()
|
||||
|
||||
if debugHTTPListener != nil {
|
||||
debugHTTPListener.Close()
|
||||
}
|
||||
httpListener.Close()
|
||||
grpcGatewayConn.Close()
|
||||
|
||||
@@ -847,9 +871,6 @@ func (h *Headscale) Serve() error {
|
||||
log.Info().
|
||||
Msg("Headscale stopped")
|
||||
|
||||
// And we're done:
|
||||
cancel()
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -877,6 +898,11 @@ func (h *Headscale) getTLSSettings() (*tls.Config, error) {
|
||||
Cache: autocert.DirCache(h.cfg.TLS.LetsEncrypt.CacheDir),
|
||||
Client: &acme.Client{
|
||||
DirectoryURL: h.cfg.ACMEURL,
|
||||
HTTPClient: &http.Client{
|
||||
Transport: &acmeLogger{
|
||||
rt: http.DefaultTransport,
|
||||
},
|
||||
},
|
||||
},
|
||||
Email: h.cfg.ACMEEmail,
|
||||
}
|
||||
@@ -935,18 +961,6 @@ func (h *Headscale) getTLSSettings() (*tls.Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func notFoundHandler(
|
||||
writer http.ResponseWriter,
|
||||
req *http.Request,
|
||||
) {
|
||||
log.Trace().
|
||||
Interface("header", req.Header).
|
||||
Interface("proto", req.Proto).
|
||||
Interface("url", req.URL).
|
||||
Msg("Request did not match")
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
||||
func readOrCreatePrivateKey(path string) (*key.MachinePrivate, error) {
|
||||
dir := filepath.Dir(path)
|
||||
err := util.EnsureDir(dir)
|
||||
@@ -994,6 +1008,31 @@ func readOrCreatePrivateKey(path string) (*key.MachinePrivate, error) {
|
||||
// Change is used to send changes to nodes.
|
||||
// All change should be enqueued here and empty will be automatically
|
||||
// ignored.
|
||||
func (h *Headscale) Change(cs ...change.ChangeSet) {
|
||||
func (h *Headscale) Change(cs ...change.Change) {
|
||||
h.mapBatcher.AddWork(cs...)
|
||||
}
|
||||
|
||||
// Provide some middleware that can inspect the ACME/autocert https calls
|
||||
// and log when things are failing.
|
||||
type acmeLogger struct {
|
||||
rt http.RoundTripper
|
||||
}
|
||||
|
||||
// RoundTrip will log when ACME/autocert failures happen either when err != nil OR
|
||||
// when http status codes indicate a failure has occurred.
|
||||
func (l *acmeLogger) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := l.rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("url", req.URL.String()).Msg("ACME request failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Error().Int("status_code", resp.StatusCode).Str("url", req.URL.String()).Bytes("body", body).Msg("ACME request returned error")
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
24
hscontrol/assets/assets.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Package assets provides embedded static assets for Headscale.
|
||||
// All static files (favicon, CSS, SVG) are embedded here for
|
||||
// centralized asset management.
|
||||
package assets
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
// Favicon is the embedded favicon.png file served at /favicon.ico
|
||||
//
|
||||
//go:embed favicon.png
|
||||
var Favicon []byte
|
||||
|
||||
// CSS is the embedded style.css stylesheet used in HTML templates.
|
||||
// Contains Material for MkDocs design system styles.
|
||||
//
|
||||
//go:embed style.css
|
||||
var CSS string
|
||||
|
||||
// SVG is the embedded headscale.svg logo used in HTML templates.
|
||||
//
|
||||
//go:embed headscale.svg
|
||||
var SVG string
|
||||
BIN
hscontrol/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
1
hscontrol/assets/headscale.svg
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
@@ -1,307 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Headscale Authentication Succeeded</title>
|
||||
<style>
|
||||
body {
|
||||
font-size: 14px;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
"Roboto",
|
||||
"Oxygen",
|
||||
"Ubuntu",
|
||||
"Cantarell",
|
||||
"Fira Sans",
|
||||
"Droid Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-color: #fdfdfe;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 70vh;
|
||||
}
|
||||
|
||||
#logo {
|
||||
display: block;
|
||||
margin-left: -20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
min-width: 40vw;
|
||||
background: #fafdfa;
|
||||
border: 1px solid #c6e9c9;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 16px 16px 12px;
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.message #checkbox {
|
||||
fill: #2eb039;
|
||||
}
|
||||
|
||||
.message .message-title {
|
||||
color: #1e7125;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.message .message-body {
|
||||
border: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.message p {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #17421b;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
color: #1563ff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: black;
|
||||
}
|
||||
|
||||
a svg {
|
||||
fill: currentcolor;
|
||||
}
|
||||
|
||||
.icon {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
width: 21px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 17.5px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h1 + p {
|
||||
margin: 8px 0 16px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body translate="no">
|
||||
<div class="container">
|
||||
<div>
|
||||
<svg
|
||||
id="logo"
|
||||
width="146"
|
||||
height="51"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
style="
|
||||
fill-rule: evenodd;
|
||||
clip-rule: evenodd;
|
||||
stroke-linejoin: round;
|
||||
stroke-miterlimit: 2;
|
||||
"
|
||||
viewBox="0 0 1280 640"
|
||||
>
|
||||
<path
|
||||
d="M.08 0v-.736h.068v.3C.203-.509.27-.545.347-.545c.029 0 .055.005.079.015.024.01.045.025.062.045.017.02.031.045.041.075.009.03.014.065.014.105V0H.475v-.289C.475-.352.464-.4.443-.433.422-.466.385-.483.334-.483c-.027 0-.052.006-.075.017C.236-.455.216-.439.2-.419c-.017.02-.029.044-.038.072-.009.028-.014.059-.014.093V0H.08Z"
|
||||
style="fill: #f8b5cb; fill-rule: nonzero"
|
||||
transform="translate(32.92220721 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="M.051-.264c0-.036.007-.071.02-.105.013-.034.031-.064.055-.09.023-.026.052-.047.086-.063.033-.015.071-.023.112-.023.039 0 .076.007.109.021.033.014.062.033.087.058.025.025.044.054.058.088.014.035.021.072.021.113v.005H.121c.001.031.007.059.018.084.01.025.024.047.042.065.018.019.04.033.065.043.025.01.052.015.082.015.026 0 .049-.003.069-.01.02-.007.038-.016.054-.028C.466-.102.48-.115.492-.13c.011-.015.022-.03.032-.046l.057.03C.556-.097.522-.058.48-.03.437-.001.387.013.328.013.284.013.245.006.21-.01.175-.024.146-.045.123-.07.1-.095.082-.125.07-.159.057-.192.051-.227.051-.264ZM.128-.32h.396C.51-.375.485-.416.449-.441.412-.466.371-.479.325-.479c-.048 0-.089.013-.123.039-.034.026-.059.066-.074.12Z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(177.16674681 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.097h.067V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.012-.027.018-.056.018-.089 0-.031-.005-.059-.016-.086C.515-.375.501-.398.482-.417.462-.436.44-.452.415-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(327.76463481 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.302h.068V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.011-.027.017-.056.017-.089 0-.031-.005-.059-.016-.086C.514-.375.5-.398.481-.417.462-.436.439-.452.414-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(488.71612761 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="m.034-.062.043-.049c.017.019.035.034.054.044.018.01.037.015.057.015.013 0 .026-.002.038-.007.011-.004.021-.01.031-.018.009-.008.016-.017.021-.028.005-.011.008-.022.008-.035 0-.019-.005-.034-.014-.047C.263-.199.248-.21.229-.221.205-.234.183-.247.162-.259.14-.271.122-.284.107-.298.092-.311.08-.327.071-.344.062-.361.058-.381.058-.404c0-.021.004-.04.012-.058.007-.016.018-.031.031-.044.013-.013.028-.022.046-.029.018-.007.037-.01.057-.01.029 0 .056.006.079.019s.045.031.068.053l-.044.045C.291-.443.275-.456.258-.465.241-.474.221-.479.2-.479c-.022 0-.041.007-.056.02C.128-.445.12-.428.12-.408c0 .019.006.035.017.048.011.013.027.026.048.037.027.015.05.028.071.04.021.013.038.026.052.039.014.013.025.028.032.044.007.016.011.035.011.057 0 .021-.004.041-.011.059-.008.019-.019.036-.033.05-.014.015-.031.026-.05.035C.237.01.215.014.191.014c-.03 0-.059-.006-.086-.02C.077-.019.053-.037.034-.062Z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(649.90292961 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="M.051-.266c0-.04.007-.077.022-.111.014-.034.034-.063.059-.089.025-.025.054-.044.089-.058.035-.014.072-.021.113-.021.051 0 .098.01.139.03.041.021.075.049.1.085l-.05.043C.498-.418.47-.441.439-.456.408-.471.372-.479.331-.479c-.03 0-.058.005-.083.016C.222-.452.2-.436.181-.418.162-.399.148-.376.137-.35c-.011.026-.016.054-.016.084 0 .031.005.06.016.086.011.027.025.049.044.068.019.019.041.034.067.044.025.011.053.016.084.016.077 0 .141-.03.191-.09l.051.04c-.028.036-.062.064-.103.085C.43.004.384.014.332.014.291.014.254.007.219-.008.184-.022.155-.042.13-.067.105-.092.086-.121.072-.156.058-.19.051-.227.051-.266Z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(741.20289921 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.097h.067V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.012-.027.018-.056.018-.089 0-.031-.005-.059-.016-.086C.515-.375.501-.398.482-.417.462-.436.44-.452.415-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(884.27089281 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="M.066-.736h.068V0H.066z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(1045.22238561 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="M.051-.264c0-.036.007-.071.02-.105.013-.034.031-.064.055-.09.023-.026.052-.047.086-.063.033-.015.071-.023.112-.023.039 0 .076.007.109.021.033.014.062.033.087.058.025.025.044.054.058.088.014.035.021.072.021.113v.005H.121c.001.031.007.059.018.084.01.025.024.047.042.065.018.019.04.033.065.043.025.01.052.015.082.015.026 0 .049-.003.069-.01.02-.007.038-.016.054-.028C.466-.102.48-.115.492-.13c.011-.015.022-.03.032-.046l.057.03C.556-.097.522-.058.48-.03.437-.001.387.013.328.013.284.013.245.006.21-.01.175-.024.146-.045.123-.07.1-.095.082-.125.07-.159.057-.192.051-.227.051-.264ZM.128-.32h.396C.51-.375.485-.416.449-.441.412-.466.371-.479.325-.479c-.048 0-.089.013-.123.039-.034.026-.059.066-.074.12Z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(1092.28422561 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<circle
|
||||
cx="141.023"
|
||||
cy="338.36"
|
||||
r="117.472"
|
||||
style="fill: #f8b5cb"
|
||||
transform="matrix(.581302 0 0 .58613 40.06479894 12.59842153)"
|
||||
/>
|
||||
<circle
|
||||
cx="352.014"
|
||||
cy="268.302"
|
||||
r="33.095"
|
||||
style="fill: #a2a2a2"
|
||||
transform="matrix(.59308 0 0 .58289 32.39345942 21.2386)"
|
||||
/>
|
||||
<circle
|
||||
cx="352.014"
|
||||
cy="268.302"
|
||||
r="33.095"
|
||||
style="fill: #a2a2a2"
|
||||
transform="matrix(.59308 0 0 .58289 32.39345942 88.80371146)"
|
||||
/>
|
||||
<circle
|
||||
cx="352.014"
|
||||
cy="268.302"
|
||||
r="33.095"
|
||||
style="fill: #a2a2a2"
|
||||
transform="matrix(.59308 0 0 .58289 120.7528627 88.80371146)"
|
||||
/>
|
||||
<circle
|
||||
cx="352.014"
|
||||
cy="268.302"
|
||||
r="33.095"
|
||||
style="fill: #a2a2a2"
|
||||
transform="matrix(.59308 0 0 .58289 120.99825939 21.2386)"
|
||||
/>
|
||||
<circle
|
||||
cx="805.557"
|
||||
cy="336.915"
|
||||
r="118.199"
|
||||
style="fill: #8d8d8d"
|
||||
transform="matrix(.5782 0 0 .58289 36.19871106 15.26642564)"
|
||||
/>
|
||||
<circle
|
||||
cx="805.557"
|
||||
cy="336.915"
|
||||
r="118.199"
|
||||
style="fill: #8d8d8d"
|
||||
transform="matrix(.5782 0 0 .58289 183.24041937 15.26642564)"
|
||||
/>
|
||||
<path
|
||||
d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z"
|
||||
style="fill: #303030"
|
||||
transform="translate(34.2345 21.2386) scale(.58289)"
|
||||
/>
|
||||
<path
|
||||
d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z"
|
||||
style="fill: #303030"
|
||||
transform="matrix(-.58289 0 0 .58289 1116.7719791 21.2386)"
|
||||
/>
|
||||
</svg>
|
||||
<div class="message is-success">
|
||||
<svg
|
||||
id="checkbox"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32zm114.9 149.1L231.8 359.6c-1.1 1.1-2.9 3.5-5.1 3.5-2.3 0-3.8-1.6-5.1-2.9-1.3-1.3-78.9-75.9-78.9-75.9l-1.5-1.5c-.6-.9-1.1-2-1.1-3.2 0-1.2.5-2.3 1.1-3.2.4-.4.7-.7 1.1-1.2 7.7-8.1 23.3-24.5 24.3-25.5 1.3-1.3 2.4-3 4.8-3 2.5 0 4.1 2.1 5.3 3.3 1.2 1.2 45 43.3 45 43.3l111.3-143c1-.8 2.2-1.4 3.5-1.4 1.3 0 2.5.5 3.5 1.3l30.6 24.1c.8 1 1.3 2.2 1.3 3.5.1 1.3-.4 2.4-1 3.3z"
|
||||
></path>
|
||||
</svg>
|
||||
<div class="message-content">
|
||||
<div class="message-title">Signed in via your OIDC provider</div>
|
||||
<p class="message-body">
|
||||
{{.Verb}} as {{.User}}, you can now close this window.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<h1>Not sure how to get started?</h1>
|
||||
<p class="learn">
|
||||
Check out beginner and advanced guides on, or read more in the
|
||||
documentation.
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/juanfont/headscale/tree/main/docs"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="icon">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.307 1H11.5a.5.5 0 1 1 0-1h3a.499.499 0 0 1 .5.65V3.5a.5.5 0 1 1-1 0V1.72l-1.793 1.774a.5.5 0 0 1-.713-.701L13.307 1zM12 14V8a.5.5 0 1 1 1 0v6.5a.5.5 0 0 1-.5.5H.563a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 .5-.5H8a.5.5 0 0 1 0 1H1v12h11zM4 6a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1H4zm0 2.5a.5.5 0 0 1 0-1h5a.5.5 0 0 1 0 1H4zM4 11a.5.5 0 1 1 0-1h5a.5.5 0 1 1 0 1H4z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
View the headscale documentation
|
||||
</a>
|
||||
<a
|
||||
href="https://tailscale.com/kb/"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="icon">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.307 1H11.5a.5.5 0 1 1 0-1h3a.499.499 0 0 1 .5.65V3.5a.5.5 0 1 1-1 0V1.72l-1.793 1.774a.5.5 0 0 1-.713-.701L13.307 1zM12 14V8a.5.5 0 1 1 1 0v6.5a.5.5 0 0 1-.5.5H.563a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 .5-.5H8a.5.5 0 0 1 0 1H1v12h11zM4 6a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1H4zm0 2.5a.5.5 0 0 1 0-1h5a.5.5 0 0 1 0 1H4zM4 11a.5.5 0 1 1 0-1h5a.5.5 0 1 1 0 1H4z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
View the tailscale documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
143
hscontrol/assets/style.css
Normal file
@@ -0,0 +1,143 @@
|
||||
/* CSS Variables from Material for MkDocs */
|
||||
:root {
|
||||
--md-default-fg-color: rgba(0, 0, 0, 0.87);
|
||||
--md-default-fg-color--light: rgba(0, 0, 0, 0.54);
|
||||
--md-default-fg-color--lighter: rgba(0, 0, 0, 0.32);
|
||||
--md-default-fg-color--lightest: rgba(0, 0, 0, 0.07);
|
||||
--md-code-fg-color: #36464e;
|
||||
--md-code-bg-color: #f5f5f5;
|
||||
--md-primary-fg-color: #4051b5;
|
||||
--md-accent-fg-color: #526cfe;
|
||||
--md-typeset-a-color: var(--md-primary-fg-color);
|
||||
--md-text-font: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
--md-code-font: "Roboto Mono", "SF Mono", Monaco, "Cascadia Code", Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* Base Typography */
|
||||
.md-typeset {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
color: var(--md-default-fg-color);
|
||||
font-family: var(--md-text-font);
|
||||
overflow-wrap: break-word;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.md-typeset h1 {
|
||||
color: var(--md-default-fg-color--light);
|
||||
font-size: 2em;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 1.25em;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.md-typeset h1:not(:first-child) {
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
.md-typeset h2 {
|
||||
font-size: 1.5625em;
|
||||
line-height: 1.4;
|
||||
margin: 2.4em 0 0.64em;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--md-default-fg-color--light);
|
||||
}
|
||||
|
||||
.md-typeset h3 {
|
||||
font-size: 1.25em;
|
||||
line-height: 1.5;
|
||||
margin: 2em 0 0.8em;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--md-default-fg-color--light);
|
||||
}
|
||||
|
||||
/* Paragraphs and block elements */
|
||||
.md-typeset p {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.md-typeset blockquote,
|
||||
.md-typeset dl,
|
||||
.md-typeset figure,
|
||||
.md-typeset ol,
|
||||
.md-typeset pre,
|
||||
.md-typeset ul {
|
||||
margin-bottom: 1em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.md-typeset ol,
|
||||
.md-typeset ul {
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.md-typeset a {
|
||||
color: var(--md-typeset-a-color);
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.md-typeset a:hover,
|
||||
.md-typeset a:focus {
|
||||
color: var(--md-accent-fg-color);
|
||||
}
|
||||
|
||||
/* Code (inline) */
|
||||
.md-typeset code {
|
||||
background-color: var(--md-code-bg-color);
|
||||
color: var(--md-code-fg-color);
|
||||
border-radius: 0.1rem;
|
||||
font-size: 0.85em;
|
||||
font-family: var(--md-code-font);
|
||||
padding: 0 0.2941176471em;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Code blocks (pre) */
|
||||
.md-typeset pre {
|
||||
display: block;
|
||||
line-height: 1.4;
|
||||
margin: 1em 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.md-typeset pre > code {
|
||||
background-color: var(--md-code-bg-color);
|
||||
color: var(--md-code-fg-color);
|
||||
display: block;
|
||||
padding: 0.7720588235em 1.1764705882em;
|
||||
font-family: var(--md-code-font);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Links in code */
|
||||
.md-typeset a code {
|
||||
color: currentcolor;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.headscale-logo {
|
||||
display: block;
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 0 0 3rem 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.headscale-logo {
|
||||
width: 200px;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
@@ -71,6 +70,13 @@ func (h *Headscale) handleRegister(
|
||||
// We do not look up nodes by [key.MachinePublic] as it might belong to multiple
|
||||
// nodes, separated by users and this path is handling expiring/logout paths.
|
||||
if node, ok := h.state.GetNodeByNodeKey(req.NodeKey); ok {
|
||||
// When tailscaled restarts, it sends RegisterRequest with Auth=nil and Expiry=zero.
|
||||
// Return the current node state without modification.
|
||||
// See: https://github.com/juanfont/headscale/issues/2862
|
||||
if req.Expiry.IsZero() && node.Expiry().Valid() && !node.IsExpired() {
|
||||
return nodeToRegisterResponse(node), nil
|
||||
}
|
||||
|
||||
resp, err := h.handleLogout(node, req, machineKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("handling existing node: %w", err)
|
||||
@@ -173,6 +179,7 @@ func (h *Headscale) handleLogout(
|
||||
}
|
||||
|
||||
// If the request expiry is in the past, we consider it a logout.
|
||||
// Zero expiry is handled in handleRegister() before calling this function.
|
||||
if req.Expiry.Before(time.Now()) {
|
||||
log.Debug().
|
||||
Uint64("node.id", node.ID().Uint64()).
|
||||
@@ -226,11 +233,7 @@ func isAuthKey(req tailcfg.RegisterRequest) bool {
|
||||
}
|
||||
|
||||
func nodeToRegisterResponse(node types.NodeView) *tailcfg.RegisterResponse {
|
||||
return &tailcfg.RegisterResponse{
|
||||
// TODO(kradalby): Only send for user-owned nodes
|
||||
// and not tagged nodes when tags is working.
|
||||
User: node.UserView().TailscaleUser(),
|
||||
Login: node.UserView().TailscaleLogin(),
|
||||
resp := &tailcfg.RegisterResponse{
|
||||
NodeKeyExpired: node.IsExpired(),
|
||||
|
||||
// Headscale does not implement the concept of machine authorization
|
||||
@@ -238,6 +241,18 @@ func nodeToRegisterResponse(node types.NodeView) *tailcfg.RegisterResponse {
|
||||
// Revisit this if #2176 gets implemented.
|
||||
MachineAuthorized: true,
|
||||
}
|
||||
|
||||
// For tagged nodes, use the TaggedDevices special user
|
||||
// For user-owned nodes, include User and Login information from the actual user
|
||||
if node.IsTagged() {
|
||||
resp.User = types.TaggedDevices.View().TailscaleUser()
|
||||
resp.Login = types.TaggedDevices.View().TailscaleLogin()
|
||||
} else if node.Owner().Valid() {
|
||||
resp.User = node.Owner().TailscaleUser()
|
||||
resp.Login = node.Owner().TailscaleLogin()
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *Headscale) waitForFollowup(
|
||||
@@ -356,16 +371,13 @@ func (h *Headscale) handleRegisterWithAuthKey(
|
||||
// eventbus.
|
||||
// TODO(kradalby): This needs to be ran as part of the batcher maybe?
|
||||
// now since we dont update the node/pol here anymore
|
||||
routeChange := h.state.AutoApproveRoutes(node)
|
||||
|
||||
if _, _, err := h.state.SaveNode(node); err != nil {
|
||||
return nil, fmt.Errorf("saving auto approved routes to node: %w", err)
|
||||
routesChange, err := h.state.AutoApproveRoutes(node)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auto approving routes: %w", err)
|
||||
}
|
||||
|
||||
if routeChange && changed.Empty() {
|
||||
changed = change.NodeAdded(node.ID())
|
||||
}
|
||||
h.Change(changed)
|
||||
// Send both changes. Empty changes are ignored by Change().
|
||||
h.Change(changed, routesChange)
|
||||
|
||||
// TODO(kradalby): I think this is covered above, but we need to validate that.
|
||||
// // If policy changed due to node registration, send a separate policy change
|
||||
@@ -377,8 +389,8 @@ func (h *Headscale) handleRegisterWithAuthKey(
|
||||
resp := &tailcfg.RegisterResponse{
|
||||
MachineAuthorized: true,
|
||||
NodeKeyExpired: node.IsExpired(),
|
||||
User: node.UserView().TailscaleUser(),
|
||||
Login: node.UserView().TailscaleLogin(),
|
||||
User: node.Owner().TailscaleUser(),
|
||||
Login: node.Owner().TailscaleLogin(),
|
||||
}
|
||||
|
||||
log.Trace().
|
||||
|
||||
535
hscontrol/auth_tags_test.go
Normal file
@@ -0,0 +1,535 @@
|
||||
package hscontrol
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
// TestTaggedPreAuthKeyCreatesTaggedNode tests that a PreAuthKey with tags creates
|
||||
// a tagged node with:
|
||||
// - Tags from the PreAuthKey
|
||||
// - UserID tracking who created the key (informational "created by")
|
||||
// - IsTagged() returns true.
|
||||
func TestTaggedPreAuthKeyCreatesTaggedNode(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
user := app.state.CreateUserForTest("tag-creator")
|
||||
tags := []string{"tag:server", "tag:prod"}
|
||||
|
||||
// Create a tagged PreAuthKey
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, tags)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, pak.Tags, "PreAuthKey should have tags")
|
||||
require.ElementsMatch(t, tags, pak.Tags, "PreAuthKey should have specified tags")
|
||||
|
||||
// Register a node using the tagged key
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "tagged-node",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
// Verify the node was created with tags
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
|
||||
// Critical assertions for tags-as-identity model
|
||||
assert.True(t, node.IsTagged(), "Node should be tagged")
|
||||
assert.ElementsMatch(t, tags, node.Tags().AsSlice(), "Node should have tags from PreAuthKey")
|
||||
assert.True(t, node.UserID().Valid(), "Node should have UserID tracking creator")
|
||||
assert.Equal(t, user.ID, node.UserID().Get(), "UserID should track PreAuthKey creator")
|
||||
|
||||
// Verify node is identified correctly
|
||||
assert.True(t, node.IsTagged(), "Tagged node is not user-owned")
|
||||
assert.True(t, node.HasTag("tag:server"), "Node should have tag:server")
|
||||
assert.True(t, node.HasTag("tag:prod"), "Node should have tag:prod")
|
||||
assert.False(t, node.HasTag("tag:other"), "Node should not have tag:other")
|
||||
}
|
||||
|
||||
// TestReAuthDoesNotReapplyTags tests that when a node re-authenticates using the
|
||||
// same PreAuthKey, the tags are NOT re-applied. Tags are only set during initial
|
||||
// authentication. This is critical for the container restart scenario (#2830).
|
||||
//
|
||||
// NOTE: This test verifies that re-authentication preserves the node's current tags
|
||||
// without testing tag modification via SetNodeTags (which requires ACL policy setup).
|
||||
func TestReAuthDoesNotReapplyTags(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
user := app.state.CreateUserForTest("tag-creator")
|
||||
initialTags := []string{"tag:server", "tag:dev"}
|
||||
|
||||
// Create a tagged PreAuthKey with reusable=true for re-auth
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, initialTags)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Initial registration
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "reauth-test-node",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
// Verify initial tags
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
require.True(t, node.IsTagged())
|
||||
require.ElementsMatch(t, initialTags, node.Tags().AsSlice())
|
||||
|
||||
// Re-authenticate with the SAME PreAuthKey (container restart scenario)
|
||||
// Key behavior: Tags should NOT be re-applied during re-auth
|
||||
reAuthReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key, // Same key
|
||||
},
|
||||
NodeKey: nodeKey.Public(), // Same node key
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "reauth-test-node",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
reAuthResp, err := app.handleRegisterWithAuthKey(reAuthReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, reAuthResp.MachineAuthorized)
|
||||
|
||||
// CRITICAL: Tags should remain unchanged after re-auth
|
||||
// They should match the original tags, proving they weren't re-applied
|
||||
nodeAfterReauth, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
assert.True(t, nodeAfterReauth.IsTagged(), "Node should still be tagged")
|
||||
assert.ElementsMatch(t, initialTags, nodeAfterReauth.Tags().AsSlice(), "Tags should remain unchanged on re-auth")
|
||||
|
||||
// Verify only one node was created (no duplicates)
|
||||
nodes := app.state.ListNodesByUser(types.UserID(user.ID))
|
||||
assert.Equal(t, 1, nodes.Len(), "Should have exactly one node")
|
||||
}
|
||||
|
||||
// NOTE: TestSetTagsOnUserOwnedNode functionality is covered by gRPC tests in grpcv1_test.go
|
||||
// which properly handle ACL policy setup. The test verifies that SetTags can convert
|
||||
// user-owned nodes to tagged nodes while preserving UserID.
|
||||
|
||||
// TestCannotRemoveAllTags tests that attempting to remove all tags from a
|
||||
// tagged node fails with ErrCannotRemoveAllTags. Once a node is tagged,
|
||||
// it must always have at least one tag (Tailscale requirement).
|
||||
func TestCannotRemoveAllTags(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
user := app.state.CreateUserForTest("tag-creator")
|
||||
tags := []string{"tag:server"}
|
||||
|
||||
// Create a tagged node
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, tags)
|
||||
require.NoError(t, err)
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "tagged-node",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
// Verify node is tagged
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
require.True(t, node.IsTagged())
|
||||
|
||||
// Attempt to remove all tags by setting empty array
|
||||
_, _, err = app.state.SetNodeTags(node.ID(), []string{})
|
||||
require.Error(t, err, "Should not be able to remove all tags")
|
||||
require.ErrorIs(t, err, types.ErrCannotRemoveAllTags, "Error should be ErrCannotRemoveAllTags")
|
||||
|
||||
// Verify node still has original tags
|
||||
nodeAfter, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
assert.True(t, nodeAfter.IsTagged(), "Node should still be tagged")
|
||||
assert.ElementsMatch(t, tags, nodeAfter.Tags().AsSlice(), "Tags should be unchanged")
|
||||
}
|
||||
|
||||
// TestUserOwnedNodeCreatedWithUntaggedPreAuthKey tests that using a PreAuthKey
|
||||
// without tags creates a user-owned node (no tags, UserID is the owner).
|
||||
func TestUserOwnedNodeCreatedWithUntaggedPreAuthKey(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
user := app.state.CreateUserForTest("node-owner")
|
||||
|
||||
// Create an untagged PreAuthKey
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, pak.Tags, "PreAuthKey should not be tagged")
|
||||
require.Empty(t, pak.Tags, "PreAuthKey should have no tags")
|
||||
|
||||
// Register a node
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "user-owned-node",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
// Verify node is user-owned
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
|
||||
// Critical assertions for user-owned node
|
||||
assert.False(t, node.IsTagged(), "Node should not be tagged")
|
||||
assert.False(t, node.IsTagged(), "Node should be user-owned (not tagged)")
|
||||
assert.Empty(t, node.Tags().AsSlice(), "Node should have no tags")
|
||||
assert.True(t, node.UserID().Valid(), "Node should have UserID")
|
||||
assert.Equal(t, user.ID, node.UserID().Get(), "UserID should be the PreAuthKey owner")
|
||||
}
|
||||
|
||||
// TestMultipleNodesWithSameReusableTaggedPreAuthKey tests that a reusable
|
||||
// PreAuthKey with tags can be used to register multiple nodes, and all nodes
|
||||
// receive the same tags from the key.
|
||||
func TestMultipleNodesWithSameReusableTaggedPreAuthKey(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
user := app.state.CreateUserForTest("tag-creator")
|
||||
tags := []string{"tag:server", "tag:prod"}
|
||||
|
||||
// Create a REUSABLE tagged PreAuthKey
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, tags)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, tags, pak.Tags)
|
||||
|
||||
// Register first node
|
||||
machineKey1 := key.NewMachine()
|
||||
nodeKey1 := key.NewNode()
|
||||
|
||||
regReq1 := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey1.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "tagged-node-1",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp1, err := app.handleRegisterWithAuthKey(regReq1, machineKey1.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp1.MachineAuthorized)
|
||||
|
||||
// Register second node with SAME PreAuthKey
|
||||
machineKey2 := key.NewMachine()
|
||||
nodeKey2 := key.NewNode()
|
||||
|
||||
regReq2 := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key, // Same key
|
||||
},
|
||||
NodeKey: nodeKey2.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "tagged-node-2",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp2, err := app.handleRegisterWithAuthKey(regReq2, machineKey2.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp2.MachineAuthorized)
|
||||
|
||||
// Verify both nodes exist and have the same tags
|
||||
node1, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
require.True(t, found)
|
||||
node2, found := app.state.GetNodeByNodeKey(nodeKey2.Public())
|
||||
require.True(t, found)
|
||||
|
||||
// Both nodes should be tagged with the same tags
|
||||
assert.True(t, node1.IsTagged(), "First node should be tagged")
|
||||
assert.True(t, node2.IsTagged(), "Second node should be tagged")
|
||||
assert.ElementsMatch(t, tags, node1.Tags().AsSlice(), "First node should have PreAuthKey tags")
|
||||
assert.ElementsMatch(t, tags, node2.Tags().AsSlice(), "Second node should have PreAuthKey tags")
|
||||
|
||||
// Both nodes should track the same creator
|
||||
assert.Equal(t, user.ID, node1.UserID().Get(), "First node should track creator")
|
||||
assert.Equal(t, user.ID, node2.UserID().Get(), "Second node should track creator")
|
||||
|
||||
// Verify we have exactly 2 nodes
|
||||
nodes := app.state.ListNodesByUser(types.UserID(user.ID))
|
||||
assert.Equal(t, 2, nodes.Len(), "Should have exactly two nodes")
|
||||
}
|
||||
|
||||
// TestNonReusableTaggedPreAuthKey tests that a non-reusable PreAuthKey with tags
|
||||
// can only be used once. The second attempt should fail.
|
||||
func TestNonReusableTaggedPreAuthKey(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
user := app.state.CreateUserForTest("tag-creator")
|
||||
tags := []string{"tag:server"}
|
||||
|
||||
// Create a NON-REUSABLE tagged PreAuthKey
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, tags)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, tags, pak.Tags)
|
||||
|
||||
// Register first node - should succeed
|
||||
machineKey1 := key.NewMachine()
|
||||
nodeKey1 := key.NewNode()
|
||||
|
||||
regReq1 := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey1.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "tagged-node-1",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp1, err := app.handleRegisterWithAuthKey(regReq1, machineKey1.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp1.MachineAuthorized)
|
||||
|
||||
// Verify first node was created with tags
|
||||
node1, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
require.True(t, found)
|
||||
assert.True(t, node1.IsTagged())
|
||||
assert.ElementsMatch(t, tags, node1.Tags().AsSlice())
|
||||
|
||||
// Attempt to register second node with SAME non-reusable key - should fail
|
||||
machineKey2 := key.NewMachine()
|
||||
nodeKey2 := key.NewNode()
|
||||
|
||||
regReq2 := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key, // Same non-reusable key
|
||||
},
|
||||
NodeKey: nodeKey2.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "tagged-node-2",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
_, err = app.handleRegisterWithAuthKey(regReq2, machineKey2.Public())
|
||||
require.Error(t, err, "Should not be able to reuse non-reusable PreAuthKey")
|
||||
|
||||
// Verify only one node was created
|
||||
nodes := app.state.ListNodesByUser(types.UserID(user.ID))
|
||||
assert.Equal(t, 1, nodes.Len(), "Should have exactly one node")
|
||||
}
|
||||
|
||||
// TestExpiredTaggedPreAuthKey tests that an expired PreAuthKey with tags
|
||||
// cannot be used to register a node.
|
||||
func TestExpiredTaggedPreAuthKey(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
user := app.state.CreateUserForTest("tag-creator")
|
||||
tags := []string{"tag:server"}
|
||||
|
||||
// Create a PreAuthKey that expires immediately
|
||||
expiration := time.Now().Add(-1 * time.Hour) // Already expired
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, &expiration, tags)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, tags, pak.Tags)
|
||||
|
||||
// Attempt to register with expired key
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "tagged-node",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
_, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.Error(t, err, "Should not be able to use expired PreAuthKey")
|
||||
|
||||
// Verify no node was created
|
||||
_, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
assert.False(t, found, "No node should be created with expired key")
|
||||
}
|
||||
|
||||
// TestSingleVsMultipleTags tests that PreAuthKeys work correctly with both
|
||||
// a single tag and multiple tags.
|
||||
func TestSingleVsMultipleTags(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
user := app.state.CreateUserForTest("tag-creator")
|
||||
|
||||
// Test with single tag
|
||||
singleTag := []string{"tag:server"}
|
||||
pak1, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, singleTag)
|
||||
require.NoError(t, err)
|
||||
|
||||
machineKey1 := key.NewMachine()
|
||||
nodeKey1 := key.NewNode()
|
||||
|
||||
regReq1 := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak1.Key,
|
||||
},
|
||||
NodeKey: nodeKey1.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "single-tag-node",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp1, err := app.handleRegisterWithAuthKey(regReq1, machineKey1.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp1.MachineAuthorized)
|
||||
|
||||
node1, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
require.True(t, found)
|
||||
assert.True(t, node1.IsTagged())
|
||||
assert.ElementsMatch(t, singleTag, node1.Tags().AsSlice())
|
||||
|
||||
// Test with multiple tags
|
||||
multipleTags := []string{"tag:server", "tag:prod", "tag:database"}
|
||||
pak2, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, multipleTags)
|
||||
require.NoError(t, err)
|
||||
|
||||
machineKey2 := key.NewMachine()
|
||||
nodeKey2 := key.NewNode()
|
||||
|
||||
regReq2 := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak2.Key,
|
||||
},
|
||||
NodeKey: nodeKey2.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "multi-tag-node",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp2, err := app.handleRegisterWithAuthKey(regReq2, machineKey2.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp2.MachineAuthorized)
|
||||
|
||||
node2, found := app.state.GetNodeByNodeKey(nodeKey2.Public())
|
||||
require.True(t, found)
|
||||
assert.True(t, node2.IsTagged())
|
||||
assert.ElementsMatch(t, multipleTags, node2.Tags().AsSlice())
|
||||
|
||||
// Verify HasTag works for all tags
|
||||
assert.True(t, node2.HasTag("tag:server"))
|
||||
assert.True(t, node2.HasTag("tag:prod"))
|
||||
assert.True(t, node2.HasTag("tag:database"))
|
||||
assert.False(t, node2.HasTag("tag:other"))
|
||||
}
|
||||
|
||||
// TestReAuthWithDifferentMachineKey tests the edge case where a node attempts
|
||||
// to re-authenticate with the same NodeKey but a DIFFERENT MachineKey.
|
||||
// This scenario should be handled gracefully (currently creates a new node).
|
||||
func TestReAuthWithDifferentMachineKey(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
user := app.state.CreateUserForTest("tag-creator")
|
||||
tags := []string{"tag:server"}
|
||||
|
||||
// Create a reusable tagged PreAuthKey
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, tags)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Initial registration
|
||||
machineKey1 := key.NewMachine()
|
||||
nodeKey := key.NewNode() // Same NodeKey for both attempts
|
||||
|
||||
regReq1 := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "test-node",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp1, err := app.handleRegisterWithAuthKey(regReq1, machineKey1.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp1.MachineAuthorized)
|
||||
|
||||
// Verify initial node
|
||||
node1, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
assert.True(t, node1.IsTagged())
|
||||
|
||||
// Re-authenticate with DIFFERENT MachineKey but SAME NodeKey
|
||||
machineKey2 := key.NewMachine() // Different machine key
|
||||
|
||||
regReq2 := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(), // Same NodeKey
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "test-node",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp2, err := app.handleRegisterWithAuthKey(regReq2, machineKey2.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp2.MachineAuthorized)
|
||||
|
||||
// Verify the node still exists and has tags
|
||||
// Note: Depending on implementation, this might be the same node or a new node
|
||||
node2, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
assert.True(t, node2.IsTagged())
|
||||
assert.ElementsMatch(t, tags, node2.Tags().AsSlice())
|
||||
}
|
||||
@@ -12,7 +12,13 @@ import (
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 90
|
||||
const (
|
||||
// minVersionParts is the minimum number of version parts needed for major.minor.
|
||||
minVersionParts = 2
|
||||
|
||||
// legacyDERPCapVer is the capability version when LegacyDERP can be cleaned up.
|
||||
legacyDERPCapVer = 111
|
||||
)
|
||||
|
||||
// CanOldCodeBeCleanedUp is intended to be called on startup to see if
|
||||
// there are old code that can ble cleaned up, entries should contain
|
||||
@@ -21,7 +27,7 @@ const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 90
|
||||
//
|
||||
// All uses of Capability version checks should be listed here.
|
||||
func CanOldCodeBeCleanedUp() {
|
||||
if MinSupportedCapabilityVersion >= 111 {
|
||||
if MinSupportedCapabilityVersion >= legacyDERPCapVer {
|
||||
panic("LegacyDERP can be cleaned up in tail.go")
|
||||
}
|
||||
}
|
||||
@@ -29,12 +35,14 @@ func CanOldCodeBeCleanedUp() {
|
||||
func tailscaleVersSorted() []string {
|
||||
vers := xmaps.Keys(tailscaleToCapVer)
|
||||
sort.Strings(vers)
|
||||
|
||||
return vers
|
||||
}
|
||||
|
||||
func capVersSorted() []tailcfg.CapabilityVersion {
|
||||
capVers := xmaps.Keys(capVerToTailscaleVer)
|
||||
slices.Sort(capVers)
|
||||
|
||||
return capVers
|
||||
}
|
||||
|
||||
@@ -44,11 +52,25 @@ func TailscaleVersion(ver tailcfg.CapabilityVersion) string {
|
||||
}
|
||||
|
||||
// CapabilityVersion returns the CapabilityVersion for the given Tailscale version.
|
||||
// It accepts both full versions (v1.90.1) and minor versions (v1.90).
|
||||
func CapabilityVersion(ver string) tailcfg.CapabilityVersion {
|
||||
if !strings.HasPrefix(ver, "v") {
|
||||
ver = "v" + ver
|
||||
}
|
||||
return tailscaleToCapVer[ver]
|
||||
|
||||
// Try direct lookup first (works for minor versions like v1.90)
|
||||
if cv, ok := tailscaleToCapVer[ver]; ok {
|
||||
return cv
|
||||
}
|
||||
|
||||
// Try extracting minor version from full version (v1.90.1 -> v1.90)
|
||||
parts := strings.Split(strings.TrimPrefix(ver, "v"), ".")
|
||||
if len(parts) >= minVersionParts {
|
||||
minor := "v" + parts[0] + "." + parts[1]
|
||||
return tailscaleToCapVer[minor]
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// TailscaleLatest returns the n latest Tailscale versions.
|
||||
@@ -73,10 +95,12 @@ func TailscaleLatestMajorMinor(n int, stripV bool) []string {
|
||||
}
|
||||
|
||||
majors := set.Set[string]{}
|
||||
|
||||
for _, vers := range tailscaleVersSorted() {
|
||||
if stripV {
|
||||
vers = strings.TrimPrefix(vers, "v")
|
||||
}
|
||||
|
||||
v := strings.Split(vers, ".")
|
||||
majors.Add(v[0] + "." + v[1])
|
||||
}
|
||||
|
||||
@@ -5,47 +5,80 @@ package capver
|
||||
import "tailscale.com/tailcfg"
|
||||
|
||||
var tailscaleToCapVer = map[string]tailcfg.CapabilityVersion{
|
||||
"v1.64.0": 90,
|
||||
"v1.64.1": 90,
|
||||
"v1.64.2": 90,
|
||||
"v1.66.0": 95,
|
||||
"v1.66.1": 95,
|
||||
"v1.66.2": 95,
|
||||
"v1.66.3": 95,
|
||||
"v1.66.4": 95,
|
||||
"v1.68.0": 97,
|
||||
"v1.68.1": 97,
|
||||
"v1.68.2": 97,
|
||||
"v1.70.0": 102,
|
||||
"v1.72.0": 104,
|
||||
"v1.72.1": 104,
|
||||
"v1.74.0": 106,
|
||||
"v1.74.1": 106,
|
||||
"v1.76.0": 106,
|
||||
"v1.76.1": 106,
|
||||
"v1.76.6": 106,
|
||||
"v1.78.0": 109,
|
||||
"v1.78.1": 109,
|
||||
"v1.80.0": 113,
|
||||
"v1.80.1": 113,
|
||||
"v1.80.2": 113,
|
||||
"v1.80.3": 113,
|
||||
"v1.82.0": 115,
|
||||
"v1.82.5": 115,
|
||||
"v1.84.0": 116,
|
||||
"v1.84.1": 116,
|
||||
"v1.84.2": 116,
|
||||
"v1.24": 32,
|
||||
"v1.26": 32,
|
||||
"v1.28": 32,
|
||||
"v1.30": 41,
|
||||
"v1.32": 46,
|
||||
"v1.34": 51,
|
||||
"v1.36": 56,
|
||||
"v1.38": 58,
|
||||
"v1.40": 61,
|
||||
"v1.42": 62,
|
||||
"v1.44": 63,
|
||||
"v1.46": 65,
|
||||
"v1.48": 68,
|
||||
"v1.50": 74,
|
||||
"v1.52": 79,
|
||||
"v1.54": 79,
|
||||
"v1.56": 82,
|
||||
"v1.58": 85,
|
||||
"v1.60": 87,
|
||||
"v1.62": 88,
|
||||
"v1.64": 90,
|
||||
"v1.66": 95,
|
||||
"v1.68": 97,
|
||||
"v1.70": 102,
|
||||
"v1.72": 104,
|
||||
"v1.74": 106,
|
||||
"v1.76": 106,
|
||||
"v1.78": 109,
|
||||
"v1.80": 113,
|
||||
"v1.82": 115,
|
||||
"v1.84": 116,
|
||||
"v1.86": 123,
|
||||
"v1.88": 125,
|
||||
"v1.90": 130,
|
||||
"v1.92": 131,
|
||||
}
|
||||
|
||||
var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{
|
||||
90: "v1.64.0",
|
||||
95: "v1.66.0",
|
||||
97: "v1.68.0",
|
||||
102: "v1.70.0",
|
||||
104: "v1.72.0",
|
||||
106: "v1.74.0",
|
||||
109: "v1.78.0",
|
||||
113: "v1.80.0",
|
||||
115: "v1.82.0",
|
||||
116: "v1.84.0",
|
||||
32: "v1.24",
|
||||
41: "v1.30",
|
||||
46: "v1.32",
|
||||
51: "v1.34",
|
||||
56: "v1.36",
|
||||
58: "v1.38",
|
||||
61: "v1.40",
|
||||
62: "v1.42",
|
||||
63: "v1.44",
|
||||
65: "v1.46",
|
||||
68: "v1.48",
|
||||
74: "v1.50",
|
||||
79: "v1.52",
|
||||
82: "v1.56",
|
||||
85: "v1.58",
|
||||
87: "v1.60",
|
||||
88: "v1.62",
|
||||
90: "v1.64",
|
||||
95: "v1.66",
|
||||
97: "v1.68",
|
||||
102: "v1.70",
|
||||
104: "v1.72",
|
||||
106: "v1.74",
|
||||
109: "v1.78",
|
||||
113: "v1.80",
|
||||
115: "v1.82",
|
||||
116: "v1.84",
|
||||
123: "v1.86",
|
||||
125: "v1.88",
|
||||
130: "v1.90",
|
||||
131: "v1.92",
|
||||
}
|
||||
|
||||
// SupportedMajorMinorVersions is the number of major.minor Tailscale versions supported.
|
||||
const SupportedMajorMinorVersions = 10
|
||||
|
||||
// MinSupportedCapabilityVersion represents the minimum capability version
|
||||
// supported by this Headscale instance (latest 10 minor versions)
|
||||
const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 106
|
||||
|
||||
@@ -4,34 +4,10 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
func TestTailscaleLatestMajorMinor(t *testing.T) {
|
||||
tests := []struct {
|
||||
n int
|
||||
stripV bool
|
||||
expected []string
|
||||
}{
|
||||
{3, false, []string{"v1.80", "v1.82", "v1.84"}},
|
||||
{2, true, []string{"1.82", "1.84"}},
|
||||
// Lazy way to see all supported versions
|
||||
{10, true, []string{
|
||||
"1.66",
|
||||
"1.68",
|
||||
"1.70",
|
||||
"1.72",
|
||||
"1.74",
|
||||
"1.76",
|
||||
"1.78",
|
||||
"1.80",
|
||||
"1.82",
|
||||
"1.84",
|
||||
}},
|
||||
{0, false, nil},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
for _, test := range tailscaleLatestMajorMinorTests {
|
||||
t.Run("", func(t *testing.T) {
|
||||
output := TailscaleLatestMajorMinor(test.n, test.stripV)
|
||||
if diff := cmp.Diff(output, test.expected); diff != "" {
|
||||
@@ -42,19 +18,7 @@ func TestTailscaleLatestMajorMinor(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCapVerMinimumTailscaleVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input tailcfg.CapabilityVersion
|
||||
expected string
|
||||
}{
|
||||
{90, "v1.64.0"},
|
||||
{95, "v1.66.0"},
|
||||
{106, "v1.74.0"},
|
||||
{109, "v1.78.0"},
|
||||
{9001, ""}, // Test case for a version higher than any in the map
|
||||
{60, ""}, // Test case for a version lower than any in the map
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
for _, test := range capVerMinimumTailscaleVersionTests {
|
||||
t.Run("", func(t *testing.T) {
|
||||
output := TailscaleVersion(test.input)
|
||||
if output != test.expected {
|
||||
|
||||
40
hscontrol/capver/capver_test_data.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package capver
|
||||
|
||||
// Generated DO NOT EDIT
|
||||
|
||||
import "tailscale.com/tailcfg"
|
||||
|
||||
var tailscaleLatestMajorMinorTests = []struct {
|
||||
n int
|
||||
stripV bool
|
||||
expected []string
|
||||
}{
|
||||
{3, false, []string{"v1.88", "v1.90", "v1.92"}},
|
||||
{2, true, []string{"1.90", "1.92"}},
|
||||
{10, true, []string{
|
||||
"1.74",
|
||||
"1.76",
|
||||
"1.78",
|
||||
"1.80",
|
||||
"1.82",
|
||||
"1.84",
|
||||
"1.86",
|
||||
"1.88",
|
||||
"1.90",
|
||||
"1.92",
|
||||
}},
|
||||
{0, false, nil},
|
||||
}
|
||||
|
||||
var capVerMinimumTailscaleVersionTests = []struct {
|
||||
input tailcfg.CapabilityVersion
|
||||
expected string
|
||||
}{
|
||||
{106, "v1.74"},
|
||||
{32, "v1.24"},
|
||||
{41, "v1.30"},
|
||||
{46, "v1.32"},
|
||||
{51, "v1.34"},
|
||||
{9001, ""}, // Test case for a version higher than any in the map
|
||||
{60, ""}, // Test case for a version lower than any in the map
|
||||
}
|
||||
@@ -9,33 +9,64 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
apiPrefixLength = 7
|
||||
apiKeyLength = 32
|
||||
apiKeyPrefix = "hskey-api-" //nolint:gosec // This is a prefix, not a credential
|
||||
apiKeyPrefixLength = 12
|
||||
apiKeyHashLength = 64
|
||||
|
||||
// Legacy format constants.
|
||||
legacyAPIPrefixLength = 7
|
||||
legacyAPIKeyLength = 32
|
||||
)
|
||||
|
||||
var ErrAPIKeyFailedToParse = errors.New("failed to parse ApiKey")
|
||||
var (
|
||||
ErrAPIKeyFailedToParse = errors.New("failed to parse ApiKey")
|
||||
ErrAPIKeyGenerationFailed = errors.New("failed to generate API key")
|
||||
ErrAPIKeyInvalidGeneration = errors.New("generated API key failed validation")
|
||||
)
|
||||
|
||||
// CreateAPIKey creates a new ApiKey in a user, and returns it.
|
||||
func (hsdb *HSDatabase) CreateAPIKey(
|
||||
expiration *time.Time,
|
||||
) (string, *types.APIKey, error) {
|
||||
prefix, err := util.GenerateRandomStringURLSafe(apiPrefixLength)
|
||||
// Generate public prefix (12 chars)
|
||||
prefix, err := util.GenerateRandomStringURLSafe(apiKeyPrefixLength)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
toBeHashed, err := util.GenerateRandomStringURLSafe(apiKeyLength)
|
||||
// Validate prefix
|
||||
if len(prefix) != apiKeyPrefixLength {
|
||||
return "", nil, fmt.Errorf("%w: generated prefix has invalid length: expected %d, got %d", ErrAPIKeyInvalidGeneration, apiKeyPrefixLength, len(prefix))
|
||||
}
|
||||
|
||||
if !isValidBase64URLSafe(prefix) {
|
||||
return "", nil, fmt.Errorf("%w: generated prefix contains invalid characters", ErrAPIKeyInvalidGeneration)
|
||||
}
|
||||
|
||||
// Generate secret (64 chars)
|
||||
secret, err := util.GenerateRandomStringURLSafe(apiKeyHashLength)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Key to return to user, this will only be visible _once_
|
||||
keyStr := prefix + "." + toBeHashed
|
||||
// Validate secret
|
||||
if len(secret) != apiKeyHashLength {
|
||||
return "", nil, fmt.Errorf("%w: generated secret has invalid length: expected %d, got %d", ErrAPIKeyInvalidGeneration, apiKeyHashLength, len(secret))
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(toBeHashed), bcrypt.DefaultCost)
|
||||
if !isValidBase64URLSafe(secret) {
|
||||
return "", nil, fmt.Errorf("%w: generated secret contains invalid characters", ErrAPIKeyInvalidGeneration)
|
||||
}
|
||||
|
||||
// Full key string (shown ONCE to user)
|
||||
keyStr := apiKeyPrefix + prefix + "-" + secret
|
||||
|
||||
// bcrypt hash of secret
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -103,23 +134,164 @@ func (hsdb *HSDatabase) ExpireAPIKey(key *types.APIKey) error {
|
||||
}
|
||||
|
||||
func (hsdb *HSDatabase) ValidateAPIKey(keyStr string) (bool, error) {
|
||||
prefix, hash, found := strings.Cut(keyStr, ".")
|
||||
if !found {
|
||||
return false, ErrAPIKeyFailedToParse
|
||||
}
|
||||
|
||||
key, err := hsdb.GetAPIKey(prefix)
|
||||
key, err := validateAPIKey(hsdb.DB, keyStr)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to validate api key: %w", err)
|
||||
}
|
||||
|
||||
if key.Expiration.Before(time.Now()) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword(key.Hash, []byte(hash)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if key.Expiration != nil && key.Expiration.Before(time.Now()) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ParseAPIKeyPrefix extracts the database prefix from a display prefix.
|
||||
// Handles formats: "hskey-api-{12chars}-***", "hskey-api-{12chars}", or just "{12chars}".
|
||||
// Returns the 12-character prefix suitable for database lookup.
|
||||
func ParseAPIKeyPrefix(displayPrefix string) (string, error) {
|
||||
// If it's already just the 12-character prefix, return it
|
||||
if len(displayPrefix) == apiKeyPrefixLength && isValidBase64URLSafe(displayPrefix) {
|
||||
return displayPrefix, nil
|
||||
}
|
||||
|
||||
// If it starts with the API key prefix, parse it
|
||||
if strings.HasPrefix(displayPrefix, apiKeyPrefix) {
|
||||
// Remove the "hskey-api-" prefix
|
||||
_, remainder, found := strings.Cut(displayPrefix, apiKeyPrefix)
|
||||
if !found {
|
||||
return "", fmt.Errorf("%w: invalid display prefix format", ErrAPIKeyFailedToParse)
|
||||
}
|
||||
|
||||
// Extract just the first 12 characters (the actual prefix)
|
||||
if len(remainder) < apiKeyPrefixLength {
|
||||
return "", fmt.Errorf("%w: prefix too short", ErrAPIKeyFailedToParse)
|
||||
}
|
||||
|
||||
prefix := remainder[:apiKeyPrefixLength]
|
||||
|
||||
// Validate it's base64 URL-safe
|
||||
if !isValidBase64URLSafe(prefix) {
|
||||
return "", fmt.Errorf("%w: prefix contains invalid characters", ErrAPIKeyFailedToParse)
|
||||
}
|
||||
|
||||
return prefix, nil
|
||||
}
|
||||
|
||||
// For legacy 7-character prefixes or other formats, return as-is
|
||||
return displayPrefix, nil
|
||||
}
|
||||
|
||||
// validateAPIKey validates an API key and returns the key if valid.
|
||||
// Handles both new (hskey-api-{prefix}-{secret}) and legacy (prefix.secret) formats.
|
||||
func validateAPIKey(db *gorm.DB, keyStr string) (*types.APIKey, error) {
|
||||
// Validate input is not empty
|
||||
if keyStr == "" {
|
||||
return nil, ErrAPIKeyFailedToParse
|
||||
}
|
||||
|
||||
// Check for new format: hskey-api-{prefix}-{secret}
|
||||
_, prefixAndSecret, found := strings.Cut(keyStr, apiKeyPrefix)
|
||||
|
||||
if !found {
|
||||
// Legacy format: prefix.secret
|
||||
return validateLegacyAPIKey(db, keyStr)
|
||||
}
|
||||
|
||||
// New format: parse and verify
|
||||
const expectedMinLength = apiKeyPrefixLength + 1 + apiKeyHashLength
|
||||
if len(prefixAndSecret) < expectedMinLength {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: key too short, expected at least %d chars after prefix, got %d",
|
||||
ErrAPIKeyFailedToParse,
|
||||
expectedMinLength,
|
||||
len(prefixAndSecret),
|
||||
)
|
||||
}
|
||||
|
||||
// Use fixed-length parsing
|
||||
prefix := prefixAndSecret[:apiKeyPrefixLength]
|
||||
|
||||
// Validate separator at expected position
|
||||
if prefixAndSecret[apiKeyPrefixLength] != '-' {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: expected separator '-' at position %d, got '%c'",
|
||||
ErrAPIKeyFailedToParse,
|
||||
apiKeyPrefixLength,
|
||||
prefixAndSecret[apiKeyPrefixLength],
|
||||
)
|
||||
}
|
||||
|
||||
secret := prefixAndSecret[apiKeyPrefixLength+1:]
|
||||
|
||||
// Validate secret length
|
||||
if len(secret) != apiKeyHashLength {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: secret length mismatch, expected %d chars, got %d",
|
||||
ErrAPIKeyFailedToParse,
|
||||
apiKeyHashLength,
|
||||
len(secret),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate prefix contains only base64 URL-safe characters
|
||||
if !isValidBase64URLSafe(prefix) {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: prefix contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)",
|
||||
ErrAPIKeyFailedToParse,
|
||||
)
|
||||
}
|
||||
|
||||
// Validate secret contains only base64 URL-safe characters
|
||||
if !isValidBase64URLSafe(secret) {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: secret contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)",
|
||||
ErrAPIKeyFailedToParse,
|
||||
)
|
||||
}
|
||||
|
||||
// Look up by prefix (indexed)
|
||||
var key types.APIKey
|
||||
|
||||
err := db.First(&key, "prefix = ?", prefix).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API key not found: %w", err)
|
||||
}
|
||||
|
||||
// Verify bcrypt hash
|
||||
err = bcrypt.CompareHashAndPassword(key.Hash, []byte(secret))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid API key: %w", err)
|
||||
}
|
||||
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// validateLegacyAPIKey validates a legacy format API key (prefix.secret).
|
||||
func validateLegacyAPIKey(db *gorm.DB, keyStr string) (*types.APIKey, error) {
|
||||
// Legacy format uses "." as separator
|
||||
prefix, secret, found := strings.Cut(keyStr, ".")
|
||||
if !found {
|
||||
return nil, ErrAPIKeyFailedToParse
|
||||
}
|
||||
|
||||
// Legacy prefix is 7 chars
|
||||
if len(prefix) != legacyAPIPrefixLength {
|
||||
return nil, fmt.Errorf("%w: legacy prefix length mismatch", ErrAPIKeyFailedToParse)
|
||||
}
|
||||
|
||||
var key types.APIKey
|
||||
|
||||
err := db.First(&key, "prefix = ?", prefix).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API key not found: %w", err)
|
||||
}
|
||||
|
||||
// Verify bcrypt (key.Hash stores bcrypt of full secret)
|
||||
err = bcrypt.CompareHashAndPassword(key.Hash, []byte(secret))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid API key: %w", err)
|
||||
}
|
||||
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gopkg.in/check.v1"
|
||||
)
|
||||
|
||||
@@ -87,3 +93,142 @@ func (*Suite) TestExpireAPIKey(c *check.C) {
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(notValid, check.Equals, false)
|
||||
}
|
||||
|
||||
func TestAPIKeyWithPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
test func(*testing.T, *HSDatabase)
|
||||
}{
|
||||
{
|
||||
name: "new_key_with_prefix",
|
||||
test: func(t *testing.T, db *HSDatabase) {
|
||||
t.Helper()
|
||||
|
||||
keyStr, apiKey, err := db.CreateAPIKey(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify format: hskey-api-{12-char-prefix}-{64-char-secret}
|
||||
assert.True(t, strings.HasPrefix(keyStr, "hskey-api-"))
|
||||
|
||||
_, prefixAndSecret, found := strings.Cut(keyStr, "hskey-api-")
|
||||
assert.True(t, found)
|
||||
assert.GreaterOrEqual(t, len(prefixAndSecret), 12+1+64)
|
||||
|
||||
prefix := prefixAndSecret[:12]
|
||||
assert.Len(t, prefix, 12)
|
||||
assert.Equal(t, byte('-'), prefixAndSecret[12])
|
||||
secret := prefixAndSecret[13:]
|
||||
assert.Len(t, secret, 64)
|
||||
|
||||
// Verify stored fields
|
||||
assert.Len(t, apiKey.Prefix, types.NewAPIKeyPrefixLength)
|
||||
assert.NotNil(t, apiKey.Hash)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "new_key_can_be_retrieved",
|
||||
test: func(t *testing.T, db *HSDatabase) {
|
||||
t.Helper()
|
||||
|
||||
keyStr, createdKey, err := db.CreateAPIKey(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate the created key
|
||||
valid, err := db.ValidateAPIKey(keyStr)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, valid)
|
||||
|
||||
// Verify prefix is correct length
|
||||
assert.Len(t, createdKey.Prefix, types.NewAPIKeyPrefixLength)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid_key_format_rejected",
|
||||
test: func(t *testing.T, db *HSDatabase) {
|
||||
t.Helper()
|
||||
|
||||
invalidKeys := []string{
|
||||
"",
|
||||
"hskey-api-short",
|
||||
"hskey-api-ABCDEFGHIJKL-tooshort",
|
||||
"hskey-api-ABC$EFGHIJKL-" + strings.Repeat("a", 64),
|
||||
"hskey-api-ABCDEFGHIJKL" + strings.Repeat("a", 64), // missing separator
|
||||
}
|
||||
|
||||
for _, invalidKey := range invalidKeys {
|
||||
valid, err := db.ValidateAPIKey(invalidKey)
|
||||
require.Error(t, err, "key should be rejected: %s", invalidKey)
|
||||
assert.False(t, valid)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "legacy_key_still_works",
|
||||
test: func(t *testing.T, db *HSDatabase) {
|
||||
t.Helper()
|
||||
|
||||
// Insert legacy API key directly (7-char prefix + 32-char secret)
|
||||
legacyPrefix := "abcdefg"
|
||||
legacySecret := strings.Repeat("x", 32)
|
||||
legacyKey := legacyPrefix + "." + legacySecret
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(legacySecret), bcrypt.DefaultCost)
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now()
|
||||
err = db.DB.Exec(`
|
||||
INSERT INTO api_keys (prefix, hash, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`, legacyPrefix, hash, now).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate legacy key
|
||||
valid, err := db.ValidateAPIKey(legacyKey)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, valid)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong_secret_rejected",
|
||||
test: func(t *testing.T, db *HSDatabase) {
|
||||
t.Helper()
|
||||
|
||||
keyStr, _, err := db.CreateAPIKey(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Tamper with the secret
|
||||
_, prefixAndSecret, _ := strings.Cut(keyStr, "hskey-api-")
|
||||
prefix := prefixAndSecret[:12]
|
||||
tamperedKey := "hskey-api-" + prefix + "-" + strings.Repeat("x", 64)
|
||||
|
||||
valid, err := db.ValidateAPIKey(tamperedKey)
|
||||
require.Error(t, err)
|
||||
assert.False(t, valid)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expired_key_rejected",
|
||||
test: func(t *testing.T, db *HSDatabase) {
|
||||
t.Helper()
|
||||
|
||||
// Create expired key
|
||||
expired := time.Now().Add(-1 * time.Hour)
|
||||
keyStr, _, err := db.CreateAPIKey(&expired)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should fail validation
|
||||
valid, err := db.ValidateAPIKey(keyStr)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, valid)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
tt.test(t, db)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -11,7 +10,6 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
@@ -26,7 +24,6 @@ import (
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/util/set"
|
||||
"zgo.at/zcache/v2"
|
||||
)
|
||||
|
||||
@@ -79,497 +76,10 @@ func NewHeadscaleDatabase(
|
||||
gormigrate.DefaultOptions,
|
||||
[]*gormigrate.Migration{
|
||||
// New migrations must be added as transactions at the end of this list.
|
||||
// The initial migration here is quite messy, completely out of order and
|
||||
// has no versioning and is the tech debt of not having versioned migrations
|
||||
// prior to this point. This first migration is all DB changes to bring a DB
|
||||
// up to 0.23.0.
|
||||
{
|
||||
ID: "202312101416",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
if cfg.Type == types.DatabasePostgres {
|
||||
tx.Exec(`create extension if not exists "uuid-ossp";`)
|
||||
}
|
||||
// Migrations start from v0.25.0. If upgrading from v0.24.x or earlier,
|
||||
// you must first upgrade to v0.25.1 before upgrading to this version.
|
||||
|
||||
_ = tx.Migrator().RenameTable("namespaces", "users")
|
||||
|
||||
// the big rename from Machine to Node
|
||||
_ = tx.Migrator().RenameTable("machines", "nodes")
|
||||
_ = tx.Migrator().
|
||||
RenameColumn(&types.Route{}, "machine_id", "node_id")
|
||||
|
||||
err = tx.AutoMigrate(types.User{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = tx.Migrator().
|
||||
RenameColumn(&types.Node{}, "namespace_id", "user_id")
|
||||
_ = tx.Migrator().
|
||||
RenameColumn(&types.PreAuthKey{}, "namespace_id", "user_id")
|
||||
|
||||
_ = tx.Migrator().
|
||||
RenameColumn(&types.Node{}, "ip_address", "ip_addresses")
|
||||
_ = tx.Migrator().RenameColumn(&types.Node{}, "name", "hostname")
|
||||
|
||||
// GivenName is used as the primary source of DNS names, make sure
|
||||
// the field is populated and normalized if it was not when the
|
||||
// node was registered.
|
||||
_ = tx.Migrator().
|
||||
RenameColumn(&types.Node{}, "nickname", "given_name")
|
||||
|
||||
dbConn.Model(&types.Node{}).Where("auth_key_id = ?", 0).Update("auth_key_id", nil)
|
||||
// If the Node table has a column for registered,
|
||||
// find all occurrences of "false" and drop them. Then
|
||||
// remove the column.
|
||||
if tx.Migrator().HasColumn(&types.Node{}, "registered") {
|
||||
log.Info().
|
||||
Msg(`Database has legacy "registered" column in node, removing...`)
|
||||
|
||||
nodes := types.Nodes{}
|
||||
if err := tx.Not("registered").Find(&nodes).Error; err != nil {
|
||||
log.Error().Err(err).Msg("Error accessing db")
|
||||
}
|
||||
|
||||
for _, node := range nodes {
|
||||
log.Info().
|
||||
Str("node", node.Hostname).
|
||||
Str("machine_key", node.MachineKey.ShortString()).
|
||||
Msg("Deleting unregistered node")
|
||||
if err := tx.Delete(&types.Node{}, node.ID).Error; err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("node", node.Hostname).
|
||||
Str("machine_key", node.MachineKey.ShortString()).
|
||||
Msg("Error deleting unregistered node")
|
||||
}
|
||||
}
|
||||
|
||||
err := tx.Migrator().DropColumn(&types.Node{}, "registered")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error dropping registered column")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any invalid routes associated with a node that does not exist.
|
||||
if tx.Migrator().HasTable(&types.Route{}) && tx.Migrator().HasTable(&types.Node{}) {
|
||||
err := tx.Exec("delete from routes where node_id not in (select id from nodes)").Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = tx.AutoMigrate(&types.Route{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.AutoMigrate(&types.Node{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure all keys have correct prefixes
|
||||
// https://github.com/tailscale/tailscale/blob/main/types/key/node.go#L35
|
||||
type result struct {
|
||||
ID uint64
|
||||
MachineKey string
|
||||
NodeKey string
|
||||
DiscoKey string
|
||||
}
|
||||
var results []result
|
||||
err = tx.Raw("SELECT id, node_key, machine_key, disco_key FROM nodes").
|
||||
Find(&results).
|
||||
Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, node := range results {
|
||||
mKey := node.MachineKey
|
||||
if !strings.HasPrefix(node.MachineKey, "mkey:") {
|
||||
mKey = "mkey:" + node.MachineKey
|
||||
}
|
||||
nKey := node.NodeKey
|
||||
if !strings.HasPrefix(node.NodeKey, "nodekey:") {
|
||||
nKey = "nodekey:" + node.NodeKey
|
||||
}
|
||||
|
||||
dKey := node.DiscoKey
|
||||
if !strings.HasPrefix(node.DiscoKey, "discokey:") {
|
||||
dKey = "discokey:" + node.DiscoKey
|
||||
}
|
||||
|
||||
err := tx.Exec(
|
||||
"UPDATE nodes SET machine_key = @mKey, node_key = @nKey, disco_key = @dKey WHERE ID = @id",
|
||||
sql.Named("mKey", mKey),
|
||||
sql.Named("nKey", nKey),
|
||||
sql.Named("dKey", dKey),
|
||||
sql.Named("id", node.ID),
|
||||
).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if tx.Migrator().HasColumn(&types.Node{}, "enabled_routes") {
|
||||
log.Info().
|
||||
Msgf("Database has legacy enabled_routes column in node, migrating...")
|
||||
|
||||
type NodeAux struct {
|
||||
ID uint64
|
||||
EnabledRoutes []netip.Prefix `gorm:"serializer:json"`
|
||||
}
|
||||
|
||||
nodesAux := []NodeAux{}
|
||||
err := tx.Table("nodes").
|
||||
Select("id, enabled_routes").
|
||||
Scan(&nodesAux).
|
||||
Error
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Error accessing db")
|
||||
}
|
||||
for _, node := range nodesAux {
|
||||
for _, prefix := range node.EnabledRoutes {
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("enabled_route", prefix.String()).
|
||||
Msg("Error parsing enabled_route")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
err = tx.Preload("Node").
|
||||
Where("node_id = ? AND prefix = ?", node.ID, prefix).
|
||||
First(&types.Route{}).
|
||||
Error
|
||||
if err == nil {
|
||||
log.Info().
|
||||
Str("enabled_route", prefix.String()).
|
||||
Msg("Route already migrated to new table, skipping")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
route := types.Route{
|
||||
NodeID: node.ID,
|
||||
Advertised: true,
|
||||
Enabled: true,
|
||||
Prefix: prefix,
|
||||
}
|
||||
if err := tx.Create(&route).Error; err != nil {
|
||||
log.Error().Err(err).Msg("Error creating route")
|
||||
} else {
|
||||
log.Info().
|
||||
Uint64("node.id", route.NodeID).
|
||||
Str("prefix", prefix.String()).
|
||||
Msg("Route migrated")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Migrator().DropColumn(&types.Node{}, "enabled_routes")
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("Error dropping enabled_routes column")
|
||||
}
|
||||
}
|
||||
|
||||
if tx.Migrator().HasColumn(&types.Node{}, "given_name") {
|
||||
nodes := types.Nodes{}
|
||||
if err := tx.Find(&nodes).Error; err != nil {
|
||||
log.Error().Err(err).Msg("Error accessing db")
|
||||
}
|
||||
|
||||
for item, node := range nodes {
|
||||
if node.GivenName == "" {
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Str("hostname", node.Hostname).
|
||||
Err(err).
|
||||
Msg("Failed to normalize node hostname in DB migration")
|
||||
}
|
||||
|
||||
err = tx.Model(nodes[item]).Updates(types.Node{
|
||||
GivenName: node.Hostname,
|
||||
}).Error
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Str("hostname", node.Hostname).
|
||||
Err(err).
|
||||
Msg("Failed to save normalized node name in DB migration")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.AutoMigrate(&KV{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.AutoMigrate(&types.PreAuthKey{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type preAuthKeyACLTag struct {
|
||||
ID uint64 `gorm:"primary_key"`
|
||||
PreAuthKeyID uint64
|
||||
Tag string
|
||||
}
|
||||
err = tx.AutoMigrate(&preAuthKeyACLTag{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = tx.Migrator().DropTable("shared_machines")
|
||||
|
||||
err = tx.AutoMigrate(&types.APIKey{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
// drop key-value table, it is not used, and has not contained
|
||||
// useful data for a long time or ever.
|
||||
ID: "202312101430",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Migrator().DropTable("kvs")
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
// remove last_successful_update from node table,
|
||||
// no longer used.
|
||||
ID: "202402151347",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
_ = tx.Migrator().DropColumn(&types.Node{}, "last_successful_update")
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
// Replace column with IP address list with dedicated
|
||||
// IP v4 and v6 column.
|
||||
// Note that previously, the list _could_ contain more
|
||||
// than two addresses, which should not really happen.
|
||||
// In that case, the first occurrence of each type will
|
||||
// be kept.
|
||||
ID: "2024041121742",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
_ = tx.Migrator().AddColumn(&types.Node{}, "ipv4")
|
||||
_ = tx.Migrator().AddColumn(&types.Node{}, "ipv6")
|
||||
|
||||
type node struct {
|
||||
ID uint64 `gorm:"column:id"`
|
||||
Addresses string `gorm:"column:ip_addresses"`
|
||||
}
|
||||
|
||||
var nodes []node
|
||||
|
||||
_ = tx.Raw("SELECT id, ip_addresses FROM nodes").Scan(&nodes).Error
|
||||
|
||||
for _, node := range nodes {
|
||||
addrs := strings.Split(node.Addresses, ",")
|
||||
|
||||
if len(addrs) == 0 {
|
||||
return fmt.Errorf("no addresses found for node(%d)", node.ID)
|
||||
}
|
||||
|
||||
var v4 *netip.Addr
|
||||
var v6 *netip.Addr
|
||||
|
||||
for _, addrStr := range addrs {
|
||||
addr, err := netip.ParseAddr(addrStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing IP for node(%d) from database: %w", node.ID, err)
|
||||
}
|
||||
|
||||
if addr.Is4() && v4 == nil {
|
||||
v4 = &addr
|
||||
}
|
||||
|
||||
if addr.Is6() && v6 == nil {
|
||||
v6 = &addr
|
||||
}
|
||||
}
|
||||
|
||||
if v4 != nil {
|
||||
err = tx.Model(&types.Node{}).Where("id = ?", node.ID).Update("ipv4", v4.String()).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("saving ip addresses to new columns: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if v6 != nil {
|
||||
err = tx.Model(&types.Node{}).Where("id = ?", node.ID).Update("ipv6", v6.String()).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("saving ip addresses to new columns: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = tx.Migrator().DropColumn(&types.Node{}, "ip_addresses")
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "202406021630",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
err := tx.AutoMigrate(&types.Policy{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
// denormalise the ACL tags for preauth keys back onto
|
||||
// the preauth key table. We dont normalise or reuse and
|
||||
// it is just a bunch of work for extra work.
|
||||
{
|
||||
ID: "202409271400",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
preauthkeyTags := map[uint64]set.Set[string]{}
|
||||
|
||||
type preAuthKeyACLTag struct {
|
||||
ID uint64 `gorm:"primary_key"`
|
||||
PreAuthKeyID uint64
|
||||
Tag string
|
||||
}
|
||||
|
||||
var aclTags []preAuthKeyACLTag
|
||||
if err := tx.Find(&aclTags).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Store the current tags.
|
||||
for _, tag := range aclTags {
|
||||
if preauthkeyTags[tag.PreAuthKeyID] == nil {
|
||||
preauthkeyTags[tag.PreAuthKeyID] = set.SetOf([]string{tag.Tag})
|
||||
} else {
|
||||
preauthkeyTags[tag.PreAuthKeyID].Add(tag.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Add tags column and restore the tags.
|
||||
_ = tx.Migrator().AddColumn(&types.PreAuthKey{}, "tags")
|
||||
for keyID, tags := range preauthkeyTags {
|
||||
s := tags.Slice()
|
||||
j, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&types.PreAuthKey{}).Where("id = ?", keyID).Update("tags", string(j)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the old table.
|
||||
_ = tx.Migrator().DropTable(&preAuthKeyACLTag{})
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
{
|
||||
// Pick up new user fields used for OIDC and to
|
||||
// populate the user with more interesting information.
|
||||
ID: "202407191627",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
// Fix an issue where the automigration in GORM expected a constraint to
|
||||
// exists that didn't, and add the one it wanted.
|
||||
// Fixes https://github.com/juanfont/headscale/issues/2351
|
||||
if cfg.Type == types.DatabasePostgres {
|
||||
err := tx.Exec(`
|
||||
BEGIN;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'uni_users_name'
|
||||
) THEN
|
||||
ALTER TABLE users ADD CONSTRAINT uni_users_name UNIQUE (name);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'users_name_key'
|
||||
) THEN
|
||||
ALTER TABLE users DROP CONSTRAINT users_name_key;
|
||||
END IF;
|
||||
END $$;
|
||||
COMMIT;
|
||||
`).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to rename constraint: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := tx.AutoMigrate(&types.User{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("automigrating types.User: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
{
|
||||
// The unique constraint of Name has been dropped
|
||||
// in favour of a unique together of name and
|
||||
// provider identity.
|
||||
ID: "202408181235",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
err := tx.AutoMigrate(&types.User{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("automigrating types.User: %w", err)
|
||||
}
|
||||
|
||||
// Set up indexes and unique constraints outside of GORM, it does not support
|
||||
// conditional unique constraints.
|
||||
// This ensures the following:
|
||||
// - A user name and provider_identifier is unique
|
||||
// - A provider_identifier is unique
|
||||
// - A user name is unique if there is no provider_identifier is not set
|
||||
for _, idx := range []string{
|
||||
"DROP INDEX IF EXISTS idx_provider_identifier",
|
||||
"DROP INDEX IF EXISTS idx_name_provider_identifier",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_provider_identifier ON users (provider_identifier) WHERE provider_identifier IS NOT NULL;",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_name_provider_identifier ON users (name,provider_identifier);",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_name_no_provider_identifier ON users (name) WHERE provider_identifier IS NULL;",
|
||||
} {
|
||||
err = tx.Exec(idx).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating username index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
// v0.25.0
|
||||
{
|
||||
// Add a constraint to routes ensuring they cannot exist without a node.
|
||||
ID: "202501221827",
|
||||
@@ -639,6 +149,7 @@ AND auth_key_id NOT IN (
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
// v0.26.0
|
||||
// Migrate all routes from the Route table to the new field ApprovedRoutes
|
||||
// in the Node table. Then drop the Route table.
|
||||
{
|
||||
@@ -733,6 +244,7 @@ AND auth_key_id NOT IN (
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
// v0.27.0
|
||||
// Schema migration to ensure all tables match the expected schema.
|
||||
// This migration recreates all tables to match the exact structure in schema.sql,
|
||||
// preserving all data during the process.
|
||||
@@ -932,6 +444,7 @@ AND auth_key_id NOT IN (
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
// v0.27.1
|
||||
{
|
||||
// Drop all tables that are no longer in use and has existed.
|
||||
// They potentially still present from broken migrations in the past.
|
||||
@@ -952,15 +465,191 @@ AND auth_key_id NOT IN (
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
// Drop all indices that are no longer in use and has existed.
|
||||
// They potentially still present from broken migrations in the past.
|
||||
// They should all be cleaned up by the db engine, but we are a bit
|
||||
// conservative to ensure all our previous mess is cleaned up.
|
||||
ID: "202511101554-drop-old-idx",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
for _, oldIdx := range []struct{ name, table string }{
|
||||
{"idx_namespaces_deleted_at", "namespaces"},
|
||||
{"idx_routes_deleted_at", "routes"},
|
||||
{"idx_shared_machines_deleted_at", "shared_machines"},
|
||||
} {
|
||||
err := tx.Migrator().DropIndex(oldIdx.table, oldIdx.name)
|
||||
if err != nil {
|
||||
log.Trace().
|
||||
Str("index", oldIdx.name).
|
||||
Str("table", oldIdx.table).
|
||||
Err(err).
|
||||
Msg("Error dropping old index, continuing...")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
// Migrations **above** this points will be REMOVED in version **0.29.0**
|
||||
// This is to clean up a lot of old migrations that is seldom used
|
||||
// and carries a lot of technical debt.
|
||||
// Any new migrations should be added after the comment below and follow
|
||||
// the rules it sets out.
|
||||
|
||||
// From this point, the following rules must be followed:
|
||||
// - NEVER use gorm.AutoMigrate, write the exact migration steps needed
|
||||
// - AutoMigrate depends on the struct staying exactly the same, which it won't over time.
|
||||
// - Never write migrations that requires foreign keys to be disabled.
|
||||
// - ALL errors in migrations must be handled properly.
|
||||
|
||||
{
|
||||
// Add columns for prefix and hash for pre auth keys, implementing
|
||||
// them with the same security model as api keys.
|
||||
ID: "202511011637-preauthkey-bcrypt",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
// Check and add prefix column if it doesn't exist
|
||||
if !tx.Migrator().HasColumn(&types.PreAuthKey{}, "prefix") {
|
||||
err := tx.Migrator().AddColumn(&types.PreAuthKey{}, "prefix")
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding prefix column: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check and add hash column if it doesn't exist
|
||||
if !tx.Migrator().HasColumn(&types.PreAuthKey{}, "hash") {
|
||||
err := tx.Migrator().AddColumn(&types.PreAuthKey{}, "hash")
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding hash column: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create partial unique index to allow multiple legacy keys (NULL/empty prefix)
|
||||
// while enforcing uniqueness for new bcrypt-based keys
|
||||
err := tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''").Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating prefix index: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
{
|
||||
ID: "202511122344-remove-newline-index",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
// Reformat multi-line indexes to single-line for consistency
|
||||
// This migration drops and recreates the three user identity indexes
|
||||
// to match the single-line format expected by schema validation
|
||||
|
||||
// Drop existing multi-line indexes
|
||||
dropIndexes := []string{
|
||||
`DROP INDEX IF EXISTS idx_provider_identifier`,
|
||||
`DROP INDEX IF EXISTS idx_name_provider_identifier`,
|
||||
`DROP INDEX IF EXISTS idx_name_no_provider_identifier`,
|
||||
}
|
||||
|
||||
for _, dropSQL := range dropIndexes {
|
||||
err := tx.Exec(dropSQL).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("dropping index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate indexes in single-line format
|
||||
createIndexes := []string{
|
||||
`CREATE UNIQUE INDEX idx_provider_identifier ON users(provider_identifier) WHERE provider_identifier IS NOT NULL`,
|
||||
`CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier)`,
|
||||
`CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL`,
|
||||
}
|
||||
|
||||
for _, createSQL := range createIndexes {
|
||||
err := tx.Exec(createSQL).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
{
|
||||
// Rename forced_tags column to tags in nodes table.
|
||||
// This must run after migration 202505141324 which creates tables with forced_tags.
|
||||
ID: "202511131445-node-forced-tags-to-tags",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
// Rename the column from forced_tags to tags
|
||||
err := tx.Migrator().RenameColumn(&types.Node{}, "forced_tags", "tags")
|
||||
if err != nil {
|
||||
return fmt.Errorf("renaming forced_tags to tags: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
migrations.InitSchema(func(tx *gorm.DB) error {
|
||||
// Create all tables using AutoMigrate
|
||||
err := tx.AutoMigrate(
|
||||
&types.User{},
|
||||
&types.PreAuthKey{},
|
||||
&types.APIKey{},
|
||||
&types.Node{},
|
||||
&types.Policy{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop all indexes (both GORM-created and potentially pre-existing ones)
|
||||
// to ensure we can recreate them in the correct format
|
||||
dropIndexes := []string{
|
||||
`DROP INDEX IF EXISTS "idx_users_deleted_at"`,
|
||||
`DROP INDEX IF EXISTS "idx_api_keys_prefix"`,
|
||||
`DROP INDEX IF EXISTS "idx_policies_deleted_at"`,
|
||||
`DROP INDEX IF EXISTS "idx_provider_identifier"`,
|
||||
`DROP INDEX IF EXISTS "idx_name_provider_identifier"`,
|
||||
`DROP INDEX IF EXISTS "idx_name_no_provider_identifier"`,
|
||||
`DROP INDEX IF EXISTS "idx_pre_auth_keys_prefix"`,
|
||||
}
|
||||
|
||||
for _, dropSQL := range dropIndexes {
|
||||
err := tx.Exec(dropSQL).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate indexes without backticks to match schema.sql format
|
||||
indexes := []string{
|
||||
`CREATE INDEX idx_users_deleted_at ON users(deleted_at)`,
|
||||
`CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix)`,
|
||||
`CREATE INDEX idx_policies_deleted_at ON policies(deleted_at)`,
|
||||
`CREATE UNIQUE INDEX idx_provider_identifier ON users(provider_identifier) WHERE provider_identifier IS NOT NULL`,
|
||||
`CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier)`,
|
||||
`CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL`,
|
||||
`CREATE UNIQUE INDEX idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''`,
|
||||
}
|
||||
|
||||
for _, indexSQL := range indexes {
|
||||
err := tx.Exec(indexSQL).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := runMigrations(cfg, dbConn, migrations); err != nil {
|
||||
log.Fatal().Err(err).Msgf("Migration failed: %v", err)
|
||||
return nil, fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
|
||||
// Validate that the schema ends up in the expected state.
|
||||
@@ -1121,13 +810,8 @@ func runMigrations(cfg types.DatabaseConfig, dbConn *gorm.DB, migrations *gormig
|
||||
// These are migrations that perform complex schema changes that GORM cannot handle safely with FK enabled
|
||||
// NO NEW MIGRATIONS SHOULD BE ADDED HERE. ALL NEW MIGRATIONS MUST RUN WITH FOREIGN KEYS ENABLED.
|
||||
migrationsRequiringFKDisabled := map[string]bool{
|
||||
"202312101416": true, // Initial migration with complex table/column renames
|
||||
"202402151347": true, // Migration that removes last_successful_update column
|
||||
"2024041121742": true, // Migration that changes IP address storage format
|
||||
"202407191627": true, // User table automigration with FK constraint issues
|
||||
"202408181235": true, // User table automigration with FK constraint issues
|
||||
"202501221827": true, // Route table automigration with FK constraint issues
|
||||
"202501311657": true, // PreAuthKey table automigration with FK constraint issues
|
||||
"202501221827": true, // Route table automigration with FK constraint issues
|
||||
"202501311657": true, // PreAuthKey table automigration with FK constraint issues
|
||||
// Add other migration IDs here as they are identified to need FK disabled
|
||||
}
|
||||
|
||||
@@ -1141,21 +825,17 @@ func runMigrations(cfg types.DatabaseConfig, dbConn *gorm.DB, migrations *gormig
|
||||
// Only IDs that are in the migrationsRequiringFKDisabled map will be processed with FK disabled
|
||||
// any other new migrations are ran after.
|
||||
migrationIDs := []string{
|
||||
"202312101416",
|
||||
"202312101430",
|
||||
"202402151347",
|
||||
"2024041121742",
|
||||
"202406021630",
|
||||
"202407191627",
|
||||
"202408181235",
|
||||
"202409271400",
|
||||
// v0.25.0
|
||||
"202501221827",
|
||||
"202501311657",
|
||||
"202502070949",
|
||||
|
||||
// v0.26.0
|
||||
"202502131714",
|
||||
"202502171819",
|
||||
"202505091439",
|
||||
"202505141324",
|
||||
|
||||
// As of 2025-07-02, no new IDs should be added here.
|
||||
// They will be ran by the migrations.Migrate() call below.
|
||||
}
|
||||
|
||||
@@ -2,19 +2,14 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
@@ -25,400 +20,14 @@ import (
|
||||
// and validates data integrity after migration. All migrations that require data validation
|
||||
// should be added here.
|
||||
func TestSQLiteMigrationAndDataValidation(t *testing.T) {
|
||||
ipp := func(p string) netip.Prefix {
|
||||
return netip.MustParsePrefix(p)
|
||||
}
|
||||
r := func(id uint64, p string, a, e, i bool) types.Route {
|
||||
return types.Route{
|
||||
NodeID: id,
|
||||
Prefix: ipp(p),
|
||||
Advertised: a,
|
||||
Enabled: e,
|
||||
IsPrimary: i,
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
dbPath string
|
||||
wantFunc func(*testing.T, *HSDatabase)
|
||||
}{
|
||||
{
|
||||
dbPath: "testdata/sqlite/0-22-3-to-0-23-0-routes-are-dropped-2063_dump.sql",
|
||||
wantFunc: func(t *testing.T, hsdb *HSDatabase) {
|
||||
t.Helper()
|
||||
// Comprehensive data preservation validation for 0.22.3->0.23.0 migration
|
||||
// Expected data from dump: 4 users, 17 pre_auth_keys, 14 machines/nodes, 12 routes
|
||||
|
||||
// Verify users data preservation - should have 4 users
|
||||
users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) {
|
||||
return ListUsers(rx)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, users, 4, "should preserve all 4 users from original schema")
|
||||
|
||||
// Verify pre_auth_keys data preservation - should have 17 keys
|
||||
preAuthKeys, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.PreAuthKey, error) {
|
||||
var keys []types.PreAuthKey
|
||||
err := rx.Find(&keys).Error
|
||||
return keys, err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, preAuthKeys, 17, "should preserve all 17 pre_auth_keys from original schema")
|
||||
|
||||
// Verify all nodes data preservation - should have 14 nodes
|
||||
allNodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) {
|
||||
return ListNodes(rx)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, allNodes, 14, "should preserve all 14 machines/nodes from original schema")
|
||||
|
||||
// Verify specific nodes and their route migration with detailed validation
|
||||
nodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) {
|
||||
n1, err := GetNodeByID(rx, 1)
|
||||
n26, err := GetNodeByID(rx, 26)
|
||||
n31, err := GetNodeByID(rx, 31)
|
||||
n32, err := GetNodeByID(rx, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return types.Nodes{n1, n26, n31, n32}, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, nodes, 4, "should have retrieved 4 specific nodes")
|
||||
|
||||
// Validate specific node data from dump file
|
||||
nodesByID := make(map[uint64]*types.Node)
|
||||
for i := range nodes {
|
||||
nodesByID[nodes[i].ID.Uint64()] = nodes[i]
|
||||
}
|
||||
|
||||
node1 := nodesByID[1]
|
||||
node26 := nodesByID[26]
|
||||
node31 := nodesByID[31]
|
||||
node32 := nodesByID[32]
|
||||
|
||||
require.NotNil(t, node1, "node 1 should exist")
|
||||
require.NotNil(t, node26, "node 26 should exist")
|
||||
require.NotNil(t, node31, "node 31 should exist")
|
||||
require.NotNil(t, node32, "node 32 should exist")
|
||||
|
||||
// Validate node data using cmp.Diff
|
||||
expectedNodes := map[uint64]struct {
|
||||
Hostname string
|
||||
GivenName string
|
||||
IPv4 string
|
||||
}{
|
||||
1: {Hostname: "test_hostname", GivenName: "test_given_name", IPv4: "100.64.0.1"},
|
||||
26: {Hostname: "test_hostname", GivenName: "test_given_name", IPv4: "100.64.0.19"},
|
||||
31: {Hostname: "test_hostname", GivenName: "test_given_name", IPv4: "100.64.0.7"},
|
||||
32: {Hostname: "test_hostname", GivenName: "test_given_name", IPv4: "100.64.0.11"},
|
||||
}
|
||||
|
||||
for nodeID, expected := range expectedNodes {
|
||||
node := nodesByID[nodeID]
|
||||
require.NotNil(t, node, "node %d should exist", nodeID)
|
||||
|
||||
actual := struct {
|
||||
Hostname string
|
||||
GivenName string
|
||||
IPv4 string
|
||||
}{
|
||||
Hostname: node.Hostname,
|
||||
GivenName: node.GivenName,
|
||||
IPv4: node.IPv4.String(),
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(expected, actual); diff != "" {
|
||||
t.Errorf("TestSQLiteMigrationAndDataValidation() node %d mismatch (-want +got):\n%s", nodeID, diff)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that routes were properly migrated from routes table to approved_routes
|
||||
// Based on the dump file routes data:
|
||||
// Node 1 (machine_id 1): routes 1,2,3 (0.0.0.0/0 enabled, ::/0 enabled, 10.9.110.0/24 enabled+primary)
|
||||
// Node 26 (machine_id 26): route 6 (172.100.100.0/24 enabled+primary), route 7 (172.100.100.0/24 disabled)
|
||||
// Node 31 (machine_id 31): routes 8,10 (0.0.0.0/0 enabled, ::/0 enabled), routes 9,11 (duplicates disabled)
|
||||
// Node 32 (machine_id 32): route 12 (192.168.0.24/32 enabled+primary)
|
||||
want := [][]netip.Prefix{
|
||||
{ipp("0.0.0.0/0"), ipp("10.9.110.0/24"), ipp("::/0")}, // node 1: 3 enabled routes
|
||||
{ipp("172.100.100.0/24")}, // node 26: 1 enabled route
|
||||
{ipp("0.0.0.0/0"), ipp("::/0")}, // node 31: 2 enabled routes
|
||||
{ipp("192.168.0.24/32")}, // node 32: 1 enabled route
|
||||
}
|
||||
var got [][]netip.Prefix
|
||||
for _, node := range nodes {
|
||||
got = append(got, node.ApprovedRoutes)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(want, got, util.PrefixComparer); diff != "" {
|
||||
t.Errorf("TestSQLiteMigrationAndDataValidation() route migration mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Verify routes table was dropped after migration
|
||||
var routesTableExists bool
|
||||
err = hsdb.DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='routes'").Row().Scan(&routesTableExists)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, routesTableExists, "routes table should have been dropped after migration")
|
||||
},
|
||||
},
|
||||
{
|
||||
dbPath: "testdata/sqlite/0-22-3-to-0-23-0-routes-fail-foreign-key-2076_dump.sql",
|
||||
wantFunc: func(t *testing.T, hsdb *HSDatabase) {
|
||||
t.Helper()
|
||||
// Comprehensive data preservation validation for foreign key constraint issue case
|
||||
// Expected data from dump: 4 users, 2 pre_auth_keys, 8 nodes
|
||||
|
||||
// Verify users data preservation
|
||||
users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) {
|
||||
return ListUsers(rx)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, users, 4, "should preserve all 4 users from original schema")
|
||||
|
||||
// Verify pre_auth_keys data preservation
|
||||
preAuthKeys, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.PreAuthKey, error) {
|
||||
var keys []types.PreAuthKey
|
||||
err := rx.Find(&keys).Error
|
||||
return keys, err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, preAuthKeys, 2, "should preserve all 2 pre_auth_keys from original schema")
|
||||
|
||||
// Verify all nodes data preservation
|
||||
allNodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) {
|
||||
return ListNodes(rx)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, allNodes, 8, "should preserve all 8 nodes from original schema")
|
||||
|
||||
// Verify specific node route migration
|
||||
node, err := Read(hsdb.DB, func(rx *gorm.DB) (*types.Node, error) {
|
||||
return GetNodeByID(rx, 13)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, node.ApprovedRoutes, 3)
|
||||
_ = types.Routes{
|
||||
// These routes exists, but have no nodes associated with them
|
||||
// when the migration starts.
|
||||
// r(1, "0.0.0.0/0", true, false),
|
||||
// r(1, "::/0", true, false),
|
||||
// r(3, "0.0.0.0/0", true, false),
|
||||
// r(3, "::/0", true, false),
|
||||
// r(5, "0.0.0.0/0", true, false),
|
||||
// r(5, "::/0", true, false),
|
||||
// r(6, "0.0.0.0/0", true, false),
|
||||
// r(6, "::/0", true, false),
|
||||
// r(6, "10.0.0.0/8", true, false, false),
|
||||
// r(7, "0.0.0.0/0", true, false),
|
||||
// r(7, "::/0", true, false),
|
||||
// r(7, "10.0.0.0/8", true, false, false),
|
||||
// r(9, "0.0.0.0/0", true, false),
|
||||
// r(9, "::/0", true, false),
|
||||
// r(9, "10.0.0.0/8", true, false),
|
||||
// r(11, "0.0.0.0/0", true, false),
|
||||
// r(11, "::/0", true, false),
|
||||
// r(11, "10.0.0.0/8", true, true),
|
||||
// r(12, "0.0.0.0/0", true, false),
|
||||
// r(12, "::/0", true, false),
|
||||
// r(12, "10.0.0.0/8", true, false, false),
|
||||
//
|
||||
// These nodes exists, so routes should be kept.
|
||||
r(13, "10.0.0.0/8", true, false, false),
|
||||
r(13, "0.0.0.0/0", true, true, false),
|
||||
r(13, "::/0", true, true, false),
|
||||
r(13, "10.18.80.2/32", true, true, true),
|
||||
}
|
||||
want := []netip.Prefix{ipp("0.0.0.0/0"), ipp("10.18.80.2/32"), ipp("::/0")}
|
||||
if diff := cmp.Diff(want, node.ApprovedRoutes, util.PrefixComparer); diff != "" {
|
||||
t.Errorf("TestSQLiteMigrationAndDataValidation() route migration mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Verify routes table was dropped after migration
|
||||
var routesTableExists bool
|
||||
err = hsdb.DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='routes'").Row().Scan(&routesTableExists)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, routesTableExists, "routes table should have been dropped after migration")
|
||||
},
|
||||
},
|
||||
// at 14:15:06 ❯ go run ./cmd/headscale preauthkeys list
|
||||
// ID | Key | Reusable | Ephemeral | Used | Expiration | Created | Tags
|
||||
// 1 | 09b28f.. | false | false | false | 2024-09-27 | 2024-09-27 | tag:derp
|
||||
// 2 | 3112b9.. | false | false | false | 2024-09-27 | 2024-09-27 | tag:derp
|
||||
// 3 | 7c23b9.. | false | false | false | 2024-09-27 | 2024-09-27 | tag:derp,tag:merp
|
||||
// 4 | f20155.. | false | false | false | 2024-09-27 | 2024-09-27 | tag:test
|
||||
// 5 | b212b9.. | false | false | false | 2024-09-27 | 2024-09-27 | tag:test,tag:woop,tag:dedu
|
||||
{
|
||||
dbPath: "testdata/sqlite/0-23-0-to-0-24-0-preauthkey-tags-table_dump.sql",
|
||||
wantFunc: func(t *testing.T, hsdb *HSDatabase) {
|
||||
t.Helper()
|
||||
// Comprehensive data preservation validation for pre-auth key tags migration
|
||||
// Expected data from dump: 2 users (kratest, testkra), 5 pre_auth_keys with specific tags
|
||||
|
||||
// Verify users data preservation with specific user data
|
||||
users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) {
|
||||
return ListUsers(rx)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, users, 2, "should preserve all 2 users from original schema")
|
||||
|
||||
// Validate specific user data from dump file using cmp.Diff
|
||||
expectedUsers := []types.User{
|
||||
{Model: gorm.Model{ID: 1}, Name: "kratest"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "testkra"},
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(expectedUsers, users,
|
||||
cmpopts.IgnoreFields(types.User{}, "CreatedAt", "UpdatedAt", "DeletedAt", "DisplayName", "Email", "ProviderIdentifier", "Provider", "ProfilePicURL")); diff != "" {
|
||||
t.Errorf("TestSQLiteMigrationAndDataValidation() users mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Create maps for easier access in later validations
|
||||
usersByName := make(map[string]*types.User)
|
||||
for i := range users {
|
||||
usersByName[users[i].Name] = &users[i]
|
||||
}
|
||||
kratest := usersByName["kratest"]
|
||||
testkra := usersByName["testkra"]
|
||||
|
||||
// Verify all pre_auth_keys data preservation
|
||||
allKeys, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.PreAuthKey, error) {
|
||||
var keys []types.PreAuthKey
|
||||
err := rx.Find(&keys).Error
|
||||
return keys, err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, allKeys, 5, "should preserve all 5 pre_auth_keys from original schema")
|
||||
|
||||
// Verify specific pre-auth keys and their tag migration with exact data validation
|
||||
keys, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.PreAuthKey, error) {
|
||||
kratest, err := ListPreAuthKeysByUser(rx, 1) // kratest
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
testkra, err := ListPreAuthKeysByUser(rx, 2) // testkra
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(kratest, testkra...), nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, keys, 5)
|
||||
|
||||
// Create map for easier validation by ID
|
||||
keysByID := make(map[uint64]*types.PreAuthKey)
|
||||
for i := range keys {
|
||||
keysByID[keys[i].ID] = &keys[i]
|
||||
}
|
||||
|
||||
// Validate specific pre-auth key data and tag migration from pre_auth_key_acl_tags table
|
||||
key1 := keysByID[1]
|
||||
key2 := keysByID[2]
|
||||
key3 := keysByID[3]
|
||||
key4 := keysByID[4]
|
||||
key5 := keysByID[5]
|
||||
|
||||
require.NotNil(t, key1, "pre_auth_key 1 should exist")
|
||||
require.NotNil(t, key2, "pre_auth_key 2 should exist")
|
||||
require.NotNil(t, key3, "pre_auth_key 3 should exist")
|
||||
require.NotNil(t, key4, "pre_auth_key 4 should exist")
|
||||
require.NotNil(t, key5, "pre_auth_key 5 should exist")
|
||||
|
||||
// Validate specific pre-auth key data and tag migration using cmp.Diff
|
||||
expectedKeys := []types.PreAuthKey{
|
||||
{
|
||||
ID: 1,
|
||||
Key: "09b28f8c3351984874d46dace0a70177a8721933a950b663",
|
||||
UserID: kratest.ID,
|
||||
Tags: []string{"tag:derp"},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Key: "3112b953cb344191b2d5aec1b891250125bf7b437eac5d26",
|
||||
UserID: kratest.ID,
|
||||
Tags: []string{"tag:derp"},
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
Key: "7c23b9f215961e7609527aef78bf82fb19064b002d78c36f",
|
||||
UserID: kratest.ID,
|
||||
Tags: []string{"tag:derp", "tag:merp"},
|
||||
},
|
||||
{
|
||||
ID: 4,
|
||||
Key: "f2015583852b725220cc4b107fb288a4cf7ac259bd458a32",
|
||||
UserID: testkra.ID,
|
||||
Tags: []string{"tag:test"},
|
||||
},
|
||||
{
|
||||
ID: 5,
|
||||
Key: "b212b990165e897944dd3772786544402729fb349da50f57",
|
||||
UserID: testkra.ID,
|
||||
Tags: []string{"tag:test", "tag:woop", "tag:dedu"},
|
||||
},
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(expectedKeys, keys, cmp.Comparer(func(a, b []string) bool {
|
||||
slices.Sort(a)
|
||||
slices.Sort(b)
|
||||
return slices.Equal(a, b)
|
||||
}), cmpopts.IgnoreFields(types.PreAuthKey{}, "User", "CreatedAt", "Reusable", "Ephemeral", "Used", "Expiration")); diff != "" {
|
||||
t.Errorf("TestSQLiteMigrationAndDataValidation() pre-auth key tags migration mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Verify pre_auth_key_acl_tags table was dropped after migration
|
||||
if hsdb.DB.Migrator().HasTable("pre_auth_key_acl_tags") {
|
||||
t.Errorf("TestSQLiteMigrationAndDataValidation() table pre_auth_key_acl_tags should not exist after migration")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
dbPath: "testdata/sqlite/0-23-0-to-0-24-0-no-more-special-types_dump.sql",
|
||||
wantFunc: func(t *testing.T, hsdb *HSDatabase) {
|
||||
t.Helper()
|
||||
// Comprehensive data preservation validation for special types removal migration
|
||||
// Expected data from dump: 2 users, 2 pre_auth_keys, 12 nodes
|
||||
|
||||
// Verify users data preservation
|
||||
users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) {
|
||||
return ListUsers(rx)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, users, 2, "should preserve all 2 users from original schema")
|
||||
|
||||
// Verify pre_auth_keys data preservation
|
||||
preAuthKeys, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.PreAuthKey, error) {
|
||||
var keys []types.PreAuthKey
|
||||
err := rx.Find(&keys).Error
|
||||
return keys, err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, preAuthKeys, 2, "should preserve all 2 pre_auth_keys from original schema")
|
||||
|
||||
// Verify nodes data preservation and field validation
|
||||
nodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) {
|
||||
return ListNodes(rx)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, nodes, 12, "should preserve all 12 nodes from original schema")
|
||||
|
||||
for _, node := range nodes {
|
||||
assert.Falsef(t, node.MachineKey.IsZero(), "expected non zero machinekey")
|
||||
assert.Contains(t, node.MachineKey.String(), "mkey:")
|
||||
assert.Falsef(t, node.NodeKey.IsZero(), "expected non zero nodekey")
|
||||
assert.Contains(t, node.NodeKey.String(), "nodekey:")
|
||||
assert.Falsef(t, node.DiscoKey.IsZero(), "expected non zero discokey")
|
||||
assert.Contains(t, node.DiscoKey.String(), "discokey:")
|
||||
assert.NotNil(t, node.IPv4)
|
||||
assert.NotNil(t, node.IPv6)
|
||||
assert.Len(t, node.Endpoints, 1)
|
||||
assert.NotNil(t, node.Hostinfo)
|
||||
assert.NotNil(t, node.MachineKey)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
dbPath: "testdata/sqlite/failing-node-preauth-constraint_dump.sql",
|
||||
wantFunc: func(t *testing.T, hsdb *HSDatabase) {
|
||||
@@ -458,253 +67,6 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
dbPath: "testdata/sqlite/wrongly-migrated-schema-0.25.1_dump.sql",
|
||||
wantFunc: func(t *testing.T, hsdb *HSDatabase) {
|
||||
t.Helper()
|
||||
// Test migration of a database that was wrongly migrated in 0.25.1
|
||||
// This database has several issues:
|
||||
// 1. Missing proper user unique constraints (idx_provider_identifier, idx_name_provider_identifier, idx_name_no_provider_identifier)
|
||||
// 2. Still has routes table that should have been migrated to node.approved_routes
|
||||
// 3. Wrong FOREIGN KEY constraint on pre_auth_keys (CASCADE instead of SET NULL)
|
||||
// 4. Missing some required indexes
|
||||
|
||||
// Verify users table data is preserved with specific user data
|
||||
users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) {
|
||||
return ListUsers(rx)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, users, 2, "should preserve existing users")
|
||||
|
||||
// Validate specific user data from dump file using cmp.Diff
|
||||
expectedUsers := []types.User{
|
||||
{Model: gorm.Model{ID: 1}, Name: "user2"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "user1"},
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(expectedUsers, users,
|
||||
cmpopts.IgnoreFields(types.User{}, "CreatedAt", "UpdatedAt", "DeletedAt", "DisplayName", "Email", "ProviderIdentifier", "Provider", "ProfilePicURL")); diff != "" {
|
||||
t.Errorf("TestSQLiteMigrationAndDataValidation() users mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Create maps for easier access in later validations
|
||||
usersByName := make(map[string]*types.User)
|
||||
for i := range users {
|
||||
usersByName[users[i].Name] = &users[i]
|
||||
}
|
||||
user1 := usersByName["user1"]
|
||||
user2 := usersByName["user2"]
|
||||
|
||||
// Verify nodes table data is preserved and routes migrated to approved_routes
|
||||
nodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) {
|
||||
return ListNodes(rx)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, nodes, 3, "should preserve existing nodes")
|
||||
|
||||
// Validate specific node data from dump file
|
||||
nodesByID := make(map[uint64]*types.Node)
|
||||
for i := range nodes {
|
||||
nodesByID[nodes[i].ID.Uint64()] = nodes[i]
|
||||
}
|
||||
|
||||
node1 := nodesByID[1]
|
||||
node2 := nodesByID[2]
|
||||
node3 := nodesByID[3]
|
||||
require.NotNil(t, node1, "node 1 should exist")
|
||||
require.NotNil(t, node2, "node 2 should exist")
|
||||
require.NotNil(t, node3, "node 3 should exist")
|
||||
|
||||
// Validate specific node field data using cmp.Diff
|
||||
expectedNodes := map[uint64]struct {
|
||||
Hostname string
|
||||
GivenName string
|
||||
IPv4 string
|
||||
IPv6 string
|
||||
UserID uint
|
||||
}{
|
||||
1: {Hostname: "node1", GivenName: "node1", IPv4: "100.64.0.1", IPv6: "fd7a:115c:a1e0::1", UserID: user2.ID},
|
||||
2: {Hostname: "node2", GivenName: "node2", IPv4: "100.64.0.2", IPv6: "fd7a:115c:a1e0::2", UserID: user2.ID},
|
||||
3: {Hostname: "node3", GivenName: "node3", IPv4: "100.64.0.3", IPv6: "fd7a:115c:a1e0::3", UserID: user1.ID},
|
||||
}
|
||||
|
||||
for nodeID, expected := range expectedNodes {
|
||||
node := nodesByID[nodeID]
|
||||
require.NotNil(t, node, "node %d should exist", nodeID)
|
||||
|
||||
actual := struct {
|
||||
Hostname string
|
||||
GivenName string
|
||||
IPv4 string
|
||||
IPv6 string
|
||||
UserID uint
|
||||
}{
|
||||
Hostname: node.Hostname,
|
||||
GivenName: node.GivenName,
|
||||
IPv4: node.IPv4.String(),
|
||||
IPv6: func() string {
|
||||
if node.IPv6 != nil {
|
||||
return node.IPv6.String()
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}(),
|
||||
UserID: node.UserID,
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(expected, actual); diff != "" {
|
||||
t.Errorf("TestSQLiteMigrationAndDataValidation() node %d basic fields mismatch (-want +got):\n%s", nodeID, diff)
|
||||
}
|
||||
|
||||
// Special validation for MachineKey content for node 1 only
|
||||
if nodeID == 1 {
|
||||
assert.Contains(t, node.MachineKey.String(), "mkey:1efe4388236c1c83fe0a19d3ce7c321ab81e138a4da57917c231ce4c01944409")
|
||||
}
|
||||
}
|
||||
|
||||
// Check that routes were migrated from routes table to node.approved_routes using cmp.Diff
|
||||
// Original routes table had 4 routes for nodes 1, 2, 3:
|
||||
// Node 1: 0.0.0.0/0 (enabled), ::/0 (enabled) -> should have 2 approved routes
|
||||
// Node 2: 192.168.100.0/24 (enabled) -> should have 1 approved route
|
||||
// Node 3: 10.0.0.0/8 (disabled) -> should have 0 approved routes
|
||||
expectedRoutes := map[uint64][]netip.Prefix{
|
||||
1: {netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")},
|
||||
2: {netip.MustParsePrefix("192.168.100.0/24")},
|
||||
3: nil,
|
||||
}
|
||||
|
||||
actualRoutes := map[uint64][]netip.Prefix{
|
||||
1: node1.ApprovedRoutes,
|
||||
2: node2.ApprovedRoutes,
|
||||
3: node3.ApprovedRoutes,
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(expectedRoutes, actualRoutes, util.PrefixComparer); diff != "" {
|
||||
t.Errorf("TestSQLiteMigrationAndDataValidation() routes migration mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Verify pre_auth_keys data is preserved with specific key data
|
||||
preAuthKeys, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.PreAuthKey, error) {
|
||||
var keys []types.PreAuthKey
|
||||
err := rx.Find(&keys).Error
|
||||
return keys, err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, preAuthKeys, 2, "should preserve existing pre_auth_keys")
|
||||
|
||||
// Validate specific pre_auth_key data from dump file using cmp.Diff
|
||||
expectedKeys := []types.PreAuthKey{
|
||||
{
|
||||
ID: 1,
|
||||
Key: "3d133ec953e31fd41edbd935371234f762b4bae300cea618",
|
||||
UserID: user2.ID,
|
||||
Reusable: true,
|
||||
Used: true,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Key: "9813cc1df1832259fb6322dad788bb9bec89d8a01eef683a",
|
||||
UserID: user1.ID,
|
||||
Reusable: true,
|
||||
Used: true,
|
||||
},
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(expectedKeys, preAuthKeys,
|
||||
cmpopts.IgnoreFields(types.PreAuthKey{}, "User", "CreatedAt", "Expiration", "Ephemeral", "Tags")); diff != "" {
|
||||
t.Errorf("TestSQLiteMigrationAndDataValidation() pre_auth_keys mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Verify api_keys data is preserved with specific key data
|
||||
var apiKeys []struct {
|
||||
ID uint64
|
||||
Prefix string
|
||||
Hash []byte
|
||||
CreatedAt string
|
||||
Expiration string
|
||||
LastSeen string
|
||||
}
|
||||
err = hsdb.DB.Raw("SELECT id, prefix, hash, created_at, expiration, last_seen FROM api_keys").Scan(&apiKeys).Error
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, apiKeys, 1, "should preserve existing api_keys")
|
||||
|
||||
// Validate specific api_key data from dump file using cmp.Diff
|
||||
expectedAPIKey := struct {
|
||||
ID uint64
|
||||
Prefix string
|
||||
Hash []byte
|
||||
}{
|
||||
ID: 1,
|
||||
Prefix: "ak_test",
|
||||
Hash: []byte{0xde, 0xad, 0xbe, 0xef},
|
||||
}
|
||||
|
||||
actualAPIKey := struct {
|
||||
ID uint64
|
||||
Prefix string
|
||||
Hash []byte
|
||||
}{
|
||||
ID: apiKeys[0].ID,
|
||||
Prefix: apiKeys[0].Prefix,
|
||||
Hash: apiKeys[0].Hash,
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(expectedAPIKey, actualAPIKey); diff != "" {
|
||||
t.Errorf("TestSQLiteMigrationAndDataValidation() api_key mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Validate date fields separately since they need Contains check
|
||||
assert.Contains(t, apiKeys[0].CreatedAt, "2025-12-31", "created_at should be preserved")
|
||||
assert.Contains(t, apiKeys[0].Expiration, "2025-06-18", "expiration should be preserved")
|
||||
|
||||
// Verify that routes table no longer exists (should have been dropped)
|
||||
var routesTableExists bool
|
||||
err = hsdb.DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='routes'").Row().Scan(&routesTableExists)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, routesTableExists, "routes table should have been dropped")
|
||||
|
||||
// Verify all required indexes exist with correct structure using cmp.Diff
|
||||
expectedIndexes := []string{
|
||||
"idx_users_deleted_at",
|
||||
"idx_provider_identifier",
|
||||
"idx_name_provider_identifier",
|
||||
"idx_name_no_provider_identifier",
|
||||
"idx_api_keys_prefix",
|
||||
"idx_policies_deleted_at",
|
||||
}
|
||||
|
||||
expectedIndexMap := make(map[string]bool)
|
||||
for _, index := range expectedIndexes {
|
||||
expectedIndexMap[index] = true
|
||||
}
|
||||
|
||||
actualIndexMap := make(map[string]bool)
|
||||
for _, indexName := range expectedIndexes {
|
||||
var indexExists bool
|
||||
err = hsdb.DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name=?", indexName).Row().Scan(&indexExists)
|
||||
require.NoError(t, err)
|
||||
actualIndexMap[indexName] = indexExists
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(expectedIndexMap, actualIndexMap); diff != "" {
|
||||
t.Errorf("TestSQLiteMigrationAndDataValidation() indexes existence mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Verify proper foreign key constraints are set
|
||||
// Check that pre_auth_keys has correct FK constraint (SET NULL, not CASCADE)
|
||||
var preAuthKeyConstraint string
|
||||
err = hsdb.DB.Raw("SELECT sql FROM sqlite_master WHERE type='table' AND name='pre_auth_keys'").Row().Scan(&preAuthKeyConstraint)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, preAuthKeyConstraint, "ON DELETE SET NULL", "pre_auth_keys should have SET NULL constraint")
|
||||
assert.NotContains(t, preAuthKeyConstraint, "ON DELETE CASCADE", "pre_auth_keys should not have CASCADE constraint")
|
||||
|
||||
// Verify that user unique constraints work properly
|
||||
// Try to create duplicate local user (should fail)
|
||||
err = hsdb.DB.Create(&types.User{Name: users[0].Name}).Error
|
||||
require.Error(t, err, "should not allow duplicate local usernames")
|
||||
assert.Contains(t, err.Error(), "UNIQUE constraint", "should fail with unique constraint error")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -869,25 +231,7 @@ func TestPostgresMigrationAndDataValidation(t *testing.T) {
|
||||
name string
|
||||
dbPath string
|
||||
wantFunc func(*testing.T, *HSDatabase)
|
||||
}{
|
||||
{
|
||||
name: "user-idx-breaking",
|
||||
dbPath: "testdata/postgres/pre-24-postgresdb.pssql.dump",
|
||||
wantFunc: func(t *testing.T, hsdb *HSDatabase) {
|
||||
t.Helper()
|
||||
users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) {
|
||||
return ListUsers(rx)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, user := range users {
|
||||
assert.NotEmpty(t, user.Name)
|
||||
assert.Empty(t, user.ProfilePicURL)
|
||||
assert.Empty(t, user.Email)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}{}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -970,11 +314,10 @@ func dbForTestWithPath(t *testing.T, sqlFilePath string) *HSDatabase {
|
||||
// in the testdata directory. It verifies they can be successfully migrated to the current
|
||||
// schema version. This test only validates migration success, not data integrity.
|
||||
//
|
||||
// A lot of the schemas have been automatically generated with old Headscale binaries on empty databases
|
||||
// (no user/node data):
|
||||
// - `headscale_<VERSION>_schema.sql` (created with `sqlite3 headscale.db .schema`)
|
||||
// - `headscale_<VERSION>_dump.sql` (created with `sqlite3 headscale.db .dump`)
|
||||
// where `_dump.sql` contains the migration steps that have been applied to the database.
|
||||
// All test database files are SQL dumps (created with `sqlite3 headscale.db .dump`) generated
|
||||
// with old Headscale binaries on empty databases (no user/node data). These dumps include the
|
||||
// migration history in the `migrations` table, which allows the migration system to correctly
|
||||
// skip already-applied migrations and only run new ones.
|
||||
func TestSQLiteAllTestdataMigrations(t *testing.T) {
|
||||
t.Parallel()
|
||||
schemas, err := os.ReadDir("testdata/sqlite")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -68,31 +68,18 @@ func TestEphemeralGarbageCollectorGoRoutineLeak(t *testing.T) {
|
||||
gc.Cancel(nodeID)
|
||||
}
|
||||
|
||||
// Create a channel to signal when we're done with cleanup checks
|
||||
cleanupDone := make(chan struct{})
|
||||
// Close GC
|
||||
gc.Close()
|
||||
|
||||
// Close GC and check for leaks in a separate goroutine
|
||||
go func() {
|
||||
// Close GC
|
||||
gc.Close()
|
||||
|
||||
// Give any potential leaked goroutines a chance to exit
|
||||
// Still need a small sleep here as we're checking for absence of goroutines
|
||||
time.Sleep(oneHundred)
|
||||
|
||||
// Check for leaked goroutines
|
||||
// Wait for goroutines to clean up and verify no leaks
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
finalGoroutines := runtime.NumGoroutine()
|
||||
t.Logf("Final number of goroutines: %d", finalGoroutines)
|
||||
|
||||
// NB: We have to allow for a small number of extra goroutines because of test itself
|
||||
assert.LessOrEqual(t, finalGoroutines, initialGoroutines+5,
|
||||
assert.LessOrEqual(c, finalGoroutines, initialGoroutines+5,
|
||||
"There are significantly more goroutines after GC usage, which suggests a leak")
|
||||
}, time.Second, 10*time.Millisecond, "goroutines should clean up after GC close")
|
||||
|
||||
close(cleanupDone)
|
||||
}()
|
||||
|
||||
// Wait for cleanup to complete
|
||||
<-cleanupDone
|
||||
t.Logf("Final number of goroutines: %d", runtime.NumGoroutine())
|
||||
}
|
||||
|
||||
// TestEphemeralGarbageCollectorReschedule is a test for the rescheduling of nodes in EphemeralGarbageCollector().
|
||||
@@ -103,10 +90,14 @@ func TestEphemeralGarbageCollectorReschedule(t *testing.T) {
|
||||
var deletedIDs []types.NodeID
|
||||
var deleteMutex sync.Mutex
|
||||
|
||||
deletionNotifier := make(chan types.NodeID, 1)
|
||||
|
||||
deleteFunc := func(nodeID types.NodeID) {
|
||||
deleteMutex.Lock()
|
||||
deletedIDs = append(deletedIDs, nodeID)
|
||||
deleteMutex.Unlock()
|
||||
|
||||
deletionNotifier <- nodeID
|
||||
}
|
||||
|
||||
// Start GC
|
||||
@@ -125,10 +116,15 @@ func TestEphemeralGarbageCollectorReschedule(t *testing.T) {
|
||||
// Reschedule the same node with a shorter expiry
|
||||
gc.Schedule(nodeID, shortExpiry)
|
||||
|
||||
// Wait for deletion
|
||||
time.Sleep(shortExpiry * 2)
|
||||
// Wait for deletion notification with timeout
|
||||
select {
|
||||
case deletedNodeID := <-deletionNotifier:
|
||||
assert.Equal(t, nodeID, deletedNodeID, "The correct node should be deleted")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timed out waiting for node deletion")
|
||||
}
|
||||
|
||||
// Verify that the node was deleted once
|
||||
// Verify that the node was deleted exactly once
|
||||
deleteMutex.Lock()
|
||||
assert.Len(t, deletedIDs, 1, "Node should be deleted exactly once")
|
||||
assert.Equal(t, nodeID, deletedIDs[0], "The correct node should be deleted")
|
||||
@@ -203,18 +199,24 @@ func TestEphemeralGarbageCollectorCloseBeforeTimerFires(t *testing.T) {
|
||||
var deletedIDs []types.NodeID
|
||||
var deleteMutex sync.Mutex
|
||||
|
||||
deletionNotifier := make(chan types.NodeID, 1)
|
||||
|
||||
deleteFunc := func(nodeID types.NodeID) {
|
||||
deleteMutex.Lock()
|
||||
deletedIDs = append(deletedIDs, nodeID)
|
||||
deleteMutex.Unlock()
|
||||
|
||||
deletionNotifier <- nodeID
|
||||
}
|
||||
|
||||
// Start the GC
|
||||
gc := NewEphemeralGarbageCollector(deleteFunc)
|
||||
go gc.Start()
|
||||
|
||||
const longExpiry = 1 * time.Hour
|
||||
const shortExpiry = fifty
|
||||
const (
|
||||
longExpiry = 1 * time.Hour
|
||||
shortWait = fifty * 2
|
||||
)
|
||||
|
||||
// Schedule node deletion with a long expiry
|
||||
gc.Schedule(types.NodeID(1), longExpiry)
|
||||
@@ -222,8 +224,13 @@ func TestEphemeralGarbageCollectorCloseBeforeTimerFires(t *testing.T) {
|
||||
// Close the GC before the timer
|
||||
gc.Close()
|
||||
|
||||
// Wait a short time
|
||||
time.Sleep(shortExpiry * 2)
|
||||
// Verify that no deletion occurred within a reasonable time
|
||||
select {
|
||||
case <-deletionNotifier:
|
||||
t.Fatal("Node was deleted after GC was closed, which should not happen")
|
||||
case <-time.After(shortWait):
|
||||
// Expected: no deletion should occur
|
||||
}
|
||||
|
||||
// Verify that no deletion occurred
|
||||
deleteMutex.Lock()
|
||||
@@ -265,29 +272,17 @@ func TestEphemeralGarbageCollectorScheduleAfterClose(t *testing.T) {
|
||||
// Close GC right away
|
||||
gc.Close()
|
||||
|
||||
// Use a channel to signal when we should check for goroutine count
|
||||
gcClosedCheck := make(chan struct{})
|
||||
go func() {
|
||||
// Give the GC time to fully close and clean up resources
|
||||
// This is still time-based but only affects when we check the goroutine count,
|
||||
// not the actual test logic
|
||||
time.Sleep(oneHundred)
|
||||
close(gcClosedCheck)
|
||||
}()
|
||||
|
||||
// Now try to schedule node for deletion with a very short expiry
|
||||
// If the Schedule operation incorrectly creates a timer, it would fire quickly
|
||||
nodeID := types.NodeID(1)
|
||||
gc.Schedule(nodeID, 1*time.Millisecond)
|
||||
|
||||
// Set up a timeout channel for our test
|
||||
timeout := time.After(fiveHundred)
|
||||
|
||||
// Check if any node was deleted (which shouldn't happen)
|
||||
// Use timeout to wait for potential deletion
|
||||
select {
|
||||
case <-nodeDeleted:
|
||||
t.Fatal("Node was deleted after GC was closed, which should not happen")
|
||||
case <-timeout:
|
||||
case <-time.After(fiveHundred):
|
||||
// This is the expected path - no deletion should occur
|
||||
}
|
||||
|
||||
@@ -298,13 +293,14 @@ func TestEphemeralGarbageCollectorScheduleAfterClose(t *testing.T) {
|
||||
assert.Equal(t, 0, nodesDeleted, "No nodes should be deleted when Schedule is called after Close")
|
||||
|
||||
// Check for goroutine leaks after GC is fully closed
|
||||
<-gcClosedCheck
|
||||
finalGoroutines := runtime.NumGoroutine()
|
||||
t.Logf("Final number of goroutines: %d", finalGoroutines)
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
finalGoroutines := runtime.NumGoroutine()
|
||||
// Allow for small fluctuations in goroutine count for testing routines etc
|
||||
assert.LessOrEqual(c, finalGoroutines, initialGoroutines+2,
|
||||
"There should be no significant goroutine leaks when Schedule is called after Close")
|
||||
}, time.Second, 10*time.Millisecond, "goroutines should clean up after GC close")
|
||||
|
||||
// Allow for small fluctuations in goroutine count for testing routines etc
|
||||
assert.LessOrEqual(t, finalGoroutines, initialGoroutines+2,
|
||||
"There should be no significant goroutine leaks when Schedule is called after Close")
|
||||
t.Logf("Final number of goroutines: %d", runtime.NumGoroutine())
|
||||
}
|
||||
|
||||
// TestEphemeralGarbageCollectorConcurrentScheduleAndClose tests the behavior of the garbage collector
|
||||
@@ -331,7 +327,8 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
|
||||
// Number of concurrent scheduling goroutines
|
||||
const numSchedulers = 10
|
||||
const nodesPerScheduler = 50
|
||||
const schedulingDuration = fiveHundred
|
||||
|
||||
const closeAfterNodes = 25 // Close GC after this many nodes per scheduler
|
||||
|
||||
// Use WaitGroup to wait for all scheduling goroutines to finish
|
||||
var wg sync.WaitGroup
|
||||
@@ -340,6 +337,9 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
|
||||
// Create a stopper channel to signal scheduling goroutines to stop
|
||||
stopScheduling := make(chan struct{})
|
||||
|
||||
// Track how many nodes have been scheduled
|
||||
var scheduledCount int64
|
||||
|
||||
// Launch goroutines that continuously schedule nodes
|
||||
for schedulerIndex := range numSchedulers {
|
||||
go func(schedulerID int) {
|
||||
@@ -355,18 +355,23 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
|
||||
default:
|
||||
nodeID := types.NodeID(baseNodeID + j + 1)
|
||||
gc.Schedule(nodeID, 1*time.Hour) // Long expiry to ensure it doesn't trigger during test
|
||||
atomic.AddInt64(&scheduledCount, 1)
|
||||
|
||||
// Random (short) sleep to introduce randomness/variability
|
||||
time.Sleep(time.Duration(rand.Intn(5)) * time.Millisecond)
|
||||
// Yield to other goroutines to introduce variability
|
||||
runtime.Gosched()
|
||||
}
|
||||
}
|
||||
}(schedulerIndex)
|
||||
}
|
||||
|
||||
// After a short delay, close the garbage collector while schedulers are still running
|
||||
// Close the garbage collector after some nodes have been scheduled
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
time.Sleep(schedulingDuration / 2)
|
||||
|
||||
// Wait until enough nodes have been scheduled
|
||||
for atomic.LoadInt64(&scheduledCount) < int64(numSchedulers*closeAfterNodes) {
|
||||
runtime.Gosched()
|
||||
}
|
||||
|
||||
// Close GC
|
||||
gc.Close()
|
||||
@@ -378,14 +383,13 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
|
||||
// Wait a bit longer to allow any leaked goroutines to do their work
|
||||
time.Sleep(oneHundred)
|
||||
// Check for leaks using EventuallyWithT
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
finalGoroutines := runtime.NumGoroutine()
|
||||
// Allow for a reasonable small variable routine count due to testing
|
||||
assert.LessOrEqual(c, finalGoroutines, initialGoroutines+5,
|
||||
"There should be no significant goroutine leaks during concurrent Schedule and Close operations")
|
||||
}, time.Second, 10*time.Millisecond, "goroutines should clean up")
|
||||
|
||||
// Check for leaks
|
||||
finalGoroutines := runtime.NumGoroutine()
|
||||
t.Logf("Final number of goroutines: %d", finalGoroutines)
|
||||
|
||||
// Allow for a reasonable small variable routine count due to testing
|
||||
assert.LessOrEqual(t, finalGoroutines, initialGoroutines+5,
|
||||
"There should be no significant goroutine leaks during concurrent Schedule and Close operations")
|
||||
t.Logf("Final number of goroutines: %d", runtime.NumGoroutine())
|
||||
}
|
||||
|
||||
@@ -325,7 +325,11 @@ func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) {
|
||||
}
|
||||
|
||||
if changed {
|
||||
err := tx.Save(node).Error
|
||||
// Use Updates() with Select() to only update IP fields, avoiding overwriting
|
||||
// other fields like Expiry. We need Select() because Updates() alone skips
|
||||
// zero values, but we DO want to update IPv4/IPv6 to nil when removing them.
|
||||
// See issue #2862.
|
||||
err := tx.Model(node).Select("ipv4", "ipv6").Updates(node).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("saving node(%d) after adding IPs: %w", node.ID, err)
|
||||
}
|
||||
@@ -337,3 +341,12 @@ func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) {
|
||||
|
||||
return ret, err
|
||||
}
|
||||
|
||||
func (i *IPAllocator) FreeIPs(ips []netip.Addr) {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
for _, ip := range ips {
|
||||
i.usedIPs.Remove(ip)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
@@ -96,7 +95,7 @@ func TestIPAllocatorSequential(t *testing.T) {
|
||||
db.DB.Save(&user)
|
||||
|
||||
db.DB.Save(&types.Node{
|
||||
User: user,
|
||||
User: &user,
|
||||
IPv4: nap("100.64.0.1"),
|
||||
IPv6: nap("fd7a:115c:a1e0::1"),
|
||||
})
|
||||
@@ -124,7 +123,7 @@ func TestIPAllocatorSequential(t *testing.T) {
|
||||
db.DB.Save(&user)
|
||||
|
||||
db.DB.Save(&types.Node{
|
||||
User: user,
|
||||
User: &user,
|
||||
IPv4: nap("100.64.0.2"),
|
||||
IPv6: nap("fd7a:115c:a1e0::2"),
|
||||
})
|
||||
@@ -159,8 +158,6 @@ func TestIPAllocatorSequential(t *testing.T) {
|
||||
types.IPAllocationStrategySequential,
|
||||
)
|
||||
|
||||
spew.Dump(alloc)
|
||||
|
||||
var got4s []netip.Addr
|
||||
var got6s []netip.Addr
|
||||
|
||||
@@ -263,8 +260,6 @@ func TestIPAllocatorRandom(t *testing.T) {
|
||||
|
||||
alloc, _ := NewIPAllocator(db, tt.prefix4, tt.prefix6, types.IPAllocationStrategyRandom)
|
||||
|
||||
spew.Dump(alloc)
|
||||
|
||||
for range tt.getCount {
|
||||
got4, got6, err := alloc.Next()
|
||||
if err != nil {
|
||||
@@ -314,7 +309,7 @@ func TestBackfillIPAddresses(t *testing.T) {
|
||||
db.DB.Save(&user)
|
||||
|
||||
db.DB.Save(&types.Node{
|
||||
User: user,
|
||||
User: &user,
|
||||
IPv4: nap("100.64.0.1"),
|
||||
})
|
||||
|
||||
@@ -339,7 +334,7 @@ func TestBackfillIPAddresses(t *testing.T) {
|
||||
db.DB.Save(&user)
|
||||
|
||||
db.DB.Save(&types.Node{
|
||||
User: user,
|
||||
User: &user,
|
||||
IPv6: nap("fd7a:115c:a1e0::1"),
|
||||
})
|
||||
|
||||
@@ -364,7 +359,7 @@ func TestBackfillIPAddresses(t *testing.T) {
|
||||
db.DB.Save(&user)
|
||||
|
||||
db.DB.Save(&types.Node{
|
||||
User: user,
|
||||
User: &user,
|
||||
IPv4: nap("100.64.0.1"),
|
||||
IPv6: nap("fd7a:115c:a1e0::1"),
|
||||
})
|
||||
@@ -388,7 +383,7 @@ func TestBackfillIPAddresses(t *testing.T) {
|
||||
db.DB.Save(&user)
|
||||
|
||||
db.DB.Save(&types.Node{
|
||||
User: user,
|
||||
User: &user,
|
||||
IPv4: nap("100.64.0.1"),
|
||||
IPv6: nap("fd7a:115c:a1e0::1"),
|
||||
})
|
||||
@@ -412,19 +407,19 @@ func TestBackfillIPAddresses(t *testing.T) {
|
||||
db.DB.Save(&user)
|
||||
|
||||
db.DB.Save(&types.Node{
|
||||
User: user,
|
||||
User: &user,
|
||||
IPv4: nap("100.64.0.1"),
|
||||
})
|
||||
db.DB.Save(&types.Node{
|
||||
User: user,
|
||||
User: &user,
|
||||
IPv4: nap("100.64.0.2"),
|
||||
})
|
||||
db.DB.Save(&types.Node{
|
||||
User: user,
|
||||
User: &user,
|
||||
IPv4: nap("100.64.0.3"),
|
||||
})
|
||||
db.DB.Save(&types.Node{
|
||||
User: user,
|
||||
User: &user,
|
||||
IPv4: nap("100.64.0.4"),
|
||||
})
|
||||
|
||||
|
||||
@@ -196,8 +196,9 @@ func SetTags(
|
||||
tags []string,
|
||||
) error {
|
||||
if len(tags) == 0 {
|
||||
// if no tags are provided, we remove all forced tags
|
||||
if err := tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("forced_tags", "[]").Error; err != nil {
|
||||
// if no tags are provided, we remove all tags
|
||||
err := tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("tags", "[]").Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("removing tags: %w", err)
|
||||
}
|
||||
|
||||
@@ -211,7 +212,8 @@ func SetTags(
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("forced_tags", string(b)).Error; err != nil {
|
||||
err = tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("tags", string(b)).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating tags: %w", err)
|
||||
}
|
||||
|
||||
@@ -349,12 +351,20 @@ func RegisterNodeForTest(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *n
|
||||
panic("RegisterNodeForTest can only be called during tests")
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
logEvent := log.Debug().
|
||||
Str("node", node.Hostname).
|
||||
Str("machine_key", node.MachineKey.ShortString()).
|
||||
Str("node_key", node.NodeKey.ShortString()).
|
||||
Str("user", node.User.Username()).
|
||||
Msg("Registering test node")
|
||||
Str("node_key", node.NodeKey.ShortString())
|
||||
|
||||
if node.User != nil {
|
||||
logEvent = logEvent.Str("user", node.User.Username())
|
||||
} else if node.UserID != nil {
|
||||
logEvent = logEvent.Uint("user_id", *node.UserID)
|
||||
} else {
|
||||
logEvent = logEvent.Str("user", "none")
|
||||
}
|
||||
|
||||
logEvent.Msg("Registering test node")
|
||||
|
||||
// If the a new node is registered with the same machine key, to the same user,
|
||||
// update the existing node.
|
||||
@@ -452,13 +462,6 @@ func NodeSetMachineKey(
|
||||
}).Error
|
||||
}
|
||||
|
||||
// NodeSave saves a node object to the database, prefer to use a specific save method rather
|
||||
// than this. It is intended to be used when we are changing or.
|
||||
// TODO(kradalby): Remove this func, just use Save.
|
||||
func NodeSave(tx *gorm.DB, node *types.Node) error {
|
||||
return tx.Save(node).Error
|
||||
}
|
||||
|
||||
func generateGivenName(suppliedName string, randomSuffix bool) (string, error) {
|
||||
// Strip invalid DNS characters for givenName
|
||||
suppliedName = strings.ToLower(suppliedName)
|
||||
@@ -649,7 +652,7 @@ func (hsdb *HSDatabase) CreateNodeForTest(user *types.User, hostname ...string)
|
||||
}
|
||||
|
||||
// Create a preauth key for the node
|
||||
pak, err := hsdb.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
|
||||
pak, err := hsdb.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to create preauth key for test node: %v", err))
|
||||
}
|
||||
@@ -663,7 +666,7 @@ func (hsdb *HSDatabase) CreateNodeForTest(user *types.User, hostname ...string)
|
||||
NodeKey: nodeKey.Public(),
|
||||
DiscoKey: discoKey.Public(),
|
||||
Hostname: nodeName,
|
||||
UserID: user.ID,
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
AuthKeyID: ptr.To(pak.ID),
|
||||
}
|
||||
@@ -756,15 +759,25 @@ func (hsdb *HSDatabase) allocateTestIPs(nodeID types.NodeID) (*netip.Addr, *neti
|
||||
}
|
||||
|
||||
// Use simple sequential allocation for tests
|
||||
// IPv4: 100.64.0.x (where x is nodeID)
|
||||
// IPv6: fd7a:115c:a1e0::x (where x is nodeID)
|
||||
// IPv4: 100.64.x.y (where x = nodeID/256, y = nodeID%256)
|
||||
// IPv6: fd7a:115c:a1e0::x:y (where x = high byte, y = low byte)
|
||||
// This supports up to 65535 nodes
|
||||
const (
|
||||
maxTestNodes = 65535
|
||||
ipv4ByteDivisor = 256
|
||||
)
|
||||
|
||||
if nodeID > 254 {
|
||||
return nil, nil, fmt.Errorf("test node ID %d too large for simple IP allocation", nodeID)
|
||||
if nodeID > maxTestNodes {
|
||||
return nil, nil, ErrCouldNotAllocateIP
|
||||
}
|
||||
|
||||
ipv4 := netip.AddrFrom4([4]byte{100, 64, 0, byte(nodeID)})
|
||||
ipv6 := netip.AddrFrom16([16]byte{0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, 0, byte(nodeID)})
|
||||
// Split nodeID into high and low bytes for IPv4 (100.64.high.low)
|
||||
highByte := byte(nodeID / ipv4ByteDivisor)
|
||||
lowByte := byte(nodeID % ipv4ByteDivisor)
|
||||
ipv4 := netip.AddrFrom4([4]byte{100, 64, highByte, lowByte})
|
||||
|
||||
// For IPv6, use the last two bytes of the address (fd7a:115c:a1e0::high:low)
|
||||
ipv6 := netip.AddrFrom16([16]byte{0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, highByte, lowByte})
|
||||
|
||||
return &ipv4, &ipv6, nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"math/big"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -83,7 +85,7 @@ func (s *Suite) TestExpireNode(c *check.C) {
|
||||
user, err := db.CreateUser(types.User{Name: "test"})
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
|
||||
pak, err := db.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
_, err = db.getNode(types.UserID(user.ID), "testnode")
|
||||
@@ -97,7 +99,7 @@ func (s *Suite) TestExpireNode(c *check.C) {
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostname: "testnode",
|
||||
UserID: user.ID,
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
AuthKeyID: ptr.To(pak.ID),
|
||||
Expiry: &time.Time{},
|
||||
@@ -124,7 +126,7 @@ func (s *Suite) TestSetTags(c *check.C) {
|
||||
user, err := db.CreateUser(types.User{Name: "test"})
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
|
||||
pak, err := db.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
_, err = db.getNode(types.UserID(user.ID), "testnode")
|
||||
@@ -138,7 +140,7 @@ func (s *Suite) TestSetTags(c *check.C) {
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostname: "testnode",
|
||||
UserID: user.ID,
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
AuthKeyID: ptr.To(pak.ID),
|
||||
}
|
||||
@@ -152,7 +154,7 @@ func (s *Suite) TestSetTags(c *check.C) {
|
||||
c.Assert(err, check.IsNil)
|
||||
node, err = db.getNode(types.UserID(user.ID), "testnode")
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(node.ForcedTags, check.DeepEquals, sTags)
|
||||
c.Assert(node.Tags, check.DeepEquals, sTags)
|
||||
|
||||
// assign duplicate tags, expect no errors but no doubles in DB
|
||||
eTags := []string{"tag:bar", "tag:test", "tag:unknown", "tag:test"}
|
||||
@@ -161,17 +163,10 @@ func (s *Suite) TestSetTags(c *check.C) {
|
||||
node, err = db.getNode(types.UserID(user.ID), "testnode")
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(
|
||||
node.ForcedTags,
|
||||
node.Tags,
|
||||
check.DeepEquals,
|
||||
[]string{"tag:bar", "tag:test", "tag:unknown"},
|
||||
)
|
||||
|
||||
// test removing tags
|
||||
err = db.SetTags(node.ID, []string{})
|
||||
c.Assert(err, check.IsNil)
|
||||
node, err = db.getNode(types.UserID(user.ID), "testnode")
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(node.ForcedTags, check.DeepEquals, []string{})
|
||||
}
|
||||
|
||||
func TestHeadscale_generateGivenName(t *testing.T) {
|
||||
@@ -430,7 +425,7 @@ func TestAutoApproveRoutes(t *testing.T) {
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "testnode",
|
||||
UserID: user.ID,
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: tt.routes,
|
||||
@@ -446,13 +441,13 @@ func TestAutoApproveRoutes(t *testing.T) {
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "taggednode",
|
||||
UserID: taggedUser.ID,
|
||||
UserID: &taggedUser.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: tt.routes,
|
||||
},
|
||||
ForcedTags: []string{"tag:exit"},
|
||||
IPv4: ptr.To(netip.MustParseAddr("100.64.0.2")),
|
||||
Tags: []string{"tag:exit"},
|
||||
IPv4: ptr.To(netip.MustParseAddr("100.64.0.2")),
|
||||
}
|
||||
|
||||
err = adb.DB.Save(&nodeTagged).Error
|
||||
@@ -514,23 +509,48 @@ func TestEphemeralGarbageCollectorOrder(t *testing.T) {
|
||||
got := []types.NodeID{}
|
||||
var mu sync.Mutex
|
||||
|
||||
deletionCount := make(chan struct{}, 10)
|
||||
|
||||
e := NewEphemeralGarbageCollector(func(ni types.NodeID) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
got = append(got, ni)
|
||||
|
||||
deletionCount <- struct{}{}
|
||||
})
|
||||
go e.Start()
|
||||
|
||||
go e.Schedule(1, 1*time.Second)
|
||||
go e.Schedule(2, 2*time.Second)
|
||||
go e.Schedule(3, 3*time.Second)
|
||||
go e.Schedule(4, 4*time.Second)
|
||||
// Use shorter timeouts for faster tests
|
||||
go e.Schedule(1, 50*time.Millisecond)
|
||||
go e.Schedule(2, 100*time.Millisecond)
|
||||
go e.Schedule(3, 150*time.Millisecond)
|
||||
go e.Schedule(4, 200*time.Millisecond)
|
||||
|
||||
time.Sleep(time.Second)
|
||||
// Wait for first deletion (node 1 at 50ms)
|
||||
select {
|
||||
case <-deletionCount:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timeout waiting for first deletion")
|
||||
}
|
||||
|
||||
// Cancel nodes 2 and 4
|
||||
go e.Cancel(2)
|
||||
go e.Cancel(4)
|
||||
|
||||
time.Sleep(6 * time.Second)
|
||||
// Wait for node 3 to be deleted (at 150ms)
|
||||
select {
|
||||
case <-deletionCount:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timeout waiting for second deletion")
|
||||
}
|
||||
|
||||
// Give a bit more time for any unexpected deletions
|
||||
select {
|
||||
case <-deletionCount:
|
||||
// Unexpected - more deletions than expected
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
// Expected - no more deletions
|
||||
}
|
||||
|
||||
e.Close()
|
||||
|
||||
@@ -548,20 +568,30 @@ func TestEphemeralGarbageCollectorLoads(t *testing.T) {
|
||||
|
||||
want := 1000
|
||||
|
||||
var deletedCount int64
|
||||
|
||||
e := NewEphemeralGarbageCollector(func(ni types.NodeID) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
time.Sleep(time.Duration(generateRandomNumber(t, 3)) * time.Millisecond)
|
||||
// Yield to other goroutines to introduce variability
|
||||
runtime.Gosched()
|
||||
got = append(got, ni)
|
||||
|
||||
atomic.AddInt64(&deletedCount, 1)
|
||||
})
|
||||
go e.Start()
|
||||
|
||||
// Use shorter expiry for faster tests
|
||||
for i := range want {
|
||||
go e.Schedule(types.NodeID(i), 1*time.Second)
|
||||
go e.Schedule(types.NodeID(i), 100*time.Millisecond) //nolint:gosec // test code, no overflow risk
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Second)
|
||||
// Wait for all deletions to complete
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
count := atomic.LoadInt64(&deletedCount)
|
||||
assert.Equal(c, int64(want), count, "all nodes should be deleted")
|
||||
}, 10*time.Second, 50*time.Millisecond, "waiting for all deletions")
|
||||
|
||||
e.Close()
|
||||
|
||||
@@ -593,10 +623,10 @@ func TestListEphemeralNodes(t *testing.T) {
|
||||
user, err := db.CreateUser(types.User{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
|
||||
pak, err := db.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
pakEph, err := db.CreatePreAuthKey(types.UserID(user.ID), false, true, nil, nil)
|
||||
pakEph, err := db.CreatePreAuthKey(user.TypedID(), false, true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
node := types.Node{
|
||||
@@ -604,7 +634,7 @@ func TestListEphemeralNodes(t *testing.T) {
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "test",
|
||||
UserID: user.ID,
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
AuthKeyID: ptr.To(pak.ID),
|
||||
}
|
||||
@@ -614,7 +644,7 @@ func TestListEphemeralNodes(t *testing.T) {
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "ephemeral",
|
||||
UserID: user.ID,
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
AuthKeyID: ptr.To(pakEph.ID),
|
||||
}
|
||||
@@ -657,7 +687,7 @@ func TestNodeNaming(t *testing.T) {
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "test",
|
||||
UserID: user.ID,
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
@@ -667,7 +697,7 @@ func TestNodeNaming(t *testing.T) {
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "test",
|
||||
UserID: user2.ID,
|
||||
UserID: &user2.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
@@ -680,7 +710,7 @@ func TestNodeNaming(t *testing.T) {
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "我的电脑",
|
||||
UserID: user2.ID,
|
||||
UserID: &user2.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
}
|
||||
|
||||
@@ -688,7 +718,7 @@ func TestNodeNaming(t *testing.T) {
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "a",
|
||||
UserID: user2.ID,
|
||||
UserID: &user2.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
}
|
||||
|
||||
@@ -808,7 +838,7 @@ func TestRenameNodeComprehensive(t *testing.T) {
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "testnode",
|
||||
UserID: user.ID,
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
@@ -931,7 +961,7 @@ func TestListPeers(t *testing.T) {
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "test1",
|
||||
UserID: user.ID,
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
@@ -941,7 +971,7 @@ func TestListPeers(t *testing.T) {
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "test2",
|
||||
UserID: user2.ID,
|
||||
UserID: &user2.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
@@ -1016,7 +1046,7 @@ func TestListNodes(t *testing.T) {
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "test1",
|
||||
UserID: user.ID,
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
@@ -1026,7 +1056,7 @@ func TestListNodes(t *testing.T) {
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "test2",
|
||||
UserID: user2.ID,
|
||||
UserID: &user2.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
@@ -10,42 +8,73 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPreAuthKeyNotFound = errors.New("AuthKey not found")
|
||||
ErrPreAuthKeyExpired = errors.New("AuthKey expired")
|
||||
ErrSingleUseAuthKeyHasBeenUsed = errors.New("AuthKey has already been used")
|
||||
ErrPreAuthKeyNotFound = errors.New("auth-key not found")
|
||||
ErrPreAuthKeyExpired = errors.New("auth-key expired")
|
||||
ErrSingleUseAuthKeyHasBeenUsed = errors.New("auth-key has already been used")
|
||||
ErrUserMismatch = errors.New("user mismatch")
|
||||
ErrPreAuthKeyACLTagInvalid = errors.New("AuthKey tag is invalid")
|
||||
ErrPreAuthKeyACLTagInvalid = errors.New("auth-key tag is invalid")
|
||||
)
|
||||
|
||||
func (hsdb *HSDatabase) CreatePreAuthKey(
|
||||
uid types.UserID,
|
||||
uid *types.UserID,
|
||||
reusable bool,
|
||||
ephemeral bool,
|
||||
expiration *time.Time,
|
||||
aclTags []string,
|
||||
) (*types.PreAuthKey, error) {
|
||||
return Write(hsdb.DB, func(tx *gorm.DB) (*types.PreAuthKey, error) {
|
||||
) (*types.PreAuthKeyNew, error) {
|
||||
return Write(hsdb.DB, func(tx *gorm.DB) (*types.PreAuthKeyNew, error) {
|
||||
return CreatePreAuthKey(tx, uid, reusable, ephemeral, expiration, aclTags)
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
authKeyPrefix = "hskey-auth-"
|
||||
authKeyPrefixLength = 12
|
||||
authKeyLength = 64
|
||||
)
|
||||
|
||||
// CreatePreAuthKey creates a new PreAuthKey in a user, and returns it.
|
||||
// The uid parameter can be nil for system-created tagged keys.
|
||||
// For tagged keys, uid tracks "created by" (who created the key).
|
||||
// For user-owned keys, uid tracks the node owner.
|
||||
func CreatePreAuthKey(
|
||||
tx *gorm.DB,
|
||||
uid types.UserID,
|
||||
uid *types.UserID,
|
||||
reusable bool,
|
||||
ephemeral bool,
|
||||
expiration *time.Time,
|
||||
aclTags []string,
|
||||
) (*types.PreAuthKey, error) {
|
||||
user, err := GetUserByID(tx, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
) (*types.PreAuthKeyNew, error) {
|
||||
// Validate: must be tagged OR user-owned, not neither
|
||||
if uid == nil && len(aclTags) == 0 {
|
||||
return nil, ErrPreAuthKeyNotTaggedOrOwned
|
||||
}
|
||||
|
||||
// If uid != nil && len(aclTags) > 0:
|
||||
// Both are allowed: UserID tracks "created by", tags define node ownership
|
||||
// This is valid per the new model
|
||||
|
||||
var (
|
||||
user *types.User
|
||||
userID *uint
|
||||
)
|
||||
|
||||
if uid != nil {
|
||||
var err error
|
||||
|
||||
user, err = GetUserByID(tx, *uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userID = &user.ID
|
||||
}
|
||||
|
||||
// Remove duplicates and sort for consistency
|
||||
@@ -65,28 +94,68 @@ func CreatePreAuthKey(
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
// TODO(kradalby): unify the key generations spread all over the code.
|
||||
kstr, err := generateKey()
|
||||
|
||||
prefix, err := util.GenerateRandomStringURLSafe(authKeyPrefixLength)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate generated prefix (should always be valid, but be defensive)
|
||||
if len(prefix) != authKeyPrefixLength {
|
||||
return nil, fmt.Errorf("%w: generated prefix has invalid length: expected %d, got %d", ErrPreAuthKeyFailedToParse, authKeyPrefixLength, len(prefix))
|
||||
}
|
||||
|
||||
if !isValidBase64URLSafe(prefix) {
|
||||
return nil, fmt.Errorf("%w: generated prefix contains invalid characters", ErrPreAuthKeyFailedToParse)
|
||||
}
|
||||
|
||||
toBeHashed, err := util.GenerateRandomStringURLSafe(authKeyLength)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate generated hash (should always be valid, but be defensive)
|
||||
if len(toBeHashed) != authKeyLength {
|
||||
return nil, fmt.Errorf("%w: generated hash has invalid length: expected %d, got %d", ErrPreAuthKeyFailedToParse, authKeyLength, len(toBeHashed))
|
||||
}
|
||||
|
||||
if !isValidBase64URLSafe(toBeHashed) {
|
||||
return nil, fmt.Errorf("%w: generated hash contains invalid characters", ErrPreAuthKeyFailedToParse)
|
||||
}
|
||||
|
||||
keyStr := authKeyPrefix + prefix + "-" + toBeHashed
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(toBeHashed), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := types.PreAuthKey{
|
||||
Key: kstr,
|
||||
UserID: user.ID,
|
||||
User: *user,
|
||||
UserID: userID, // nil for system-created keys, or "created by" for tagged keys
|
||||
User: user, // nil for system-created keys
|
||||
Reusable: reusable,
|
||||
Ephemeral: ephemeral,
|
||||
CreatedAt: &now,
|
||||
Expiration: expiration,
|
||||
Tags: aclTags,
|
||||
Tags: aclTags, // empty for user-owned keys
|
||||
Prefix: prefix, // Store prefix
|
||||
Hash: hash, // Store hash
|
||||
}
|
||||
|
||||
if err := tx.Save(&key).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create key in the database: %w", err)
|
||||
}
|
||||
|
||||
return &key, nil
|
||||
return &types.PreAuthKeyNew{
|
||||
ID: key.ID,
|
||||
Key: keyStr,
|
||||
Reusable: key.Reusable,
|
||||
Ephemeral: key.Ephemeral,
|
||||
Tags: key.Tags,
|
||||
Expiration: key.Expiration,
|
||||
CreatedAt: key.CreatedAt,
|
||||
User: key.User,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (hsdb *HSDatabase) ListPreAuthKeys(uid types.UserID) ([]types.PreAuthKey, error) {
|
||||
@@ -103,13 +172,119 @@ func ListPreAuthKeysByUser(tx *gorm.DB, uid types.UserID) ([]types.PreAuthKey, e
|
||||
}
|
||||
|
||||
keys := []types.PreAuthKey{}
|
||||
if err := tx.Preload("User").Where(&types.PreAuthKey{UserID: user.ID}).Find(&keys).Error; err != nil {
|
||||
|
||||
err = tx.Preload("User").Where(&types.PreAuthKey{UserID: &user.ID}).Find(&keys).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
var (
|
||||
ErrPreAuthKeyFailedToParse = errors.New("failed to parse auth-key")
|
||||
ErrPreAuthKeyNotTaggedOrOwned = errors.New("auth-key must be either tagged or owned by user")
|
||||
)
|
||||
|
||||
func findAuthKey(tx *gorm.DB, keyStr string) (*types.PreAuthKey, error) {
|
||||
var pak types.PreAuthKey
|
||||
|
||||
// Validate input is not empty
|
||||
if keyStr == "" {
|
||||
return nil, ErrPreAuthKeyFailedToParse
|
||||
}
|
||||
|
||||
_, prefixAndHash, found := strings.Cut(keyStr, authKeyPrefix)
|
||||
|
||||
if !found {
|
||||
// Legacy format (plaintext) - backwards compatibility
|
||||
err := tx.Preload("User").First(&pak, "key = ?", keyStr).Error
|
||||
if err != nil {
|
||||
return nil, ErrPreAuthKeyNotFound
|
||||
}
|
||||
|
||||
return &pak, nil
|
||||
}
|
||||
|
||||
// New format: hskey-auth-{12-char-prefix}-{64-char-hash}
|
||||
// Expected minimum length: 12 (prefix) + 1 (separator) + 64 (hash) = 77
|
||||
const expectedMinLength = authKeyPrefixLength + 1 + authKeyLength
|
||||
if len(prefixAndHash) < expectedMinLength {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: key too short, expected at least %d chars after prefix, got %d",
|
||||
ErrPreAuthKeyFailedToParse,
|
||||
expectedMinLength,
|
||||
len(prefixAndHash),
|
||||
)
|
||||
}
|
||||
|
||||
// Use fixed-length parsing instead of separator-based to handle dashes in base64 URL-safe
|
||||
prefix := prefixAndHash[:authKeyPrefixLength]
|
||||
|
||||
// Validate separator at expected position
|
||||
if prefixAndHash[authKeyPrefixLength] != '-' {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: expected separator '-' at position %d, got '%c'",
|
||||
ErrPreAuthKeyFailedToParse,
|
||||
authKeyPrefixLength,
|
||||
prefixAndHash[authKeyPrefixLength],
|
||||
)
|
||||
}
|
||||
|
||||
hash := prefixAndHash[authKeyPrefixLength+1:]
|
||||
|
||||
// Validate hash length
|
||||
if len(hash) != authKeyLength {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: hash length mismatch, expected %d chars, got %d",
|
||||
ErrPreAuthKeyFailedToParse,
|
||||
authKeyLength,
|
||||
len(hash),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate prefix contains only base64 URL-safe characters
|
||||
if !isValidBase64URLSafe(prefix) {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: prefix contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)",
|
||||
ErrPreAuthKeyFailedToParse,
|
||||
)
|
||||
}
|
||||
|
||||
// Validate hash contains only base64 URL-safe characters
|
||||
if !isValidBase64URLSafe(hash) {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: hash contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)",
|
||||
ErrPreAuthKeyFailedToParse,
|
||||
)
|
||||
}
|
||||
|
||||
// Look up key by prefix
|
||||
err := tx.Preload("User").First(&pak, "prefix = ?", prefix).Error
|
||||
if err != nil {
|
||||
return nil, ErrPreAuthKeyNotFound
|
||||
}
|
||||
|
||||
// Verify hash matches
|
||||
err = bcrypt.CompareHashAndPassword(pak.Hash, []byte(hash))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid auth key: %w", err)
|
||||
}
|
||||
|
||||
return &pak, nil
|
||||
}
|
||||
|
||||
// isValidBase64URLSafe checks if a string contains only base64 URL-safe characters.
|
||||
func isValidBase64URLSafe(s string) bool {
|
||||
for _, c := range s {
|
||||
if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '_' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (hsdb *HSDatabase) GetPreAuthKey(key string) (*types.PreAuthKey, error) {
|
||||
return GetPreAuthKey(hsdb.DB, key)
|
||||
}
|
||||
@@ -117,18 +292,23 @@ func (hsdb *HSDatabase) GetPreAuthKey(key string) (*types.PreAuthKey, error) {
|
||||
// GetPreAuthKey returns a PreAuthKey for a given key. The caller is responsible
|
||||
// for checking if the key is usable (expired or used).
|
||||
func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) {
|
||||
pak := types.PreAuthKey{}
|
||||
if err := tx.Preload("User").First(&pak, "key = ?", key).Error; err != nil {
|
||||
return nil, ErrPreAuthKeyNotFound
|
||||
}
|
||||
|
||||
return &pak, nil
|
||||
return findAuthKey(tx, key)
|
||||
}
|
||||
|
||||
// DestroyPreAuthKey destroys a preauthkey. Returns error if the PreAuthKey
|
||||
// does not exist.
|
||||
// does not exist. This also clears the auth_key_id on any nodes that reference
|
||||
// this key.
|
||||
func DestroyPreAuthKey(tx *gorm.DB, pak types.PreAuthKey) error {
|
||||
return tx.Transaction(func(db *gorm.DB) error {
|
||||
// First, clear the foreign key reference on any nodes using this key
|
||||
err := db.Model(&types.Node{}).
|
||||
Where("auth_key_id = ?", pak.ID).
|
||||
Update("auth_key_id", nil).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clear auth_key_id on nodes: %w", err)
|
||||
}
|
||||
|
||||
// Then delete the pre-auth key
|
||||
if result := db.Unscoped().Delete(pak); result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
@@ -143,13 +323,20 @@ func (hsdb *HSDatabase) ExpirePreAuthKey(k *types.PreAuthKey) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (hsdb *HSDatabase) DeletePreAuthKey(k *types.PreAuthKey) error {
|
||||
return hsdb.Write(func(tx *gorm.DB) error {
|
||||
return DestroyPreAuthKey(tx, *k)
|
||||
})
|
||||
}
|
||||
|
||||
// UsePreAuthKey marks a PreAuthKey as used.
|
||||
func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
|
||||
k.Used = true
|
||||
if err := tx.Save(k).Error; err != nil {
|
||||
err := tx.Model(k).Update("used", true).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update key used status in the database: %w", err)
|
||||
}
|
||||
|
||||
k.Used = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -158,13 +345,3 @@ func ExpirePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
|
||||
now := time.Now()
|
||||
return tx.Model(&types.PreAuthKey{}).Where("id = ?", k.ID).Update("expiration", now).Error
|
||||
}
|
||||
|
||||
func generateKey() (string, error) {
|
||||
size := 24
|
||||
bytes := make([]byte, size)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
@@ -1,84 +1,456 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/check.v1"
|
||||
"tailscale.com/types/ptr"
|
||||
)
|
||||
|
||||
func (*Suite) TestCreatePreAuthKey(c *check.C) {
|
||||
// ID does not exist
|
||||
_, err := db.CreatePreAuthKey(12345, true, false, nil, nil)
|
||||
c.Assert(err, check.NotNil)
|
||||
func TestCreatePreAuthKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
test func(*testing.T, *HSDatabase)
|
||||
}{
|
||||
{
|
||||
name: "error_invalid_user_id",
|
||||
test: func(t *testing.T, db *HSDatabase) {
|
||||
t.Helper()
|
||||
|
||||
user, err := db.CreateUser(types.User{Name: "test"})
|
||||
c.Assert(err, check.IsNil)
|
||||
_, err := db.CreatePreAuthKey(ptr.To(types.UserID(12345)), true, false, nil, nil)
|
||||
assert.Error(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "success_create_and_list",
|
||||
test: func(t *testing.T, db *HSDatabase) {
|
||||
t.Helper()
|
||||
|
||||
key, err := db.CreatePreAuthKey(types.UserID(user.ID), true, false, nil, nil)
|
||||
c.Assert(err, check.IsNil)
|
||||
user, err := db.CreateUser(types.User{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Did we get a valid key?
|
||||
c.Assert(key.Key, check.NotNil)
|
||||
c.Assert(len(key.Key), check.Equals, 48)
|
||||
key, err := db.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, key.Key)
|
||||
|
||||
// Make sure the User association is populated
|
||||
c.Assert(key.User.ID, check.Equals, user.ID)
|
||||
// List keys for the user
|
||||
keys, err := db.ListPreAuthKeys(types.UserID(user.ID))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, keys, 1)
|
||||
|
||||
// ID does not exist
|
||||
_, err = db.ListPreAuthKeys(1000000)
|
||||
c.Assert(err, check.NotNil)
|
||||
// Verify User association is populated
|
||||
assert.Equal(t, user.ID, keys[0].User.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error_list_invalid_user_id",
|
||||
test: func(t *testing.T, db *HSDatabase) {
|
||||
t.Helper()
|
||||
|
||||
keys, err := db.ListPreAuthKeys(types.UserID(user.ID))
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(len(keys), check.Equals, 1)
|
||||
_, err := db.ListPreAuthKeys(1000000)
|
||||
assert.Error(t, err)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Make sure the User association is populated
|
||||
c.Assert((keys)[0].User.ID, check.Equals, user.ID)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
tt.test(t, db)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (*Suite) TestPreAuthKeyACLTags(c *check.C) {
|
||||
user, err := db.CreateUser(types.User{Name: "test8"})
|
||||
c.Assert(err, check.IsNil)
|
||||
func TestPreAuthKeyACLTags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
test func(*testing.T, *HSDatabase)
|
||||
}{
|
||||
{
|
||||
name: "reject_malformed_tags",
|
||||
test: func(t *testing.T, db *HSDatabase) {
|
||||
t.Helper()
|
||||
|
||||
_, err = db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, []string{"badtag"})
|
||||
c.Assert(err, check.NotNil) // Confirm that malformed tags are rejected
|
||||
user, err := db.CreateUser(types.User{Name: "test-tags-1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
tags := []string{"tag:test1", "tag:test2"}
|
||||
tagsWithDuplicate := []string{"tag:test1", "tag:test2", "tag:test2"}
|
||||
_, err = db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, tagsWithDuplicate)
|
||||
c.Assert(err, check.IsNil)
|
||||
_, err = db.CreatePreAuthKey(user.TypedID(), false, false, nil, []string{"badtag"})
|
||||
assert.Error(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deduplicate_and_sort_tags",
|
||||
test: func(t *testing.T, db *HSDatabase) {
|
||||
t.Helper()
|
||||
|
||||
listedPaks, err := db.ListPreAuthKeys(types.UserID(user.ID))
|
||||
c.Assert(err, check.IsNil)
|
||||
gotTags := listedPaks[0].Proto().GetAclTags()
|
||||
slices.Sort(gotTags)
|
||||
c.Assert(gotTags, check.DeepEquals, tags)
|
||||
user, err := db.CreateUser(types.User{Name: "test-tags-2"})
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedTags := []string{"tag:test1", "tag:test2"}
|
||||
tagsWithDuplicate := []string{"tag:test1", "tag:test2", "tag:test2"}
|
||||
|
||||
_, err = db.CreatePreAuthKey(user.TypedID(), false, false, nil, tagsWithDuplicate)
|
||||
require.NoError(t, err)
|
||||
|
||||
listedPaks, err := db.ListPreAuthKeys(types.UserID(user.ID))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, listedPaks, 1)
|
||||
|
||||
gotTags := listedPaks[0].Proto().GetAclTags()
|
||||
slices.Sort(gotTags)
|
||||
assert.Equal(t, expectedTags, gotTags)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
tt.test(t, db)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotDeleteAssignedPreAuthKey(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
require.NoError(t, err)
|
||||
user, err := db.CreateUser(types.User{Name: "test8"})
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, []string{"tag:good"})
|
||||
assert.NoError(t, err)
|
||||
key, err := db.CreatePreAuthKey(user.TypedID(), false, false, nil, []string{"tag:good"})
|
||||
require.NoError(t, err)
|
||||
|
||||
node := types.Node{
|
||||
ID: 0,
|
||||
Hostname: "testest",
|
||||
UserID: user.ID,
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
AuthKeyID: ptr.To(key.ID),
|
||||
}
|
||||
db.DB.Save(&node)
|
||||
|
||||
err = db.DB.Delete(key).Error
|
||||
err = db.DB.Delete(&types.PreAuthKey{ID: key.ID}).Error
|
||||
require.ErrorContains(t, err, "constraint failed: FOREIGN KEY constraint failed")
|
||||
}
|
||||
|
||||
func TestPreAuthKeyAuthentication(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
user := db.CreateUserForTest("test-user")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupKey func() string // Returns key string to test
|
||||
wantFindErr bool // Error when finding the key
|
||||
wantValidateErr bool // Error when validating the key
|
||||
validateResult func(*testing.T, *types.PreAuthKey)
|
||||
}{
|
||||
{
|
||||
name: "legacy_key_plaintext",
|
||||
setupKey: func() string {
|
||||
// Insert legacy key directly using GORM (simulate existing production key)
|
||||
// Note: We use raw SQL to bypass GORM's handling and set prefix to empty string
|
||||
// which simulates how legacy keys exist in production databases
|
||||
legacyKey := "abc123def456ghi789jkl012mno345pqr678stu901vwx234yz"
|
||||
now := time.Now()
|
||||
|
||||
// Use raw SQL to insert with empty prefix to avoid UNIQUE constraint
|
||||
err := db.DB.Exec(`
|
||||
INSERT INTO pre_auth_keys (key, user_id, reusable, ephemeral, used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, legacyKey, user.ID, true, false, false, now).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
return legacyKey
|
||||
},
|
||||
wantFindErr: false,
|
||||
wantValidateErr: false,
|
||||
validateResult: func(t *testing.T, pak *types.PreAuthKey) {
|
||||
t.Helper()
|
||||
|
||||
assert.Equal(t, user.ID, *pak.UserID)
|
||||
assert.NotEmpty(t, pak.Key) // Legacy keys have Key populated
|
||||
assert.Empty(t, pak.Prefix) // Legacy keys have empty Prefix
|
||||
assert.Nil(t, pak.Hash) // Legacy keys have nil Hash
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "new_key_bcrypt",
|
||||
setupKey: func() string {
|
||||
// Create new key via API
|
||||
keyStr, err := db.CreatePreAuthKey(
|
||||
user.TypedID(),
|
||||
true, false, nil, []string{"tag:test"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return keyStr.Key
|
||||
},
|
||||
wantFindErr: false,
|
||||
wantValidateErr: false,
|
||||
validateResult: func(t *testing.T, pak *types.PreAuthKey) {
|
||||
t.Helper()
|
||||
|
||||
assert.Equal(t, user.ID, *pak.UserID)
|
||||
assert.Empty(t, pak.Key) // New keys have empty Key
|
||||
assert.NotEmpty(t, pak.Prefix) // New keys have Prefix
|
||||
assert.NotNil(t, pak.Hash) // New keys have Hash
|
||||
assert.Len(t, pak.Prefix, 12) // Prefix is 12 chars
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "new_key_format_validation",
|
||||
setupKey: func() string {
|
||||
keyStr, err := db.CreatePreAuthKey(
|
||||
user.TypedID(),
|
||||
true, false, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify format: hskey-auth-{12-char-prefix}-{64-char-hash}
|
||||
// Use fixed-length parsing since prefix/hash can contain dashes (base64 URL-safe)
|
||||
assert.True(t, strings.HasPrefix(keyStr.Key, "hskey-auth-"))
|
||||
|
||||
// Extract prefix and hash using fixed-length parsing like the real code does
|
||||
_, prefixAndHash, found := strings.Cut(keyStr.Key, "hskey-auth-")
|
||||
assert.True(t, found)
|
||||
assert.GreaterOrEqual(t, len(prefixAndHash), 12+1+64) // prefix + '-' + hash minimum
|
||||
|
||||
prefix := prefixAndHash[:12]
|
||||
assert.Len(t, prefix, 12) // Prefix is 12 chars
|
||||
assert.Equal(t, byte('-'), prefixAndHash[12]) // Separator
|
||||
hash := prefixAndHash[13:]
|
||||
assert.Len(t, hash, 64) // Hash is 64 chars
|
||||
|
||||
return keyStr.Key
|
||||
},
|
||||
wantFindErr: false,
|
||||
wantValidateErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid_bcrypt_hash",
|
||||
setupKey: func() string {
|
||||
// Create valid key
|
||||
key, err := db.CreatePreAuthKey(
|
||||
user.TypedID(),
|
||||
true, false, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
keyStr := key.Key
|
||||
|
||||
// Return key with tampered hash using fixed-length parsing
|
||||
_, prefixAndHash, _ := strings.Cut(keyStr, "hskey-auth-")
|
||||
prefix := prefixAndHash[:12]
|
||||
|
||||
return "hskey-auth-" + prefix + "-" + "wrong_hash_here_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
},
|
||||
wantFindErr: true,
|
||||
wantValidateErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty_key",
|
||||
setupKey: func() string {
|
||||
return ""
|
||||
},
|
||||
wantFindErr: true,
|
||||
wantValidateErr: false,
|
||||
},
|
||||
{
|
||||
name: "key_too_short",
|
||||
setupKey: func() string {
|
||||
return "hskey-auth-short"
|
||||
},
|
||||
wantFindErr: true,
|
||||
wantValidateErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing_separator",
|
||||
setupKey: func() string {
|
||||
return "hskey-auth-ABCDEFGHIJKLabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
},
|
||||
wantFindErr: true,
|
||||
wantValidateErr: false,
|
||||
},
|
||||
{
|
||||
name: "hash_too_short",
|
||||
setupKey: func() string {
|
||||
return "hskey-auth-ABCDEFGHIJKL-short"
|
||||
},
|
||||
wantFindErr: true,
|
||||
wantValidateErr: false,
|
||||
},
|
||||
{
|
||||
name: "prefix_with_invalid_chars",
|
||||
setupKey: func() string {
|
||||
return "hskey-auth-ABC$EF@HIJKL-" + strings.Repeat("a", 64)
|
||||
},
|
||||
wantFindErr: true,
|
||||
wantValidateErr: false,
|
||||
},
|
||||
{
|
||||
name: "hash_with_invalid_chars",
|
||||
setupKey: func() string {
|
||||
return "hskey-auth-ABCDEFGHIJKL-" + "invalid$chars" + strings.Repeat("a", 54)
|
||||
},
|
||||
wantFindErr: true,
|
||||
wantValidateErr: false,
|
||||
},
|
||||
{
|
||||
name: "prefix_not_found_in_db",
|
||||
setupKey: func() string {
|
||||
// Create a validly formatted key but with a prefix that doesn't exist
|
||||
return "hskey-auth-NotInDB12345-" + strings.Repeat("a", 64)
|
||||
},
|
||||
wantFindErr: true,
|
||||
wantValidateErr: false,
|
||||
},
|
||||
{
|
||||
name: "expired_legacy_key",
|
||||
setupKey: func() string {
|
||||
legacyKey := "expired_legacy_key_123456789012345678901234"
|
||||
now := time.Now()
|
||||
expiration := time.Now().Add(-1 * time.Hour) // Expired 1 hour ago
|
||||
|
||||
// Use raw SQL to avoid UNIQUE constraint on empty prefix
|
||||
err := db.DB.Exec(`
|
||||
INSERT INTO pre_auth_keys (key, user_id, reusable, ephemeral, used, created_at, expiration)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, legacyKey, user.ID, true, false, false, now, expiration).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
return legacyKey
|
||||
},
|
||||
wantFindErr: false,
|
||||
wantValidateErr: true,
|
||||
},
|
||||
{
|
||||
name: "used_single_use_legacy_key",
|
||||
setupKey: func() string {
|
||||
legacyKey := "used_legacy_key_123456789012345678901234567"
|
||||
now := time.Now()
|
||||
|
||||
// Use raw SQL to avoid UNIQUE constraint on empty prefix
|
||||
err := db.DB.Exec(`
|
||||
INSERT INTO pre_auth_keys (key, user_id, reusable, ephemeral, used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, legacyKey, user.ID, false, false, true, now).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
return legacyKey
|
||||
},
|
||||
wantFindErr: false,
|
||||
wantValidateErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
keyStr := tt.setupKey()
|
||||
|
||||
pak, err := db.GetPreAuthKey(keyStr)
|
||||
|
||||
if tt.wantFindErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pak)
|
||||
|
||||
// Check validation if needed
|
||||
if tt.wantValidateErr {
|
||||
err := pak.Validate()
|
||||
assert.Error(t, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if tt.validateResult != nil {
|
||||
tt.validateResult(t, pak)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleLegacyKeysAllowed(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
user, err := db.CreateUser(types.User{Name: "test-legacy"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create multiple legacy keys by directly inserting with empty prefix
|
||||
// This simulates the migration scenario where existing databases have multiple
|
||||
// plaintext keys without prefix/hash fields
|
||||
now := time.Now()
|
||||
|
||||
for i := range 5 {
|
||||
legacyKey := fmt.Sprintf("legacy_key_%d_%s", i, strings.Repeat("x", 40))
|
||||
|
||||
err := db.DB.Exec(`
|
||||
INSERT INTO pre_auth_keys (key, prefix, hash, user_id, reusable, ephemeral, used, created_at)
|
||||
VALUES (?, '', NULL, ?, ?, ?, ?, ?)
|
||||
`, legacyKey, user.ID, true, false, false, now).Error
|
||||
require.NoError(t, err, "should allow multiple legacy keys with empty prefix")
|
||||
}
|
||||
|
||||
// Verify all legacy keys can be retrieved
|
||||
var legacyKeys []types.PreAuthKey
|
||||
|
||||
err = db.DB.Where("prefix = '' OR prefix IS NULL").Find(&legacyKeys).Error
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, legacyKeys, 5, "should have created 5 legacy keys")
|
||||
|
||||
// Now create new bcrypt-based keys - these should have unique prefixes
|
||||
key1, err := db.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, key1.Key)
|
||||
|
||||
key2, err := db.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, key2.Key)
|
||||
|
||||
// Verify the new keys have different prefixes
|
||||
pak1, err := db.GetPreAuthKey(key1.Key)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, pak1.Prefix)
|
||||
|
||||
pak2, err := db.GetPreAuthKey(key2.Key)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, pak2.Prefix)
|
||||
|
||||
assert.NotEqual(t, pak1.Prefix, pak2.Prefix, "new keys should have unique prefixes")
|
||||
|
||||
// Verify we cannot manually insert duplicate non-empty prefixes
|
||||
duplicatePrefix := "test_prefix1"
|
||||
hash1 := []byte("hash1")
|
||||
hash2 := []byte("hash2")
|
||||
|
||||
// First insert should succeed
|
||||
err = db.DB.Exec(`
|
||||
INSERT INTO pre_auth_keys (key, prefix, hash, user_id, reusable, ephemeral, used, created_at)
|
||||
VALUES ('', ?, ?, ?, ?, ?, ?, ?)
|
||||
`, duplicatePrefix, hash1, user.ID, true, false, false, now).Error
|
||||
require.NoError(t, err, "first key with prefix should succeed")
|
||||
|
||||
// Second insert with same prefix should fail
|
||||
err = db.DB.Exec(`
|
||||
INSERT INTO pre_auth_keys (key, prefix, hash, user_id, reusable, ephemeral, used, created_at)
|
||||
VALUES ('', ?, ?, ?, ?, ?, ?, ?)
|
||||
`, duplicatePrefix, hash2, user.ID, true, false, false, now).Error
|
||||
require.Error(t, err, "duplicate non-empty prefix should be rejected")
|
||||
assert.Contains(t, err.Error(), "UNIQUE constraint failed", "should fail with UNIQUE constraint error")
|
||||
}
|
||||
|
||||
@@ -34,20 +34,15 @@ CREATE INDEX idx_users_deleted_at ON users(deleted_at);
|
||||
-- - Cannot create another local user "alice" (blocked by idx_name_no_provider_identifier)
|
||||
-- - Cannot create another user with provider_identifier="alice_github" (blocked by idx_provider_identifier)
|
||||
-- - Cannot create user "bob" with provider_identifier="alice_github" (blocked by idx_name_provider_identifier)
|
||||
CREATE UNIQUE INDEX idx_provider_identifier ON users(
|
||||
provider_identifier
|
||||
) WHERE provider_identifier IS NOT NULL;
|
||||
CREATE UNIQUE INDEX idx_name_provider_identifier ON users(
|
||||
name,
|
||||
provider_identifier
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(
|
||||
name
|
||||
) WHERE provider_identifier IS NULL;
|
||||
CREATE UNIQUE INDEX idx_provider_identifier ON users(provider_identifier) WHERE provider_identifier IS NOT NULL;
|
||||
CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier);
|
||||
CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL;
|
||||
|
||||
CREATE TABLE pre_auth_keys(
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
key text,
|
||||
prefix text,
|
||||
hash blob,
|
||||
user_id integer,
|
||||
reusable numeric,
|
||||
ephemeral numeric DEFAULT false,
|
||||
@@ -59,6 +54,7 @@ CREATE TABLE pre_auth_keys(
|
||||
|
||||
CONSTRAINT fk_pre_auth_keys_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != '';
|
||||
|
||||
CREATE TABLE api_keys(
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -85,7 +81,7 @@ CREATE TABLE nodes(
|
||||
given_name varchar(63),
|
||||
user_id integer,
|
||||
register_method text,
|
||||
forced_tags text,
|
||||
tags text,
|
||||
auth_key_id integer,
|
||||
last_seen datetime,
|
||||
expiry datetime,
|
||||
|
||||
@@ -16,6 +16,7 @@ var (
|
||||
ErrInvalidAutoVacuum = errors.New("invalid auto_vacuum")
|
||||
ErrWALAutocheckpoint = errors.New("wal_autocheckpoint must be >= -1")
|
||||
ErrInvalidSynchronous = errors.New("invalid synchronous")
|
||||
ErrInvalidTxLock = errors.New("invalid txlock")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -225,6 +226,62 @@ func (s Synchronous) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
// TxLock represents SQLite transaction lock mode.
|
||||
// Transaction lock mode determines when write locks are acquired during transactions.
|
||||
//
|
||||
// Lock Acquisition Behavior:
|
||||
//
|
||||
// DEFERRED - SQLite default, acquire lock lazily:
|
||||
// - Transaction starts without any lock
|
||||
// - First read acquires SHARED lock
|
||||
// - First write attempts to upgrade to RESERVED lock
|
||||
// - If another transaction holds RESERVED: SQLITE_BUSY (potential deadlock)
|
||||
// - Can cause deadlocks when multiple connections attempt concurrent writes
|
||||
//
|
||||
// IMMEDIATE - Recommended for write-heavy workloads:
|
||||
// - Transaction immediately acquires RESERVED lock at BEGIN
|
||||
// - If lock unavailable, waits up to busy_timeout before failing
|
||||
// - Other writers queue orderly instead of deadlocking
|
||||
// - Prevents the upgrade-lock deadlock scenario
|
||||
// - Slight overhead for read-only transactions that don't need locks
|
||||
//
|
||||
// EXCLUSIVE - Maximum isolation:
|
||||
// - Transaction immediately acquires EXCLUSIVE lock at BEGIN
|
||||
// - No other connections can read or write
|
||||
// - Highest isolation but lowest concurrency
|
||||
// - Rarely needed in practice
|
||||
type TxLock string
|
||||
|
||||
const (
|
||||
// TxLockDeferred acquires locks lazily (SQLite default).
|
||||
// Risk of SQLITE_BUSY deadlocks with concurrent writers. Use for read-heavy workloads.
|
||||
TxLockDeferred TxLock = "deferred"
|
||||
|
||||
// TxLockImmediate acquires write lock immediately (RECOMMENDED for production).
|
||||
// Prevents deadlocks by acquiring RESERVED lock at transaction start.
|
||||
// Writers queue orderly, respecting busy_timeout.
|
||||
TxLockImmediate TxLock = "immediate"
|
||||
|
||||
// TxLockExclusive acquires exclusive lock immediately.
|
||||
// Maximum isolation, no concurrent reads or writes. Rarely needed.
|
||||
TxLockExclusive TxLock = "exclusive"
|
||||
)
|
||||
|
||||
// IsValid returns true if the TxLock is valid.
|
||||
func (t TxLock) IsValid() bool {
|
||||
switch t {
|
||||
case TxLockDeferred, TxLockImmediate, TxLockExclusive, "":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the string representation.
|
||||
func (t TxLock) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
// Config holds SQLite database configuration with type-safe enums.
|
||||
// This configuration balances performance, durability, and operational requirements
|
||||
// for Headscale's SQLite database usage patterns.
|
||||
@@ -236,6 +293,7 @@ type Config struct {
|
||||
WALAutocheckpoint int // pages (-1 = default/not set, 0 = disabled, >0 = enabled)
|
||||
Synchronous Synchronous // synchronous mode (affects durability vs performance)
|
||||
ForeignKeys bool // enable foreign key constraints (data integrity)
|
||||
TxLock TxLock // transaction lock mode (affects write concurrency)
|
||||
}
|
||||
|
||||
// Default returns the production configuration optimized for Headscale's usage patterns.
|
||||
@@ -244,6 +302,7 @@ type Config struct {
|
||||
// - Data durability with good performance (NORMAL synchronous)
|
||||
// - Automatic space management (INCREMENTAL auto-vacuum)
|
||||
// - Data integrity (foreign key constraints enabled)
|
||||
// - Safe concurrent writes (IMMEDIATE transaction lock)
|
||||
// - Reasonable timeout for busy database scenarios (10s)
|
||||
func Default(path string) *Config {
|
||||
return &Config{
|
||||
@@ -254,6 +313,7 @@ func Default(path string) *Config {
|
||||
WALAutocheckpoint: 1000,
|
||||
Synchronous: SynchronousNormal,
|
||||
ForeignKeys: true,
|
||||
TxLock: TxLockImmediate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +352,10 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidSynchronous, c.Synchronous)
|
||||
}
|
||||
|
||||
if c.TxLock != "" && !c.TxLock.IsValid() {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidTxLock, c.TxLock)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -332,12 +396,20 @@ func (c *Config) ToURL() (string, error) {
|
||||
baseURL = "file:" + c.Path
|
||||
}
|
||||
|
||||
// Add parameters without encoding = signs
|
||||
if len(pragmas) > 0 {
|
||||
var queryParts []string
|
||||
for _, pragma := range pragmas {
|
||||
queryParts = append(queryParts, "_pragma="+pragma)
|
||||
}
|
||||
// Build query parameters
|
||||
queryParts := make([]string, 0, 1+len(pragmas))
|
||||
|
||||
// Add _txlock first (it's a connection parameter, not a pragma)
|
||||
if c.TxLock != "" {
|
||||
queryParts = append(queryParts, "_txlock="+string(c.TxLock))
|
||||
}
|
||||
|
||||
// Add pragma parameters
|
||||
for _, pragma := range pragmas {
|
||||
queryParts = append(queryParts, "_pragma="+pragma)
|
||||
}
|
||||
|
||||
if len(queryParts) > 0 {
|
||||
baseURL += "?" + strings.Join(queryParts, "&")
|
||||
}
|
||||
|
||||
|
||||