mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-22 07:50:43 +09:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6082cb9f6 | |||
| 0c577301c6 | |||
| 1e20972db0 | |||
| c0fd9b60ed | |||
| d3a6afdbf0 | |||
| 800fed8d50 | |||
| 818a8eb8e3 | |||
| 9e96adfa49 | |||
| 9f5e49d3ee | |||
| d1bd6e5b88 | |||
| f213a8c22a | |||
| 6a8a0b4b14 | |||
| 2469c3eb80 | |||
| 3065b45310 | |||
| d77ce3582d | |||
| bbea92033c | |||
| ac8f9c7ecc | |||
| 6e5508444f | |||
| 8754ed10dc | |||
| db8ac49680 | |||
| 8a3a4af03d | |||
| bbcbcd3ea5 | |||
| 85a03db016 | |||
| 34fd7d00ea | |||
| 232a9c8f9a | |||
| ea85dcbc14 | |||
| 45d5544736 |
@@ -0,0 +1,870 @@
|
||||
---
|
||||
name: headscale-integration-tester
|
||||
description: Use this agent when you need to execute, analyze, or troubleshoot Headscale integration tests. This includes running specific test scenarios, investigating test failures, interpreting test artifacts, validating end-to-end functionality, or ensuring integration test quality before releases. Examples: <example>Context: User has made changes to the route management code and wants to validate the changes work correctly. user: 'I've updated the route advertisement logic in poll.go. Can you run the relevant integration tests to make sure everything still works?' assistant: 'I'll use the headscale-integration-tester agent to run the subnet routing integration tests and analyze the results.' <commentary>Since the user wants to validate route-related changes with integration tests, use the headscale-integration-tester agent to execute the appropriate tests and analyze results.</commentary></example> <example>Context: A CI pipeline integration test is failing and the user needs help understanding why. user: 'The TestSubnetRouterMultiNetwork test is failing in CI. The logs show some timing issues but I can't figure out what's wrong.' assistant: 'Let me use the headscale-integration-tester agent to analyze the test failure and examine the artifacts.' <commentary>Since this involves analyzing integration test failures and interpreting test artifacts, use the headscale-integration-tester agent to investigate the issue.</commentary></example>
|
||||
color: green
|
||||
---
|
||||
|
||||
You are a specialist Quality Assurance Engineer with deep expertise in Headscale's integration testing system. You understand the Docker-based test infrastructure, real Tailscale client interactions, and the complex timing considerations involved in end-to-end network testing.
|
||||
|
||||
## Integration Test System Overview
|
||||
|
||||
The Headscale integration test system uses Docker containers running real Tailscale clients against a Headscale server. Tests validate end-to-end functionality including routing, ACLs, node lifecycle, and network coordination. The system is built around the `hi` (Headscale Integration) test runner in `cmd/hi/`.
|
||||
|
||||
## Critical Test Execution Knowledge
|
||||
|
||||
### System Requirements and Setup
|
||||
```bash
|
||||
# ALWAYS run this first to verify system readiness
|
||||
go run ./cmd/hi doctor
|
||||
```
|
||||
This command verifies:
|
||||
- Docker installation and daemon status
|
||||
- Go environment setup
|
||||
- Required container images availability
|
||||
- Sufficient disk space (critical - tests generate ~100MB logs per run)
|
||||
- Network configuration
|
||||
|
||||
### Test Execution Patterns
|
||||
|
||||
**CRITICAL TIMEOUT REQUIREMENTS**:
|
||||
- **NEVER use bash `timeout` command** - this can cause test failures and incomplete cleanup
|
||||
- **ALWAYS use the built-in `--timeout` flag** with generous timeouts (minimum 15 minutes)
|
||||
- **Increase timeout if tests ever time out** - infrastructure issues require longer timeouts
|
||||
|
||||
```bash
|
||||
# Single test execution (recommended for development)
|
||||
# ALWAYS use --timeout flag with minimum 15 minutes (900s)
|
||||
go run ./cmd/hi run "TestSubnetRouterMultiNetwork" --timeout=900s
|
||||
|
||||
# Database-heavy tests require PostgreSQL backend and longer timeouts
|
||||
go run ./cmd/hi run "TestExpireNode" --postgres --timeout=1800s
|
||||
|
||||
# Pattern matching for related tests - use longer timeout for multiple tests
|
||||
go run ./cmd/hi run "TestSubnet*" --timeout=1800s
|
||||
|
||||
# Long-running individual tests need extended timeouts
|
||||
go run ./cmd/hi run "TestNodeOnlineStatus" --timeout=2100s # Runs for 12+ minutes
|
||||
|
||||
# Full test suite (CI/validation only) - very long timeout required
|
||||
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)
|
||||
- **Long-running tests**: `--timeout=2100s` (35 minutes)
|
||||
- **Full test suite**: `-timeout 45m` (45 minutes)
|
||||
|
||||
**NEVER do this**:
|
||||
```bash
|
||||
# ❌ FORBIDDEN: Never use bash timeout command
|
||||
timeout 300 go run ./cmd/hi run "TestName"
|
||||
|
||||
# ❌ FORBIDDEN: Too short timeout will cause failures
|
||||
go run ./cmd/hi run "TestName" --timeout=60s
|
||||
```
|
||||
|
||||
### Test Categories and Timing Expectations
|
||||
- **Fast tests** (<2 min): Basic functionality, CLI operations
|
||||
- **Medium tests** (2-5 min): Route management, ACL validation
|
||||
- **Slow tests** (5+ min): Node expiration, HA failover
|
||||
- **Long-running tests** (10+ min): `TestNodeOnlineStatus` runs for 12 minutes
|
||||
|
||||
**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
|
||||
|
||||
### Artifact Structure
|
||||
All test runs save comprehensive artifacts to `control_logs/TIMESTAMP-ID/`:
|
||||
```
|
||||
control_logs/20250713-213106-iajsux/
|
||||
├── hs-testname-abc123.stderr.log # Headscale server error logs
|
||||
├── hs-testname-abc123.stdout.log # Headscale server output logs
|
||||
├── hs-testname-abc123.db # Database snapshot for post-mortem
|
||||
├── hs-testname-abc123_metrics.txt # Prometheus metrics dump
|
||||
├── hs-testname-abc123-mapresponses/ # Protocol-level debug data
|
||||
├── ts-client-xyz789.stderr.log # Tailscale client error logs
|
||||
├── ts-client-xyz789.stdout.log # Tailscale client output logs
|
||||
└── ts-client-xyz789_status.json # Client network status dump
|
||||
```
|
||||
|
||||
### Log Analysis Priority Order
|
||||
When tests fail, examine artifacts in this specific order:
|
||||
|
||||
1. **Headscale server stderr logs** (`hs-*.stderr.log`): Look for errors, panics, database issues, policy evaluation failures
|
||||
2. **Tailscale client stderr logs** (`ts-*.stderr.log`): Check for authentication failures, network connectivity issues
|
||||
3. **MapResponse JSON files**: Protocol-level debugging for network map generation issues
|
||||
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
|
||||
|
||||
**⚠️ IMPORTANT**: When tests fail, it is ALMOST ALWAYS a code issue with Headscale, NOT infrastructure problems. Do not immediately blame disk space, Docker issues, or timing unless you have thoroughly investigated the actual error logs first.
|
||||
|
||||
### Systematic Debugging Process
|
||||
|
||||
1. **Read the actual error message**: Don't assume - read the stderr logs completely
|
||||
2. **Check Headscale server logs first**: Most issues originate from server-side logic
|
||||
3. **Verify client connectivity**: Only after ruling out server issues
|
||||
4. **Check timing patterns**: Use proper `EventuallyWithT` patterns
|
||||
5. **Infrastructure as last resort**: Only blame infrastructure after code analysis
|
||||
|
||||
### Real Failure Patterns
|
||||
|
||||
#### 1. Timing Issues (Common but fixable)
|
||||
```go
|
||||
// ❌ Wrong: Immediate assertions after async operations
|
||||
client.Execute([]string{"tailscale", "set", "--advertise-routes=10.0.0.0/24"})
|
||||
nodes, _ := headscale.ListNodes()
|
||||
require.Len(t, nodes[0].GetAvailableRoutes(), 1) // WILL FAIL
|
||||
|
||||
// ✅ Correct: Wait for async operations
|
||||
client.Execute([]string{"tailscale", "set", "--advertise-routes=10.0.0.0/24"})
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
assert.Len(c, nodes[0].GetAvailableRoutes(), 1)
|
||||
}, 10*time.Second, 100*time.Millisecond, "route should be advertised")
|
||||
```
|
||||
|
||||
**Timeout Guidelines**:
|
||||
- Route operations: 3-5 seconds
|
||||
- Node state changes: 5-10 seconds
|
||||
- Complex scenarios: 10-15 seconds
|
||||
- Policy recalculation: 5-10 seconds
|
||||
|
||||
#### 2. NodeStore Synchronization Issues
|
||||
Route advertisements must propagate through poll requests (`poll.go:420`). NodeStore updates happen at specific synchronization points after Hostinfo changes.
|
||||
|
||||
#### 3. Test Data Management Issues
|
||||
```go
|
||||
// ❌ Wrong: Assuming array ordering
|
||||
require.Len(t, nodes[0].GetAvailableRoutes(), 1)
|
||||
|
||||
// ✅ Correct: Identify nodes by properties
|
||||
expectedRoutes := map[string]string{"1": "10.33.0.0/16"}
|
||||
for _, node := range nodes {
|
||||
nodeIDStr := fmt.Sprintf("%d", node.GetId())
|
||||
if route, shouldHaveRoute := expectedRoutes[nodeIDStr]; shouldHaveRoute {
|
||||
// Test the specific node that should have the route
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Database Backend Differences
|
||||
SQLite vs PostgreSQL have different timing characteristics:
|
||||
- Use `--postgres` flag for database-intensive tests
|
||||
- PostgreSQL generally has more consistent timing
|
||||
- Some race conditions only appear with specific backends
|
||||
|
||||
## Resource Management and Cleanup
|
||||
|
||||
### Disk Space Management
|
||||
Tests consume significant disk space (~100MB per run):
|
||||
```bash
|
||||
# Check available space before running tests
|
||||
df -h
|
||||
|
||||
# Clean up test artifacts periodically
|
||||
rm -rf control_logs/older-timestamp-dirs/
|
||||
|
||||
# Clean Docker resources
|
||||
docker system prune -f
|
||||
docker volume prune -f
|
||||
```
|
||||
|
||||
### Container Cleanup
|
||||
- Successful tests clean up automatically
|
||||
- Failed tests may leave containers running
|
||||
- Manually clean if needed: `docker ps -a` and `docker rm -f <containers>`
|
||||
|
||||
## Advanced Debugging Techniques
|
||||
|
||||
### Protocol-Level Debugging
|
||||
MapResponse JSON files in `control_logs/*/hs-*-mapresponses/` contain:
|
||||
- Network topology as sent to clients
|
||||
- Peer relationships and visibility
|
||||
- Route distribution and primary route selection
|
||||
- Policy evaluation results
|
||||
|
||||
### Database State Analysis
|
||||
Use the database snapshots for post-mortem analysis:
|
||||
```bash
|
||||
# SQLite examination
|
||||
sqlite3 control_logs/TIMESTAMP/hs-*.db
|
||||
.tables
|
||||
.schema nodes
|
||||
SELECT * FROM nodes WHERE name LIKE '%problematic%';
|
||||
```
|
||||
|
||||
### Performance Analysis
|
||||
Prometheus metrics dumps show:
|
||||
- Request latencies and error rates
|
||||
- NodeStore operation timing
|
||||
- Database query performance
|
||||
- Memory usage patterns
|
||||
|
||||
## Test Development and Quality Guidelines
|
||||
|
||||
### Proper Test Patterns
|
||||
```go
|
||||
// Always use EventuallyWithT for async operations
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
// Test condition that may take time to become true
|
||||
}, timeout, interval, "descriptive failure message")
|
||||
|
||||
// Handle node identification correctly
|
||||
var targetNode *v1.Node
|
||||
for _, node := range nodes {
|
||||
if node.GetName() == expectedNodeName {
|
||||
targetNode = node
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, targetNode, "should find expected node")
|
||||
```
|
||||
|
||||
### Quality Validation Checklist
|
||||
- ✅ Tests use `EventuallyWithT` for asynchronous operations
|
||||
- ✅ Tests don't rely on array ordering for node identification
|
||||
- ✅ Proper cleanup and resource management
|
||||
- ✅ Tests handle both success and failure scenarios
|
||||
- ✅ Timing assumptions are realistic for operations being tested
|
||||
- ✅ Error messages are descriptive and actionable
|
||||
|
||||
## Real-World Test Failure Patterns from HA Debugging
|
||||
|
||||
### Infrastructure vs Code Issues - Detailed Examples
|
||||
|
||||
**INFRASTRUCTURE FAILURES (Rare but Real)**:
|
||||
1. **DNS Resolution in Auth Tests**: `failed to resolve "hs-pingallbyip-jax97k": no DNS fallback candidates remain`
|
||||
- **Pattern**: Client containers can't resolve headscale server hostname during logout
|
||||
- **Detection**: Error messages specifically mention DNS/hostname resolution
|
||||
- **Solution**: Docker networking reset, not code changes
|
||||
|
||||
2. **Container Creation Timeouts**: Test gets stuck during client container setup
|
||||
- **Pattern**: Tests hang indefinitely at container startup phase
|
||||
- **Detection**: No progress in logs for >2 minutes during initialization
|
||||
- **Solution**: `docker system prune -f` and retry
|
||||
|
||||
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
|
||||
- **Pattern**: Tests expecting approved routes but finding none
|
||||
- **Detection**: `SubnetRoutes()` returns empty when `AnnouncedRoutes()` shows routes
|
||||
- **Root Cause**: Auto-approval logic bugs, policy evaluation issues
|
||||
|
||||
2. **NodeStore Synchronization Issues**: State updates not propagating correctly
|
||||
- **Pattern**: Route changes not reflected in NodeStore or Primary Routes
|
||||
- **Detection**: Logs show route announcements but no tracking updates
|
||||
- **Root Cause**: Missing synchronization points in `poll.go:420` area
|
||||
|
||||
3. **HA Failover Architecture Issues**: Routes removed when nodes go offline
|
||||
- **Pattern**: `TestHASubnetRouterFailover` fails because approved routes disappear
|
||||
- **Detection**: Routes available on online nodes but lost when nodes disconnect
|
||||
- **Root Cause**: Conflating route approval with node connectivity
|
||||
|
||||
### Critical Test Environment Setup
|
||||
|
||||
**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
|
||||
# Only clean old log directories if disk space is low
|
||||
rm -rf control_logs/202507*
|
||||
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**:
|
||||
```bash
|
||||
# Verify system readiness
|
||||
go run ./cmd/hi doctor
|
||||
|
||||
# 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
|
||||
|
||||
#### Route-Related Tests (Primary Focus)
|
||||
```bash
|
||||
# Core route functionality - these should work first
|
||||
# Note: Generous timeouts are required for reliable execution
|
||||
go run ./cmd/hi run "TestSubnetRouteACL" --timeout=1200s
|
||||
go run ./cmd/hi run "TestAutoApproveMultiNetwork" --timeout=1800s
|
||||
go run ./cmd/hi run "TestHASubnetRouterFailover" --timeout=1800s
|
||||
```
|
||||
|
||||
**Common Route Test Patterns**:
|
||||
- Tests validate route announcement, approval, and distribution workflows
|
||||
- Route state changes are asynchronous - may need `EventuallyWithT` wrappers
|
||||
- Route approval must respect ACL policies - test expectations encode security requirements
|
||||
- HA tests verify route persistence during node connectivity changes
|
||||
|
||||
#### Authentication Tests (Infrastructure-Prone)
|
||||
```bash
|
||||
# These tests are more prone to infrastructure issues
|
||||
# Require longer timeouts due to auth flow complexity
|
||||
go run ./cmd/hi run "TestAuthKeyLogoutAndReloginSameUser" --timeout=1200s
|
||||
go run ./cmd/hi run "TestAuthWebFlowLogoutAndRelogin" --timeout=1200s
|
||||
go run ./cmd/hi run "TestOIDCExpireNodesBasedOnTokenExpiry" --timeout=1800s
|
||||
```
|
||||
|
||||
**Common Auth Test Infrastructure Failures**:
|
||||
- DNS resolution during logout operations
|
||||
- Container creation timeouts
|
||||
- HTTP/2 stream errors (often symptoms, not root cause)
|
||||
|
||||
### Security-Critical Debugging Rules
|
||||
|
||||
**❌ FORBIDDEN CHANGES (Security & Test Integrity)**:
|
||||
1. **Never change expected test outputs** - Tests define correct behavior contracts
|
||||
- Changing `require.Len(t, routes, 3)` to `require.Len(t, routes, 2)` because test fails
|
||||
- Modifying expected status codes, node counts, or route counts
|
||||
- Removing assertions that are "inconvenient"
|
||||
- **Why forbidden**: Test expectations encode business requirements and security policies
|
||||
|
||||
2. **Never bypass security mechanisms** - Security must never be compromised for convenience
|
||||
- Using `AnnouncedRoutes()` instead of `SubnetRoutes()` in production code
|
||||
- Skipping authentication or authorization checks
|
||||
- **Why forbidden**: Security bypasses create vulnerabilities in production
|
||||
|
||||
3. **Never reduce test coverage** - Tests prevent regressions
|
||||
- Removing test cases or assertions
|
||||
- Commenting out "problematic" test sections
|
||||
- **Why forbidden**: Reduced coverage allows bugs to slip through
|
||||
|
||||
**✅ ALLOWED CHANGES (Timing & Observability)**:
|
||||
1. **Fix timing issues with proper async patterns**
|
||||
```go
|
||||
// ✅ GOOD: Add EventuallyWithT for async operations
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
assert.Len(c, nodes, expectedCount) // Keep original expectation
|
||||
}, 10*time.Second, 100*time.Millisecond, "nodes should reach expected count")
|
||||
```
|
||||
- **Why allowed**: Fixes race conditions without changing business logic
|
||||
|
||||
2. **Add MORE observability and debugging**
|
||||
- Additional logging statements
|
||||
- More detailed error messages
|
||||
- Extra assertions that verify intermediate states
|
||||
- **Why allowed**: Better observability helps debug without changing behavior
|
||||
|
||||
3. **Improve test documentation**
|
||||
- Add godoc comments explaining test purpose and business logic
|
||||
- Document timing requirements and async behavior
|
||||
- **Why encouraged**: Helps future maintainers understand intent
|
||||
|
||||
### Advanced Debugging Workflows
|
||||
|
||||
#### Route Tracking Debug Flow
|
||||
```bash
|
||||
# Run test with detailed logging and proper timeout
|
||||
go run ./cmd/hi run "TestSubnetRouteACL" --timeout=1200s > test_output.log 2>&1
|
||||
|
||||
# Check route approval process
|
||||
grep -E "(auto-approval|ApproveRoutesWithPolicy|PolicyManager)" test_output.log
|
||||
|
||||
# Check route tracking
|
||||
tail -50 control_logs/*/hs-*.stderr.log | grep -E "(announced|tracking|SetNodeRoutes)"
|
||||
|
||||
# Check for security violations
|
||||
grep -E "(AnnouncedRoutes.*SetNodeRoutes|bypass.*approval)" test_output.log
|
||||
```
|
||||
|
||||
#### HA Failover Debug Flow
|
||||
```bash
|
||||
# Test HA failover specifically with adequate timeout
|
||||
go run ./cmd/hi run "TestHASubnetRouterFailover" --timeout=1800s
|
||||
|
||||
# Check route persistence during disconnect
|
||||
grep -E "(Disconnect|NodeWentOffline|PrimaryRoutes)" control_logs/*/hs-*.stderr.log
|
||||
|
||||
# Verify routes don't disappear inappropriately
|
||||
grep -E "(removing.*routes|SetNodeRoutes.*empty)" control_logs/*/hs-*.stderr.log
|
||||
```
|
||||
|
||||
### Test Result Interpretation Guidelines
|
||||
|
||||
#### Success Patterns to Look For
|
||||
- `"updating node routes for tracking"` in logs
|
||||
- Routes appearing in `announcedRoutes` logs
|
||||
- Proper `ApproveRoutesWithPolicy` calls for auto-approval
|
||||
- Routes persisting through node connectivity changes (HA tests)
|
||||
|
||||
#### Failure Patterns to Investigate
|
||||
- `SubnetRoutes()` returning empty when `AnnouncedRoutes()` has routes
|
||||
- Routes disappearing when nodes go offline (HA architectural issue)
|
||||
- Missing `EventuallyWithT` causing timing race conditions
|
||||
- Security bypass attempts using wrong route methods
|
||||
|
||||
### Critical Testing Methodology
|
||||
|
||||
**Phase-Based Testing Approach**:
|
||||
1. **Phase 1**: Core route tests (ACL, auto-approval, basic functionality)
|
||||
2. **Phase 2**: HA and complex route scenarios
|
||||
3. **Phase 3**: Auth tests (infrastructure-sensitive, test last)
|
||||
|
||||
**Per-Test Process**:
|
||||
1. Clean environment before each test
|
||||
2. Monitor logs for route tracking and approval messages
|
||||
3. Check artifacts in `control_logs/` if test fails
|
||||
4. Focus on actual error messages, not assumptions
|
||||
5. Document results and patterns discovered
|
||||
|
||||
## Test Documentation and Code Quality Standards
|
||||
|
||||
### Adding Missing Test Documentation
|
||||
When you understand a test's purpose through debugging, always add comprehensive godoc:
|
||||
|
||||
```go
|
||||
// TestSubnetRoutes validates the complete subnet route lifecycle including
|
||||
// advertisement from clients, policy-based approval, and distribution to peers.
|
||||
// This test ensures that route security policies are properly enforced and that
|
||||
// only approved routes are distributed to the network.
|
||||
//
|
||||
// The test verifies:
|
||||
// - Route announcements are received and tracked
|
||||
// - 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) {
|
||||
// Test implementation...
|
||||
}
|
||||
```
|
||||
|
||||
**Why add documentation**: Future maintainers need to understand business logic and security requirements encoded in tests.
|
||||
|
||||
### Comment Guidelines - Focus on WHY, Not WHAT
|
||||
|
||||
```go
|
||||
// ✅ GOOD: Explains reasoning and business logic
|
||||
// Wait for route propagation because NodeStore updates are asynchronous
|
||||
// and happen after poll requests complete processing
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
// Check that security policies are enforced...
|
||||
}, timeout, interval, "route approval must respect ACL policies")
|
||||
|
||||
// ❌ BAD: Just describes what the code does
|
||||
// Wait for routes
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
// Get routes and check length
|
||||
}, timeout, interval, "checking routes")
|
||||
```
|
||||
|
||||
**Why focus on WHY**: Helps maintainers understand architectural decisions and security requirements.
|
||||
|
||||
## EventuallyWithT Pattern for External Calls
|
||||
|
||||
### 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
|
||||
|
||||
### 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: Single external call with related assertions
|
||||
var nodes []*v1.Node
|
||||
var err error
|
||||
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err = headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
assert.Len(c, nodes, 2)
|
||||
// These assertions are all based on the ListNodes() call
|
||||
requireNodeRouteCountWithCollect(c, nodes[0], 2, 2, 2)
|
||||
requireNodeRouteCountWithCollect(c, nodes[1], 1, 1, 1)
|
||||
}, 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)
|
||||
// All these assertions are based on the single Status() call
|
||||
for _, peerKey := range status.Peers() {
|
||||
peerStatus := status.Peer[peerKey]
|
||||
requirePeerSubnetRoutesWithCollect(c, peerStatus, expectedPrefixes)
|
||||
}
|
||||
}, 10*time.Second, 500*time.Millisecond, "client should see expected routes")
|
||||
|
||||
// CORRECT: Variable scoping for sharing between blocks
|
||||
var routeNode *v1.Node
|
||||
var nodeKey key.NodePublic
|
||||
|
||||
// First EventuallyWithT to get the node
|
||||
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
|
||||
nodeKey, _ = key.ParseNodePublicUntyped(mem.S(node.GetNodeKey()))
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.NotNil(c, routeNode, "should find router node")
|
||||
}, 10*time.Second, 100*time.Millisecond, "router node should exist")
|
||||
|
||||
// Second EventuallyWithT using the nodeKey from first block
|
||||
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)
|
||||
}, 10*time.Second, 100*time.Millisecond, "routes should be visible to client")
|
||||
```
|
||||
|
||||
### Incorrect Patterns to Avoid
|
||||
|
||||
```go
|
||||
// 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")
|
||||
|
||||
// INCORRECT: Nested EventuallyWithT
|
||||
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()
|
||||
assert.NotNil(c2, status)
|
||||
}, 5*time.Second, 100*time.Millisecond, "nested")
|
||||
}, 10*time.Second, 500*time.Millisecond, "outer")
|
||||
|
||||
// INCORRECT: Variable scoping error
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes() // This shadows outer 'nodes' variable
|
||||
assert.NoError(c, err)
|
||||
}, 10*time.Second, 500*time.Millisecond, "get nodes")
|
||||
|
||||
// This will fail - nodes is nil because := created a new variable inside the block
|
||||
require.Len(t, nodes, 2) // COMPILATION ERROR or nil pointer
|
||||
|
||||
// INCORRECT: Not wrapping external calls
|
||||
nodes, err := headscale.ListNodes() // External call not wrapped!
|
||||
require.NoError(t, err)
|
||||
```
|
||||
|
||||
### Helper Functions for EventuallyWithT
|
||||
|
||||
When creating helper functions for use within EventuallyWithT:
|
||||
|
||||
```go
|
||||
// Helper function that accepts CollectT
|
||||
func requireNodeRouteCountWithCollect(c *assert.CollectT, node *v1.Node, available, approved, primary int) {
|
||||
assert.Len(c, node.GetAvailableRoutes(), available, "available routes for node %s", node.GetName())
|
||||
assert.Len(c, node.GetApprovedRoutes(), approved, "approved routes for node %s", node.GetName())
|
||||
assert.Len(c, node.GetPrimaryRoutes(), primary, "primary routes for node %s", node.GetName())
|
||||
}
|
||||
|
||||
// Usage within EventuallyWithT
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
requireNodeRouteCountWithCollect(c, nodes[0], 2, 2, 2)
|
||||
}, 10*time.Second, 500*time.Millisecond, "route counts should match expected")
|
||||
```
|
||||
|
||||
### Operations That Must NOT Be Wrapped
|
||||
|
||||
**CRITICAL**: The following operations are **blocking/mutating operations** that change state and MUST NOT be wrapped in EventuallyWithT:
|
||||
- `tailscale set` commands (e.g., `--advertise-routes`, `--accept-routes`)
|
||||
- `headscale.ApproveRoute()` - Approves routes on server
|
||||
- `headscale.CreateUser()` - Creates users
|
||||
- `headscale.CreatePreAuthKey()` - Creates authentication keys
|
||||
- `headscale.RegisterNode()` - Registers new nodes
|
||||
- Any `client.Execute()` that modifies configuration
|
||||
- Any operation that creates, updates, or deletes resources
|
||||
|
||||
These operations:
|
||||
1. Complete synchronously or fail immediately
|
||||
2. Should not be retried automatically
|
||||
3. Need explicit error handling with `require.NoError()`
|
||||
|
||||
### Correct Pattern for Blocking Operations
|
||||
|
||||
```go
|
||||
// CORRECT: Blocking operation NOT wrapped
|
||||
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)
|
||||
|
||||
// Then wait for the result with EventuallyWithT
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
assert.Contains(c, nodes[0].GetAvailableRoutes(), expectedRoutes[string(status.Self.ID)])
|
||||
}, 10*time.Second, 100*time.Millisecond, "route should be advertised")
|
||||
|
||||
// INCORRECT: Blocking operation wrapped (DON'T DO THIS)
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
_, _, err = client.Execute([]string{"tailscale", "set", "--advertise-routes=10.0.0.0/24"})
|
||||
assert.NoError(c, err) // This might retry the command multiple times!
|
||||
}, 10*time.Second, 100*time.Millisecond, "advertise routes")
|
||||
```
|
||||
|
||||
### Assert vs Require Pattern
|
||||
|
||||
When working within EventuallyWithT blocks where you need to prevent panics:
|
||||
|
||||
```go
|
||||
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")
|
||||
assert.Contains(c,
|
||||
srs1PeerStatus.PrimaryRoutes.AsSlice(),
|
||||
pref,
|
||||
)
|
||||
}, 5*time.Second, 200*time.Millisecond, "checking route state")
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
### Common Scenarios
|
||||
|
||||
1. **Waiting for route advertisement**:
|
||||
```go
|
||||
client.Execute([]string{"tailscale", "set", "--advertise-routes=10.0.0.0/24"})
|
||||
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
assert.Contains(c, nodes[0].GetAvailableRoutes(), "10.0.0.0/24")
|
||||
}, 10*time.Second, 100*time.Millisecond, "route should be advertised")
|
||||
```
|
||||
|
||||
2. **Checking client sees routes**:
|
||||
```go
|
||||
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]
|
||||
assert.Contains(c, peerStatus.AllowedIPs, expectedPrefix)
|
||||
}
|
||||
}, 10*time.Second, 100*time.Millisecond, "all peers should see route")
|
||||
```
|
||||
|
||||
3. **Sequential operations**:
|
||||
```go
|
||||
// First wait for node to appear
|
||||
var nodeID uint64
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
assert.Len(c, nodes, 1)
|
||||
nodeID = nodes[0].GetId()
|
||||
}, 10*time.Second, 100*time.Millisecond, "node should register")
|
||||
|
||||
// Then perform operation
|
||||
_, err := headscale.ApproveRoute(nodeID, "10.0.0.0/24")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then wait for result
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
nodes, err := headscale.ListNodes()
|
||||
assert.NoError(c, err)
|
||||
assert.Contains(c, nodes[0].GetApprovedRoutes(), "10.0.0.0/24")
|
||||
}, 10*time.Second, 100*time.Millisecond, "route should be approved")
|
||||
```
|
||||
|
||||
## Your Core Responsibilities
|
||||
|
||||
1. **Test Execution Strategy**: Execute integration tests with appropriate configurations, understanding when to use `--postgres` and timing requirements for different test categories. Follow phase-based testing approach prioritizing route tests.
|
||||
- **Why this priority**: Route tests are less infrastructure-sensitive and validate core security logic
|
||||
|
||||
2. **Systematic Test Analysis**: When tests fail, systematically examine artifacts starting with Headscale server logs, then client logs, then protocol data. Focus on CODE ISSUES first (99% of cases), not infrastructure. Use real-world failure patterns to guide investigation.
|
||||
- **Why this approach**: Most failures are logic bugs, not environment issues - efficient debugging saves time
|
||||
|
||||
3. **Timing & Synchronization Expertise**: Understand asynchronous Headscale operations, particularly route advertisements, NodeStore synchronization at `poll.go:420`, and policy propagation. Fix timing with `EventuallyWithT` while preserving original test expectations.
|
||||
- **Why preserve expectations**: Test assertions encode business requirements and security policies
|
||||
- **Key Pattern**: Apply the EventuallyWithT pattern correctly for all external calls as documented above
|
||||
|
||||
4. **Root Cause Analysis**: Distinguish between actual code regressions (route approval logic, HA failover architecture), timing issues requiring `EventuallyWithT` patterns, and genuine infrastructure problems (DNS, Docker, container issues).
|
||||
- **Why this distinction matters**: Different problem types require completely different solution approaches
|
||||
- **EventuallyWithT Issues**: Often manifest as flaky tests or immediate assertion failures after async operations
|
||||
|
||||
5. **Security-Aware Quality Validation**: Ensure tests properly validate end-to-end functionality with realistic timing expectations and proper error handling. Never suggest security bypasses or test expectation changes. Add comprehensive godoc when you understand test business logic.
|
||||
- **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.
|
||||
@@ -66,7 +66,6 @@ func findTests() []string {
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--type", "go",
|
||||
"--regexp", "func (Test.+)\\(.*",
|
||||
"../../integration/",
|
||||
"--replace", "$1",
|
||||
|
||||
@@ -13,6 +13,8 @@ repos:
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=2000]
|
||||
exclude: ^hscontrol/policy/v2/testdata/
|
||||
- id: check-case-conflict
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-json
|
||||
|
||||
+1
-17
@@ -15,8 +15,7 @@ overall our implementation was very close.
|
||||
|
||||
SSH rules with `"action": "check"` are now supported. When a client initiates a SSH connection to a node
|
||||
with a `check` action policy, the user is prompted to authenticate via OIDC or CLI approval before access
|
||||
is granted. OIDC approval requires the authenticated user to own the source node; tagged source nodes
|
||||
cannot use SSH check-mode.
|
||||
is granted.
|
||||
|
||||
A new `headscale auth` CLI command group supports the approval flow:
|
||||
|
||||
@@ -25,7 +24,6 @@ A new `headscale auth` CLI command group supports the approval flow:
|
||||
- `headscale auth register --auth-id <id> --user <user>` registers a node (replaces deprecated `headscale nodes register`)
|
||||
|
||||
[#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
[#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
|
||||
### Grants
|
||||
|
||||
@@ -62,14 +60,6 @@ internet is a security-sensitive choice. `autogroup:danger-all` can only be used
|
||||
|
||||
### Changes
|
||||
|
||||
- **OIDC registration**: Add a confirmation page before completing node registration, showing the device hostname and machine key fingerprint [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- **Debug endpoints**: Omit secret fields (`Pass`, `ClientSecret`, `APIKey`) from `/debug/config` JSON output [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- **Debug endpoints**: Route `statsviz` through `tsweb.Protected` [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- Remove gRPC reflection from the remote (TCP) server [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- **Node Expiry**: Add `node.expiry` configuration option to set a default node key expiry for nodes registered via auth key [#3122](https://github.com/juanfont/headscale/pull/3122)
|
||||
- Tagged nodes (registered with tagged pre-auth keys) are exempt from default expiry
|
||||
- `oidc.expiry` has been removed; use `node.expiry` instead (applies to all registration methods including OIDC)
|
||||
- `ephemeral_node_inactivity_timeout` is deprecated in favour of `node.ephemeral.inactivity_timeout`
|
||||
- **SSH Policy**: Add support for `localpart:*@<domain>` in SSH rule `users` field, mapping each matching user's email local-part as their OS username [#3091](https://github.com/juanfont/headscale/pull/3091)
|
||||
- **ACL Policy**: Add ICMP and IPv6-ICMP protocols to default filter rules when no protocol is specified [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- **ACL Policy**: Fix autogroup:self handling for tagged nodes - tagged nodes no longer incorrectly receive autogroup:self filter rules [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
@@ -96,12 +86,6 @@ internet is a security-sensitive choice. `autogroup:danger-all` can only be used
|
||||
- Fix exit node approval not triggering filter rule recalculation for peers [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Policy validation error messages now include field context (e.g., `src=`, `dst=`) and are more descriptive [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
|
||||
## 0.28.1 (202x-xx-xx)
|
||||
|
||||
### Changes
|
||||
|
||||
- **User deletion**: Fix `DestroyUser` deleting all pre-auth keys in the database instead of only the target user's keys [#3155](https://github.com/juanfont/headscale/pull/3155)
|
||||
|
||||
## 0.28.0 (2026-02-04)
|
||||
|
||||
**Minimum supported Tailscale client version: v1.74.0**
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# For testing purposes only
|
||||
|
||||
FROM golang:1.26.2-alpine AS build-env
|
||||
FROM golang:1.26.1-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# This Dockerfile is more or less lifted from tailscale/tailscale
|
||||
# to ensure a similar build process when testing the HEAD of tailscale.
|
||||
|
||||
FROM golang:1.26.2-alpine AS build-env
|
||||
FROM golang:1.26.1-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ func bypassDatabase() (*db.HSDatabase, error) {
|
||||
return nil, fmt.Errorf("loading config: %w", err)
|
||||
}
|
||||
|
||||
d, err := db.NewHeadscaleDatabase(cfg)
|
||||
d, err := db.NewHeadscaleDatabase(cfg, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening database: %w", err)
|
||||
}
|
||||
|
||||
+5
-261
@@ -1,262 +1,6 @@
|
||||
# hi — Headscale Integration test runner
|
||||
# hi
|
||||
|
||||
`hi` wraps Docker container orchestration around the tests in
|
||||
[`../../integration`](../../integration) and extracts debugging artefacts
|
||||
(logs, database snapshots, MapResponse protocol captures) for post-mortem
|
||||
analysis.
|
||||
|
||||
**Read this file in full before running any `hi` command.** The test
|
||||
runner has sharp edges — wrong flags produce stale containers, lost
|
||||
artefacts, or hung CI.
|
||||
|
||||
For test-authoring patterns (scenario setup, `EventuallyWithT`,
|
||||
`IntegrationSkip`, helper variants), read
|
||||
[`../../integration/README.md`](../../integration/README.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Verify system requirements (Docker, Go, disk space, images)
|
||||
go run ./cmd/hi doctor
|
||||
|
||||
# Run a single test (the default flags are tuned for development)
|
||||
go run ./cmd/hi run "TestPingAllByIP"
|
||||
|
||||
# Run a database-heavy test against PostgreSQL
|
||||
go run ./cmd/hi run "TestExpireNode" --postgres
|
||||
|
||||
# Pattern matching
|
||||
go run ./cmd/hi run "TestSubnet*"
|
||||
```
|
||||
|
||||
Run `doctor` before the first `run` in any new environment. Tests
|
||||
generate ~100 MB of logs per run in `control_logs/`; `doctor` verifies
|
||||
there is enough space and that the required Docker images are available.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
| ------------------ | ---------------------------------------------------- |
|
||||
| `run [pattern]` | Execute the test(s) matching `pattern` |
|
||||
| `doctor` | Verify system requirements |
|
||||
| `clean networks` | Prune unused Docker networks |
|
||||
| `clean images` | Clean old test images |
|
||||
| `clean containers` | Kill **all** test containers (dangerous — see below) |
|
||||
| `clean cache` | Clean Go module cache volume |
|
||||
| `clean all` | Run all cleanup operations |
|
||||
|
||||
## Flags
|
||||
|
||||
Defaults are tuned for single-test development runs. Review before
|
||||
changing.
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
| ------------------- | -------------- | --------------------------------------------------------------------------- |
|
||||
| `--timeout` | `120m` | Total test timeout. Use the built-in flag — never wrap with bash `timeout`. |
|
||||
| `--postgres` | `false` | Use PostgreSQL instead of SQLite |
|
||||
| `--failfast` | `true` | Stop on first test failure |
|
||||
| `--go-version` | auto | Detected from `go.mod` (currently 1.26.1) |
|
||||
| `--clean-before` | `true` | Clean stale (stopped/exited) containers before starting |
|
||||
| `--clean-after` | `true` | Clean this run's containers after completion |
|
||||
| `--keep-on-failure` | `false` | Preserve containers for manual inspection on failure |
|
||||
| `--logs-dir` | `control_logs` | Where to save run artefacts |
|
||||
| `--verbose` | `false` | Verbose output |
|
||||
| `--stats` | `false` | Collect container resource-usage stats |
|
||||
| `--hs-memory-limit` | `0` | Fail if any headscale container exceeds N MB (0 = disabled) |
|
||||
| `--ts-memory-limit` | `0` | Fail if any tailscale container exceeds N MB |
|
||||
|
||||
### Timeout guidance
|
||||
|
||||
The default `120m` is generous for a single test. If you must tune it,
|
||||
these are realistic floors by category:
|
||||
|
||||
| Test type | Minimum | Examples |
|
||||
| ------------------------- | ----------- | ------------------------------------- |
|
||||
| Basic functionality / CLI | 900s (15m) | `TestPingAllByIP`, `TestCLI*` |
|
||||
| Route / ACL | 1200s (20m) | `TestSubnet*`, `TestACL*` |
|
||||
| HA / failover | 1800s (30m) | `TestHASubnetRouter*` |
|
||||
| Long-running | 2100s (35m) | `TestNodeOnlineStatus` (~12 min body) |
|
||||
| Full suite | 45m | `go test ./integration -timeout 45m` |
|
||||
|
||||
**Never** use the shell `timeout` command around `hi`. It kills the
|
||||
process mid-cleanup and leaves stale containers:
|
||||
|
||||
```bash
|
||||
timeout 300 go run ./cmd/hi run "TestName" # WRONG — orphaned containers
|
||||
go run ./cmd/hi run "TestName" --timeout=900s # correct
|
||||
```
|
||||
|
||||
## Concurrent Execution
|
||||
|
||||
Multiple `hi run` invocations can run simultaneously on the same Docker
|
||||
daemon. Each invocation gets a unique **Run ID** (format
|
||||
`YYYYMMDD-HHMMSS-6charhash`, e.g. `20260409-104215-mdjtzx`).
|
||||
|
||||
- **Container names** include the short run ID: `ts-mdjtzx-1-74-fgdyls`
|
||||
- **Docker labels**: `hi.run-id={runID}` on every container
|
||||
- **Port allocation**: dynamic — kernel assigns free ports, no conflicts
|
||||
- **Cleanup isolation**: each run cleans only its own containers
|
||||
- **Log directories**: `control_logs/{runID}/`
|
||||
|
||||
```bash
|
||||
# Start three tests in parallel — each gets its own run ID
|
||||
go run ./cmd/hi run "TestPingAllByIP" &
|
||||
go run ./cmd/hi run "TestACLAllowUserDst" &
|
||||
go run ./cmd/hi run "TestOIDCAuthenticationPingAll" &
|
||||
```
|
||||
|
||||
### Safety rules for concurrent runs
|
||||
|
||||
- ✅ Your run cleans only containers labelled with its own `hi.run-id`
|
||||
- ✅ `--clean-before` removes only stopped/exited containers
|
||||
- ❌ **Never** run `docker rm -f $(docker ps -q --filter name=hs-)` —
|
||||
this destroys other agents' live test sessions
|
||||
- ❌ **Never** run `docker system prune -f` while any tests are running
|
||||
- ❌ **Never** run `hi clean containers` / `hi clean all` while other
|
||||
tests are running — both kill all test containers on the daemon
|
||||
|
||||
To identify your own containers:
|
||||
|
||||
```bash
|
||||
docker ps --filter "label=hi.run-id=20260409-104215-mdjtzx"
|
||||
```
|
||||
|
||||
The run ID appears at the top of the `hi run` output — copy it from
|
||||
there rather than trying to reconstruct it.
|
||||
|
||||
## Artefacts
|
||||
|
||||
Every run saves debugging artefacts under `control_logs/{runID}/`:
|
||||
|
||||
```
|
||||
control_logs/20260409-104215-mdjtzx/
|
||||
├── hs-<test>-<hash>.stderr.log # headscale server errors
|
||||
├── hs-<test>-<hash>.stdout.log # headscale server output
|
||||
├── hs-<test>-<hash>.db # database snapshot (SQLite)
|
||||
├── hs-<test>-<hash>_metrics.txt # Prometheus metrics dump
|
||||
├── hs-<test>-<hash>-mapresponses/ # MapResponse protocol captures
|
||||
├── ts-<client>-<hash>.stderr.log # tailscale client errors
|
||||
├── ts-<client>-<hash>.stdout.log # tailscale client output
|
||||
└── ts-<client>-<hash>_status.json # client network-status dump
|
||||
```
|
||||
|
||||
Artefacts persist after cleanup. Old runs accumulate fast — delete
|
||||
unwanted directories to reclaim disk.
|
||||
|
||||
## Debugging workflow
|
||||
|
||||
When a test fails, read the artefacts **in this order**:
|
||||
|
||||
1. **`hs-*.stderr.log`** — headscale server errors, panics, policy
|
||||
evaluation failures. Most issues originate server-side.
|
||||
|
||||
```bash
|
||||
grep -E "ERROR|panic|FATAL" control_logs/*/hs-*.stderr.log
|
||||
```
|
||||
|
||||
2. **`ts-*.stderr.log`** — authentication failures, connectivity issues,
|
||||
DNS resolution problems on the client side.
|
||||
|
||||
3. **MapResponse JSON** in `hs-*-mapresponses/` — protocol-level
|
||||
debugging for network map generation, peer visibility, route
|
||||
distribution, policy evaluation results.
|
||||
|
||||
```bash
|
||||
ls control_logs/*/hs-*-mapresponses/
|
||||
jq '.Peers[] | {Name, Tags, PrimaryRoutes}' \
|
||||
control_logs/*/hs-*-mapresponses/001.json
|
||||
```
|
||||
|
||||
4. **`*_status.json`** — client peer-connectivity state.
|
||||
|
||||
5. **`hs-*.db`** — SQLite snapshot for post-mortem consistency checks.
|
||||
|
||||
```bash
|
||||
sqlite3 control_logs/<runID>/hs-*.db
|
||||
sqlite> .tables
|
||||
sqlite> .schema nodes
|
||||
sqlite> SELECT id, hostname, user_id, tags FROM nodes WHERE hostname LIKE '%problematic%';
|
||||
```
|
||||
|
||||
6. **`*_metrics.txt`** — Prometheus dumps for latency, NodeStore
|
||||
operation timing, database query performance, memory usage.
|
||||
|
||||
## Heuristic: infrastructure vs code
|
||||
|
||||
**Before blaming Docker, disk, or network: read `hs-*.stderr.log` in
|
||||
full.** In practice, well over 99% of failures are code bugs (policy
|
||||
evaluation, NodeStore sync, route approval) rather than infrastructure.
|
||||
|
||||
Actual infrastructure failures have signature error messages:
|
||||
|
||||
| Signature | Cause | Fix |
|
||||
| --------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------- |
|
||||
| `failed to resolve "hs-...": no DNS fallback candidates remain` | Docker DNS | Reset Docker networking |
|
||||
| `container creation timeout`, no progress >2 min | Resource exhaustion | `docker system prune -f` (when no other tests running), retry |
|
||||
| OOM kills, slow Docker daemon | Too many concurrent tests | Reduce concurrency, wait for completion |
|
||||
| `no space left on device` | Disk full | Delete old `control_logs/` |
|
||||
|
||||
If you don't see a signature error, **assume it's a code regression** —
|
||||
do not retry hoping the flake goes away.
|
||||
|
||||
## Common failure patterns (code bugs)
|
||||
|
||||
### Route advertisement timing
|
||||
|
||||
Test asserts route state before the client has finished propagating its
|
||||
Hostinfo update. Symptom: `nodes[0].GetAvailableRoutes()` empty when
|
||||
the test expects a route.
|
||||
|
||||
- **Wrong fix**: `time.Sleep(5 * time.Second)` — fragile and slow.
|
||||
- **Right fix**: wrap the assertion in `EventuallyWithT`. See
|
||||
[`../../integration/README.md`](../../integration/README.md).
|
||||
|
||||
### NodeStore sync issues
|
||||
|
||||
Route changes not reflected in the NodeStore snapshot. Symptom: route
|
||||
advertisements in logs but no tracking updates in subsequent reads.
|
||||
|
||||
The sync point is `State.UpdateNodeFromMapRequest()` in
|
||||
`hscontrol/state/state.go`. If you added a new kind of client state
|
||||
update, make sure it lands here.
|
||||
|
||||
### HA failover: routes disappearing on disconnect
|
||||
|
||||
`TestHASubnetRouterFailover` fails because approved routes vanish when
|
||||
a subnet router goes offline. **This is a bug, not expected behaviour.**
|
||||
Route approval must not be coupled to client connectivity — routes
|
||||
stay approved; only the primary-route selection is affected by
|
||||
connectivity.
|
||||
|
||||
### Policy evaluation race
|
||||
|
||||
Symptom: tests that change policy and immediately assert peer visibility
|
||||
fail intermittently. Policy changes trigger async recomputation.
|
||||
|
||||
- See recent fixes in `git log -- hscontrol/state/` for examples (e.g.
|
||||
the `PolicyChange` trigger on every Connect/Disconnect).
|
||||
|
||||
### SQLite vs PostgreSQL timing differences
|
||||
|
||||
Some race conditions only surface on one backend. If a test is flaky,
|
||||
try the other backend with `--postgres`:
|
||||
|
||||
```bash
|
||||
go run ./cmd/hi run "TestName" --postgres --verbose
|
||||
```
|
||||
|
||||
PostgreSQL generally has more consistent timing; SQLite can expose
|
||||
races during rapid writes.
|
||||
|
||||
## Keeping containers for inspection
|
||||
|
||||
If you need to inspect a failed test's state manually:
|
||||
|
||||
```bash
|
||||
go run ./cmd/hi run "TestName" --keep-on-failure
|
||||
# containers survive — inspect them
|
||||
docker exec -it ts-<runID>-<...> /bin/sh
|
||||
docker logs hs-<runID>-<...>
|
||||
# clean up manually when done
|
||||
go run ./cmd/hi clean all # only when no other tests are running
|
||||
```
|
||||
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.
|
||||
|
||||
+9
-26
@@ -145,25 +145,8 @@ derp:
|
||||
# Disables the automatic check for headscale updates on startup
|
||||
disable_check_updates: false
|
||||
|
||||
# Node lifecycle configuration.
|
||||
node:
|
||||
# Default key expiry for non-tagged nodes, regardless of registration method
|
||||
# (auth key, CLI, web auth). Tagged nodes are exempt and never expire.
|
||||
#
|
||||
# This is the base default. OIDC can override this via oidc.expiry.
|
||||
# If a client explicitly requests a specific expiry, the client value is used.
|
||||
#
|
||||
# Setting the value to "0" means no default expiry (nodes never expire unless
|
||||
# explicitly expired via `headscale nodes expire`).
|
||||
#
|
||||
# Tailscale SaaS uses 180d; set to a positive duration to match that behaviour.
|
||||
#
|
||||
# Default: 0 (no default expiry)
|
||||
expiry: 0
|
||||
|
||||
ephemeral:
|
||||
# Time before an inactive ephemeral node is deleted.
|
||||
inactivity_timeout: 30m
|
||||
# Time before an inactive ephemeral node is deleted?
|
||||
ephemeral_node_inactivity_timeout: 30m
|
||||
|
||||
database:
|
||||
# Database type. Available options: sqlite, postgres
|
||||
@@ -372,11 +355,15 @@ unix_socket_permission: "0770"
|
||||
# # `LoadCredential` straightforward:
|
||||
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
|
||||
#
|
||||
# # The amount of time a node is authenticated with OpenID until it expires
|
||||
# # and needs to reauthenticate.
|
||||
# # Setting the value to "0" will mean no expiry.
|
||||
# expiry: 180d
|
||||
#
|
||||
# # Use the expiry from the token received from OpenID when the user logged
|
||||
# # in. This will typically lead to frequent need to reauthenticate and should
|
||||
# # only be enabled if you know what you are doing.
|
||||
# # Note: enabling this will cause `node.expiry` to be ignored for
|
||||
# # OIDC-authenticated nodes.
|
||||
# # Note: enabling this will cause `oidc.expiry` to be ignored.
|
||||
# use_expiry_from_token: false
|
||||
#
|
||||
# # The OIDC scopes to use, defaults to "openid", "profile" and "email".
|
||||
@@ -445,16 +432,12 @@ taildrop:
|
||||
# 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:
|
||||
# # Maximum number of pending registration entries in the auth cache.
|
||||
# # Oldest entries are evicted when the cap is reached.
|
||||
# #
|
||||
# # register_cache_max_entries: 1024
|
||||
#
|
||||
# # NodeStore write batching configuration.
|
||||
# # The NodeStore batches write operations before rebuilding peer relationships,
|
||||
# # which is computationally expensive. Batching reduces rebuild frequency.
|
||||
|
||||
+7
-3
@@ -145,12 +145,16 @@ oidc:
|
||||
### Customize node expiration
|
||||
|
||||
The node expiration is the amount of time a node is authenticated with OpenID Connect until it expires and needs to
|
||||
reauthenticate. The default node expiration can be configured via the top-level `node.expiry` setting.
|
||||
reauthenticate. The default node expiration is 180 days. This can either be customized or set to the expiration from the
|
||||
Access Token.
|
||||
|
||||
=== "Customize node expiration"
|
||||
|
||||
```yaml hl_lines="2"
|
||||
node:
|
||||
```yaml hl_lines="5"
|
||||
oidc:
|
||||
issuer: "https://sso.example.com"
|
||||
client_id: "headscale"
|
||||
client_secret: "generated-secret"
|
||||
expiry: 30d # Use 0 to disable node expiration
|
||||
```
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1775701739,
|
||||
"narHash": "sha256-2FWWY1rr/+pGUJK1npcVcsWNEblzmKs6VxD3VEvwJSs=",
|
||||
"lastModified": 1772956932,
|
||||
"narHash": "sha256-M0yS4AafhKxPPmOHGqIV0iKxgNO8bHDWdl1kOwGBwRY=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0f7663154ff2fec150f9dbf5f81ec2785dc1e0db",
|
||||
"rev": "608d0cadfed240589a7eea422407a547ad626a14",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${prev.stdenv.hostPlatform.system};
|
||||
buildGo = pkgs.buildGo126Module;
|
||||
vendorHash = "sha256-x0xXxa7sjyDwWLq8fO0Z/pbPefctzctK3TAdBea7FtY=";
|
||||
vendorHash = "sha256-jom1279Lx2Knff93rfoEgGeBBk+EjJO7GAkaQYlchgY=";
|
||||
in
|
||||
{
|
||||
headscale = buildGo {
|
||||
@@ -62,16 +62,16 @@
|
||||
|
||||
protoc-gen-grpc-gateway = buildGo rec {
|
||||
pname = "grpc-gateway";
|
||||
version = "2.28.0";
|
||||
version = "2.27.7";
|
||||
|
||||
src = pkgs.fetchFromGitHub {
|
||||
owner = "grpc-ecosystem";
|
||||
repo = "grpc-gateway";
|
||||
rev = "v${version}";
|
||||
sha256 = "sha256-93omvHb+b+S0w4D+FGEEwYYDjgumJFDAruc1P4elfvA=";
|
||||
sha256 = "sha256-6R0EhNnOBEISJddjkbVTcBvUuU5U3r9Hu2UPfAZDep4=";
|
||||
};
|
||||
|
||||
vendorHash = "sha256-jVP5zfFPfHeAEApKNJzZwuZLA+DjKgkL7m2DFG72UNs=";
|
||||
vendorHash = "sha256-SOAbRrzMf2rbKaG9PGSnPSLY/qZVgbHcNjOLmVonycY=";
|
||||
|
||||
nativeBuildInputs = [ pkgs.installShellFiles ];
|
||||
|
||||
@@ -80,13 +80,13 @@
|
||||
|
||||
protobuf-language-server = buildGo rec {
|
||||
pname = "protobuf-language-server";
|
||||
version = "ab4c128";
|
||||
version = "1cf777d";
|
||||
|
||||
src = pkgs.fetchFromGitHub {
|
||||
owner = "lasorda";
|
||||
repo = "protobuf-language-server";
|
||||
rev = "ab4c128f00774d51bd6d1f4cfa735f4b7c8619e3";
|
||||
sha256 = "sha256-yF6kG+qTRxVO/qp2V9HgTyFBeOm5RQzeqdZFrdidwxM=";
|
||||
rev = "1cf777de4d35a6e493a689e3ca1a6183ce3206b6";
|
||||
sha256 = "sha256-9MkBQPxr/TDr/sNz/Sk7eoZwZwzdVbE5u6RugXXk5iY=";
|
||||
};
|
||||
|
||||
vendorHash = "sha256-4nTpKBe7ekJsfQf+P6edT/9Vp2SBYbKz1ITawD3bhkI=";
|
||||
@@ -97,16 +97,16 @@
|
||||
# Build golangci-lint with Go 1.26 (upstream uses hardcoded Go version)
|
||||
golangci-lint = buildGo rec {
|
||||
pname = "golangci-lint";
|
||||
version = "2.11.4";
|
||||
version = "2.9.0";
|
||||
|
||||
src = pkgs.fetchFromGitHub {
|
||||
owner = "golangci";
|
||||
repo = "golangci-lint";
|
||||
rev = "v${version}";
|
||||
hash = "sha256-B19aLvfNRY9TOYw/71f2vpNUuSIz8OI4dL0ijGezsas=";
|
||||
hash = "sha256-8LEtm1v0slKwdLBtS41OilKJLXytSxcI9fUlZbj5Gfw=";
|
||||
};
|
||||
|
||||
vendorHash = "sha256-xuoj4+U4tB5gpABKq4Dbp2cxnljxdYoBbO8A7DqPM5E=";
|
||||
vendorHash = "sha256-w8JfF6n1ylrU652HEv/cYdsOdDZz9J2uRQDqxObyhkY=";
|
||||
|
||||
subPackages = [ "cmd/golangci-lint" ];
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
golangci-lint
|
||||
golangci-lint-langserver
|
||||
golines
|
||||
prettier
|
||||
nodePackages.prettier
|
||||
nixpkgs-fmt
|
||||
goreleaser
|
||||
nfpm
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc-gen-go-grpc v1.6.0
|
||||
// - protoc (unknown)
|
||||
// source: headscale/v1/headscale.proto
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ require (
|
||||
github.com/cenkalti/backoff/v5 v5.0.3
|
||||
github.com/chasefleming/elem-go v0.31.0
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/creachadair/command v0.2.2
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/creachadair/command v0.2.0
|
||||
github.com/creachadair/flax v0.0.5
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
@@ -17,12 +17,11 @@ require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/metrics v0.1.1
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e
|
||||
github.com/gofrs/uuid/v5 v5.4.0
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7
|
||||
github.com/jagottsicher/termcolor v1.0.2
|
||||
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
|
||||
github.com/ory/dockertest/v3 v3.12.0
|
||||
@@ -30,31 +29,32 @@ require (
|
||||
github.com/pkg/profile v1.7.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/common v0.67.5
|
||||
github.com/pterm/pterm v0.12.83
|
||||
github.com/pterm/pterm v0.12.82
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0
|
||||
github.com/rs/zerolog v1.35.0
|
||||
github.com/samber/lo v1.53.0
|
||||
github.com/sasha-s/go-deadlock v0.3.9
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/samber/lo v1.52.0
|
||||
github.com/sasha-s/go-deadlock v0.3.6
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd
|
||||
github.com/tailscale/squibble v0.0.0-20260303070345-3ac5157f405e
|
||||
github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c
|
||||
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a
|
||||
github.com/tailscale/squibble v0.0.0-20251104223530-a961feffb67f
|
||||
github.com/tailscale/tailsql v0.0.0-20260105194658-001575c3ca09
|
||||
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.20.0
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d
|
||||
google.golang.org/grpc v1.80.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sync v0.19.0
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20
|
||||
google.golang.org/grpc v1.78.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
tailscale.com v1.96.5
|
||||
tailscale.com v1.94.1
|
||||
zgo.at/zcache/v2 v2.4.1
|
||||
zombiezen.com/go/postgrestest v1.0.1
|
||||
)
|
||||
|
||||
@@ -76,10 +76,10 @@ require (
|
||||
// together, e.g:
|
||||
// go get modernc.org/libc@v1.55.3 modernc.org/sqlite@v1.33.1
|
||||
require (
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.48.2
|
||||
modernc.org/sqlite v1.44.3
|
||||
)
|
||||
|
||||
// NOTE: gvisor must be updated in lockstep with
|
||||
@@ -88,14 +88,14 @@ require (
|
||||
// To find the correct version, check tailscale.com's
|
||||
// go.mod file for the gvisor.dev/gvisor version:
|
||||
// https://github.com/tailscale/tailscale/blob/main/go.mod
|
||||
require gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect
|
||||
require gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect
|
||||
|
||||
require (
|
||||
atomicgo.dev/cursor v0.2.0 // indirect
|
||||
atomicgo.dev/keyboard v0.2.9 // indirect
|
||||
atomicgo.dev/schedule v0.1.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
|
||||
@@ -119,12 +119,13 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/containerd/console v1.0.5 // indirect
|
||||
github.com/containerd/continuity v0.4.5 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/creachadair/mds v0.26.2 // indirect
|
||||
github.com/creachadair/mds v0.25.15 // indirect
|
||||
github.com/creachadair/msync v0.8.2 // indirect
|
||||
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
|
||||
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect
|
||||
@@ -139,7 +140,7 @@ require (
|
||||
github.com/gaissmai/bart v0.26.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
@@ -172,7 +173,7 @@ require (
|
||||
github.com/lithammer/fuzzysearch v1.1.8 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mdlayher/netlink v1.8.0 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
@@ -224,15 +225,15 @@ require (
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
|
||||
@@ -10,8 +10,8 @@ atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
|
||||
atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
|
||||
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
@@ -103,8 +103,10 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
|
||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
github.com/cilium/ebpf v0.17.3 h1:FnP4r16PWYSE4ux6zN+//jMcW4nMVRvuTLVTvCjyyjg=
|
||||
github.com/cilium/ebpf v0.17.3/go.mod h1:G5EDHij8yiLzaqn0WjyfJHvRa+3aDlReIaLVRMvOyJk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
|
||||
@@ -120,15 +122,16 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
|
||||
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creachadair/command v0.2.2 h1:4RGsUhqFf1imFC+vMWOOCiQdncThCdcdMJp0JNCjxxc=
|
||||
github.com/creachadair/command v0.2.2/go.mod h1:Z6Zp6CSJcnaWWR4wHgdqzODnFdxFJAaa/DrcVkeUu3E=
|
||||
github.com/creachadair/command v0.2.0 h1:qTA9cMMhZePAxFoNdnk6F6nn94s1qPndIg9hJbqI9cA=
|
||||
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.26.2 h1:rCtvEV/bCRY0hGfwvvMg0p3yzKgBE8l/9OV4fjF9QQ8=
|
||||
github.com/creachadair/mds v0.26.2/go.mod h1:dMBTCSy3iS3dwh4Rb1zxeZz2d7K8+N24GCTsayWtQRI=
|
||||
github.com/creachadair/mds v0.25.15 h1:i8CUqtfgbCqbvZ++L7lm8No3cOeic9YKF4vHEvEoj+Y=
|
||||
github.com/creachadair/mds v0.25.15/go.mod h1:XtMfRW15sjd1iOi1Z1k+dq0pRsR5xPbulpoTrpyhk8w=
|
||||
github.com/creachadair/msync v0.8.2 h1:ujvc/SVJPn+bFwmjUHucXNTTn3opVe2YbQ46mBCnP08=
|
||||
github.com/creachadair/msync v0.8.2/go.mod h1:LzxqD9kfIl/O3DczkwOgJplLPqwrTbIhINlf9bHIsEY=
|
||||
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
||||
@@ -186,10 +189,10 @@ github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiI
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -206,6 +209,7 @@ github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
|
||||
@@ -248,10 +252,11 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
|
||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||
@@ -316,13 +321,16 @@ github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
|
||||
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||
github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM=
|
||||
@@ -375,8 +383,8 @@ github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQ
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/philip-bui/grpc-zerolog v1.0.1 h1:EMacvLRUd2O1K0eWod27ZP5CY1iTNkhBDLSN+Q4JEvA=
|
||||
github.com/philip-bui/grpc-zerolog v1.0.1/go.mod h1:qXbiq/2X4ZUMMshsqlWyTHOcw7ns+GZmlqZZN05ZHcQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pires/go-proxyproto v0.9.2 h1:H1UdHn695zUVVmB0lQ354lOWHOy6TZSpzBl3tgN0s1U=
|
||||
github.com/pires/go-proxyproto v0.9.2/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -405,8 +413,8 @@ github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEej
|
||||
github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE=
|
||||
github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8=
|
||||
github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s=
|
||||
github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA=
|
||||
github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk=
|
||||
github.com/pterm/pterm v0.12.82 h1:+D9wYhCaeaK0FIQoZtqbNQuNpe2lB2tajKKsTd5paVQ=
|
||||
github.com/pterm/pterm v0.12.82/go.mod h1:TyuyrPjnxfwP+ccJdBTeWHtd/e0ybQHkOS/TakajZCw=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0 h1:vlSN6/CkEY0pY8KaB0yqo/pCLZvp9nhdbBdjipT4gWo=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -414,17 +422,18 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
|
||||
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/safchain/ethtool v0.7.0 h1:rlJzfDetsVvT61uz8x1YIcFn12akMfuPulHtZjtb7Is=
|
||||
github.com/safchain/ethtool v0.7.0/go.mod h1:MenQKEjXdfkjD3mp2QdCk8B/hwvkrlOTm/FD4gTpFxQ=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/sasha-s/go-deadlock v0.3.9 h1:fiaT9rB7g5sr5ddNZvlwheclN9IP86eFW9WgqlEQV+w=
|
||||
github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y=
|
||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/sasha-s/go-deadlock v0.3.6 h1:TR7sfOnZ7x00tWPfD397Peodt57KzMDo+9Ae9rMiUmw=
|
||||
github.com/sasha-s/go-deadlock v0.3.6/go.mod h1:CUqNyyvMxTyjFqDT7MRg9mb4Dv/btmGTqSR+rky/UXo=
|
||||
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
@@ -461,18 +470,18 @@ github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM=
|
||||
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
|
||||
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA=
|
||||
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
|
||||
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I=
|
||||
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
|
||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
|
||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
|
||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
|
||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
|
||||
github.com/tailscale/setec v0.0.0-20260115174028-19d190c5556d h1:N+TtzIaGYREbLbKZB0WU0vVnMSfaqUkSf3qMEi03hwE=
|
||||
github.com/tailscale/setec v0.0.0-20260115174028-19d190c5556d/go.mod h1:6NU8H/GLPVX2TnXAY1duyy9ylLaHwFpr0X93UPiYmNI=
|
||||
github.com/tailscale/squibble v0.0.0-20260303070345-3ac5157f405e h1:4yfp5/YDr+TzbUME/PalYJVXAsp7zA2Gv2xQMZ9Qors=
|
||||
github.com/tailscale/squibble v0.0.0-20260303070345-3ac5157f405e/go.mod h1:xJkMmR3t+thnUQhA3Q4m2VSlS5pcOq+CIjmU/xfKKx4=
|
||||
github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c h1:7lJQ/zycbk1E9e0nUiMuwIDYprFTLpWXUwiPdi+tRlI=
|
||||
github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c/go.mod h1:bpNmZdvZKmBstrZunT+NXL6hmrFw5AsuT7MGiYS8sRc=
|
||||
github.com/tailscale/squibble v0.0.0-20251104223530-a961feffb67f h1:CL6gu95Y1o2ko4XiWPvWkJka0QmQWcUyPywWVWDPQbQ=
|
||||
github.com/tailscale/squibble v0.0.0-20251104223530-a961feffb67f/go.mod h1:xJkMmR3t+thnUQhA3Q4m2VSlS5pcOq+CIjmU/xfKKx4=
|
||||
github.com/tailscale/tailsql v0.0.0-20260105194658-001575c3ca09 h1:Fc9lE2cDYJbBLpCqnVmoLdf7McPqoHZiDxDPPpkJM04=
|
||||
github.com/tailscale/tailsql v0.0.0-20260105194658-001575c3ca09/go.mod h1:QMNhC4XGFiXKngHVLXE+ERDmQoH0s5fD7AUxupykocQ=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368 h1:0tpDdAj9sSfSZg4gMwNTdqMP592sBrq2Sm0w6ipnh7k=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
|
||||
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
|
||||
@@ -539,33 +548,33 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/W
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
|
||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -578,13 +587,15 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -592,37 +603,37 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -641,26 +652,26 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA=
|
||||
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
|
||||
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k=
|
||||
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM=
|
||||
honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLaf0O3aOenC0Z4ZafIWSpjUzsQaNho=
|
||||
honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ=
|
||||
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
|
||||
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -669,8 +680,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
|
||||
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
@@ -679,7 +690,9 @@ pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
|
||||
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
|
||||
tailscale.com v1.94.1 h1:0dAst/ozTuFkgmxZULc3oNwR9+qPIt5ucvzH7kaM0Jw=
|
||||
tailscale.com v1.94.1/go.mod h1:gLnVrEOP32GWvroaAHHGhjSGMPJ1i4DvqNwEg+Yuov4=
|
||||
zgo.at/zcache/v2 v2.4.1 h1:Dfjoi8yI0Uq7NCc4lo2kaQJJmp9Mijo21gef+oJstbY=
|
||||
zgo.at/zcache/v2 v2.4.1/go.mod h1:gyCeoLVo01QjDZynjime8xUGHHMbsLiPyUTBpDGd4Gk=
|
||||
zombiezen.com/go/postgrestest v1.0.1 h1:aXoADQAJmZDU3+xilYVut0pHhgc0sF8ZspPW9gFNwP4=
|
||||
zombiezen.com/go/postgrestest v1.0.1/go.mod h1:marlZezr+k2oSJrvXHnZUs1olHqpE9czlz8ZYkVxliQ=
|
||||
|
||||
+2
-2
@@ -485,7 +485,6 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
|
||||
|
||||
if provider, ok := h.authProvider.(*AuthProviderOIDC); ok {
|
||||
r.Get("/oidc/callback", provider.OIDCCallbackHandler)
|
||||
r.Post("/register/confirm/{auth_id}", provider.RegisterConfirmHandler)
|
||||
}
|
||||
|
||||
r.Get("/apple", h.AppleConfigMessage)
|
||||
@@ -584,7 +583,7 @@ func (h *Headscale) Serve() error {
|
||||
|
||||
ephmNodes := h.state.ListEphemeralNodes()
|
||||
for _, node := range ephmNodes.All() {
|
||||
h.ephemeralGC.Schedule(node.ID(), h.cfg.Node.Ephemeral.InactivityTimeout)
|
||||
h.ephemeralGC.Schedule(node.ID(), h.cfg.EphemeralNodeInactivityTimeout)
|
||||
}
|
||||
|
||||
if h.cfg.DNSConfig.ExtraRecordsPath != "" {
|
||||
@@ -728,6 +727,7 @@ func (h *Headscale) Serve() error {
|
||||
grpcServer = grpc.NewServer(grpcOptions...)
|
||||
|
||||
v1.RegisterHeadscaleServiceServer(grpcServer, newHeadscaleV1APIServer(h))
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
grpcListener, err = new(net.ListenConfig).Listen(context.Background(), "tcp", h.cfg.GRPCAddr)
|
||||
if err != nil {
|
||||
|
||||
+54
-50
@@ -1,6 +1,7 @@
|
||||
package hscontrol
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -70,20 +71,6 @@ 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 {
|
||||
// Refuse to act on a node looked up purely by NodeKey unless
|
||||
// the Noise session's machine key matches the cached node.
|
||||
// Without this check anyone holding a target's NodeKey could
|
||||
// open a Noise session with a throwaway machine key and read
|
||||
// the owner's User/Login back through nodeToRegisterResponse.
|
||||
// handleLogout enforces the same check on its own path.
|
||||
if node.MachineKey() != machineKey {
|
||||
return nil, NewHTTPError(
|
||||
http.StatusUnauthorized,
|
||||
"node exists with a different machine key",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -315,10 +302,31 @@ func (h *Headscale) reqToNewRegisterResponse(
|
||||
return nil, NewHTTPError(http.StatusInternalServerError, "failed to generate registration ID", err)
|
||||
}
|
||||
|
||||
authRegReq := types.NewRegisterAuthRequest(
|
||||
registrationDataFromRequest(req, machineKey),
|
||||
// Ensure we have a valid hostname
|
||||
hostname := util.EnsureHostname(
|
||||
req.Hostinfo.View(),
|
||||
machineKey.String(),
|
||||
req.NodeKey.String(),
|
||||
)
|
||||
|
||||
// Ensure we have valid hostinfo
|
||||
hostinfo := cmp.Or(req.Hostinfo, &tailcfg.Hostinfo{})
|
||||
hostinfo.Hostname = hostname
|
||||
|
||||
nodeToRegister := types.Node{
|
||||
Hostname: hostname,
|
||||
MachineKey: machineKey,
|
||||
NodeKey: req.NodeKey,
|
||||
Hostinfo: hostinfo,
|
||||
LastSeen: new(time.Now()),
|
||||
}
|
||||
|
||||
if !req.Expiry.IsZero() {
|
||||
nodeToRegister.Expiry = &req.Expiry
|
||||
}
|
||||
|
||||
authRegReq := types.NewRegisterAuthRequest(nodeToRegister)
|
||||
|
||||
log.Info().Msgf("new followup node registration using auth id: %s", newAuthID)
|
||||
h.state.SetAuthCacheEntry(newAuthID, authRegReq)
|
||||
|
||||
@@ -327,36 +335,6 @@ func (h *Headscale) reqToNewRegisterResponse(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// registrationDataFromRequest builds the RegistrationData payload stored
|
||||
// in the auth cache for a pending registration. The original Hostinfo is
|
||||
// retained so that consumers (auth callback, observability) see the
|
||||
// fields the client originally announced; the bounded-LRU cap on the
|
||||
// cache is what bounds the unauthenticated cache-fill DoS surface.
|
||||
func registrationDataFromRequest(
|
||||
req tailcfg.RegisterRequest,
|
||||
machineKey key.MachinePublic,
|
||||
) *types.RegistrationData {
|
||||
hostname := util.EnsureHostname(
|
||||
req.Hostinfo.View(),
|
||||
machineKey.String(),
|
||||
req.NodeKey.String(),
|
||||
)
|
||||
|
||||
regData := &types.RegistrationData{
|
||||
MachineKey: machineKey,
|
||||
NodeKey: req.NodeKey,
|
||||
Hostname: hostname,
|
||||
Hostinfo: req.Hostinfo,
|
||||
}
|
||||
|
||||
if !req.Expiry.IsZero() {
|
||||
expiry := req.Expiry
|
||||
regData.Expiry = &expiry
|
||||
}
|
||||
|
||||
return regData
|
||||
}
|
||||
|
||||
func (h *Headscale) handleRegisterWithAuthKey(
|
||||
req tailcfg.RegisterRequest,
|
||||
machineKey key.MachinePublic,
|
||||
@@ -430,23 +408,49 @@ func (h *Headscale) handleRegisterInteractive(
|
||||
return nil, fmt.Errorf("generating registration ID: %w", err)
|
||||
}
|
||||
|
||||
// Ensure we have a valid hostname
|
||||
hostname := util.EnsureHostname(
|
||||
req.Hostinfo.View(),
|
||||
machineKey.String(),
|
||||
req.NodeKey.String(),
|
||||
)
|
||||
|
||||
// Ensure we have valid hostinfo
|
||||
hostinfo := cmp.Or(req.Hostinfo, &tailcfg.Hostinfo{})
|
||||
if req.Hostinfo == nil {
|
||||
log.Warn().
|
||||
Str("machine.key", machineKey.ShortString()).
|
||||
Str("node.key", req.NodeKey.ShortString()).
|
||||
Str("generated.hostname", hostname).
|
||||
Msg("Received registration request with nil hostinfo, generated default hostname")
|
||||
} else if req.Hostinfo.Hostname == "" {
|
||||
log.Warn().
|
||||
Str("machine.key", machineKey.ShortString()).
|
||||
Str("node.key", req.NodeKey.ShortString()).
|
||||
Str("generated.hostname", hostname).
|
||||
Msg("Received registration request with empty hostname, generated default")
|
||||
}
|
||||
|
||||
authRegReq := types.NewRegisterAuthRequest(
|
||||
registrationDataFromRequest(req, machineKey),
|
||||
)
|
||||
hostinfo.Hostname = hostname
|
||||
|
||||
h.state.SetAuthCacheEntry(authID, authRegReq)
|
||||
nodeToRegister := types.Node{
|
||||
Hostname: hostname,
|
||||
MachineKey: machineKey,
|
||||
NodeKey: req.NodeKey,
|
||||
Hostinfo: hostinfo,
|
||||
LastSeen: new(time.Now()),
|
||||
}
|
||||
|
||||
if !req.Expiry.IsZero() {
|
||||
nodeToRegister.Expiry = &req.Expiry
|
||||
}
|
||||
|
||||
authRegReq := types.NewRegisterAuthRequest(nodeToRegister)
|
||||
|
||||
h.state.SetAuthCacheEntry(
|
||||
authID,
|
||||
authRegReq,
|
||||
)
|
||||
|
||||
log.Info().Msgf("starting node registration using auth id: %s", authID)
|
||||
|
||||
|
||||
+4
-290
@@ -4,7 +4,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/mapper"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -12,49 +11,6 @@ import (
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
// createTestAppWithNodeExpiry creates a test app with a specific node.expiry config.
|
||||
func createTestAppWithNodeExpiry(t *testing.T, nodeExpiry time.Duration) *Headscale {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := types.Config{
|
||||
ServerURL: "http://localhost:8080",
|
||||
NoisePrivateKeyPath: tmpDir + "/noise_private.key",
|
||||
Node: types.NodeConfig{
|
||||
Expiry: nodeExpiry,
|
||||
},
|
||||
Database: types.DatabaseConfig{
|
||||
Type: "sqlite3",
|
||||
Sqlite: types.SqliteConfig{
|
||||
Path: tmpDir + "/headscale_test.db",
|
||||
},
|
||||
},
|
||||
OIDC: types.OIDCConfig{},
|
||||
Policy: types.PolicyConfig{
|
||||
Mode: types.PolicyModeDB,
|
||||
},
|
||||
Tuning: types.Tuning{
|
||||
BatchChangeDelay: 100 * time.Millisecond,
|
||||
BatcherWorkers: 1,
|
||||
},
|
||||
}
|
||||
|
||||
app, err := NewHeadscale(&cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
app.mapBatcher = mapper.NewBatcherAndMapper(&cfg, app.state)
|
||||
app.mapBatcher.Start()
|
||||
|
||||
t.Cleanup(func() {
|
||||
if app.mapBatcher != nil {
|
||||
app.mapBatcher.Close()
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// TestTaggedPreAuthKeyCreatesTaggedNode tests that a PreAuthKey with tags creates
|
||||
// a tagged node with:
|
||||
// - Tags from the PreAuthKey
|
||||
@@ -696,7 +652,7 @@ func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) {
|
||||
// Step 1: Create user-owned node WITH expiry set
|
||||
clientExpiry := time.Now().Add(24 * time.Hour)
|
||||
registrationID1 := types.MustAuthID()
|
||||
regEntry1 := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
regEntry1 := types.NewRegisterAuthRequest(types.Node{
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: nodeKey1.Public(),
|
||||
Hostname: "personal-to-tagged",
|
||||
@@ -718,7 +674,7 @@ func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) {
|
||||
// Step 2: Re-auth with tags (Personal → Tagged conversion)
|
||||
nodeKey2 := key.NewNode()
|
||||
registrationID2 := types.MustAuthID()
|
||||
regEntry2 := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
regEntry2 := types.NewRegisterAuthRequest(types.Node{
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: nodeKey2.Public(),
|
||||
Hostname: "personal-to-tagged",
|
||||
@@ -768,7 +724,7 @@ func TestExpiryDuringTaggedToPersonalConversion(t *testing.T) {
|
||||
|
||||
// Step 1: Create tagged node (expiry should be nil)
|
||||
registrationID1 := types.MustAuthID()
|
||||
regEntry1 := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
regEntry1 := types.NewRegisterAuthRequest(types.Node{
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: nodeKey1.Public(),
|
||||
Hostname: "tagged-to-personal",
|
||||
@@ -790,7 +746,7 @@ func TestExpiryDuringTaggedToPersonalConversion(t *testing.T) {
|
||||
nodeKey2 := key.NewNode()
|
||||
clientExpiry := time.Now().Add(48 * time.Hour)
|
||||
registrationID2 := types.MustAuthID()
|
||||
regEntry2 := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
regEntry2 := types.NewRegisterAuthRequest(types.Node{
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: nodeKey2.Public(),
|
||||
Hostname: "tagged-to-personal",
|
||||
@@ -877,245 +833,3 @@ func TestReAuthWithDifferentMachineKey(t *testing.T) {
|
||||
assert.True(t, node2.IsTagged())
|
||||
assert.ElementsMatch(t, tags, node2.Tags().AsSlice())
|
||||
}
|
||||
|
||||
// TestUntaggedAuthKeyZeroExpiryGetsDefault tests that when node.expiry is configured
|
||||
// and a client registers with an untagged auth key without requesting a specific expiry,
|
||||
// the node gets the configured default expiry.
|
||||
// This is the core fix for https://github.com/juanfont/headscale/issues/1711
|
||||
func TestUntaggedAuthKeyZeroExpiryGetsDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nodeExpiry := 180 * 24 * time.Hour // 180 days
|
||||
app := createTestAppWithNodeExpiry(t, nodeExpiry)
|
||||
|
||||
user := app.state.CreateUserForTest("node-owner")
|
||||
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
// Client sends zero expiry (the default behaviour of tailscale up --authkey).
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "default-expiry-test",
|
||||
},
|
||||
Expiry: time.Time{}, // zero — no client-requested expiry
|
||||
}
|
||||
|
||||
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
|
||||
assert.False(t, node.IsTagged())
|
||||
assert.True(t, node.Expiry().Valid(), "node should have expiry set from config default")
|
||||
assert.False(t, node.IsExpired(), "node should not be expired yet")
|
||||
|
||||
expectedExpiry := time.Now().Add(nodeExpiry)
|
||||
assert.WithinDuration(t, expectedExpiry, node.Expiry().Get(), 10*time.Second,
|
||||
"node expiry should be ~180 days from now")
|
||||
}
|
||||
|
||||
// TestTaggedAuthKeyIgnoresNodeExpiry tests that tagged nodes still get nil
|
||||
// expiry even when node.expiry is configured.
|
||||
func TestTaggedAuthKeyIgnoresNodeExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nodeExpiry := 180 * 24 * time.Hour
|
||||
app := createTestAppWithNodeExpiry(t, nodeExpiry)
|
||||
|
||||
user := app.state.CreateUserForTest("tag-creator")
|
||||
tags := []string{"tag:server"}
|
||||
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, tags)
|
||||
require.NoError(t, err)
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "tagged-no-expiry",
|
||||
},
|
||||
Expiry: time.Time{},
|
||||
}
|
||||
|
||||
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
|
||||
assert.True(t, node.IsTagged())
|
||||
assert.False(t, node.Expiry().Valid(),
|
||||
"tagged node should have expiry disabled (nil) even with node.expiry configured")
|
||||
}
|
||||
|
||||
// TestNodeExpiryZeroDisablesDefault tests that setting node.expiry to 0
|
||||
// preserves the old behaviour where nodes registered without a client-requested
|
||||
// expiry get no expiry (never expire).
|
||||
func TestNodeExpiryZeroDisablesDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// node.expiry = 0 means "no default expiry"
|
||||
app := createTestAppWithNodeExpiry(t, 0)
|
||||
|
||||
user := app.state.CreateUserForTest("node-owner")
|
||||
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "no-default-expiry",
|
||||
},
|
||||
Expiry: time.Time{}, // zero
|
||||
}
|
||||
|
||||
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
|
||||
assert.False(t, node.IsTagged())
|
||||
assert.False(t, node.IsExpired(), "node should not be expired")
|
||||
|
||||
// With node.expiry=0 and zero client expiry, the node gets a zero expiry
|
||||
// which IsExpired() treats as "never expires" — backwards compatible.
|
||||
if node.Expiry().Valid() {
|
||||
assert.True(t, node.Expiry().Get().IsZero(),
|
||||
"with node.expiry=0 and zero client expiry, expiry should be zero time")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientNonZeroExpiryTakesPrecedence tests that when a client explicitly
|
||||
// requests an expiry, that value is used instead of the configured default.
|
||||
func TestClientNonZeroExpiryTakesPrecedence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nodeExpiry := 180 * 24 * time.Hour // 180 days
|
||||
app := createTestAppWithNodeExpiry(t, nodeExpiry)
|
||||
|
||||
user := app.state.CreateUserForTest("node-owner")
|
||||
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
// Client explicitly requests 24h expiry
|
||||
clientExpiry := time.Now().Add(24 * time.Hour)
|
||||
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "client-expiry-test",
|
||||
},
|
||||
Expiry: clientExpiry,
|
||||
}
|
||||
|
||||
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
|
||||
assert.True(t, node.Expiry().Valid(), "node should have expiry set")
|
||||
assert.WithinDuration(t, clientExpiry, node.Expiry().Get(), 5*time.Second,
|
||||
"client-requested expiry should take precedence over node.expiry default")
|
||||
}
|
||||
|
||||
// TestReregistrationAppliesDefaultExpiry tests that when a node re-registers
|
||||
// with an untagged auth key and the client sends zero expiry, the configured
|
||||
// default is applied.
|
||||
func TestReregistrationAppliesDefaultExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nodeExpiry := 90 * 24 * time.Hour // 90 days
|
||||
app := createTestAppWithNodeExpiry(t, nodeExpiry)
|
||||
|
||||
user := app.state.CreateUserForTest("node-owner")
|
||||
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
// Initial registration with zero expiry
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "reregister-test",
|
||||
},
|
||||
Expiry: time.Time{},
|
||||
}
|
||||
|
||||
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
assert.True(t, node.Expiry().Valid(), "initial registration should get default expiry")
|
||||
|
||||
firstExpiry := node.Expiry().Get()
|
||||
|
||||
// Re-register with a new node key but same machine key
|
||||
nodeKey2 := key.NewNode()
|
||||
regReq2 := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey2.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "reregister-test",
|
||||
},
|
||||
Expiry: time.Time{}, // still zero
|
||||
}
|
||||
|
||||
resp2, err := app.handleRegisterWithAuthKey(regReq2, machineKey.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.Expiry().Valid(), "re-registration should also get default expiry")
|
||||
|
||||
// The expiry should be refreshed (new 90d from now), not the old one
|
||||
expectedExpiry := time.Now().Add(nodeExpiry)
|
||||
assert.WithinDuration(t, expectedExpiry, node2.Expiry().Get(), 10*time.Second,
|
||||
"re-registration should refresh the default expiry")
|
||||
assert.True(t, node2.Expiry().Get().After(firstExpiry),
|
||||
"re-registration expiry should be later than initial registration expiry")
|
||||
}
|
||||
|
||||
@@ -681,7 +681,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nodeToRegister := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
nodeToRegister := types.NewRegisterAuthRequest(types.Node{
|
||||
Hostname: "followup-success-node",
|
||||
})
|
||||
app.state.SetAuthCacheEntry(regID, nodeToRegister)
|
||||
@@ -723,7 +723,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nodeToRegister := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
nodeToRegister := types.NewRegisterAuthRequest(types.Node{
|
||||
Hostname: "followup-timeout-node",
|
||||
})
|
||||
app.state.SetAuthCacheEntry(regID, nodeToRegister)
|
||||
@@ -1341,7 +1341,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nodeToRegister := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
nodeToRegister := types.NewRegisterAuthRequest(types.Node{
|
||||
Hostname: "nil-response-node",
|
||||
})
|
||||
app.state.SetAuthCacheEntry(regID, nodeToRegister)
|
||||
@@ -2618,7 +2618,7 @@ func runInteractiveWorkflowTest(t *testing.T, tt struct {
|
||||
cacheEntry, found := app.state.GetAuthCacheEntry(registrationID)
|
||||
require.True(t, found, "registration cache entry should exist")
|
||||
require.NotNil(t, cacheEntry, "cache entry should not be nil")
|
||||
require.Equal(t, req.NodeKey, cacheEntry.RegistrationData().NodeKey, "cache entry should have correct node key")
|
||||
require.Equal(t, req.NodeKey, cacheEntry.Node().NodeKey(), "cache entry should have correct node key")
|
||||
}
|
||||
|
||||
case stepTypeAuthCompletion:
|
||||
@@ -3570,7 +3570,7 @@ func TestWebAuthRejectsUnauthorizedRequestTags(t *testing.T) {
|
||||
|
||||
// Simulate a registration cache entry (as would be created during web auth)
|
||||
registrationID := types.MustAuthID()
|
||||
regEntry := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
regEntry := types.NewRegisterAuthRequest(types.Node{
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostname: "webauth-tags-node",
|
||||
@@ -3633,7 +3633,7 @@ func TestWebAuthReauthWithEmptyTagsRemovesAllTags(t *testing.T) {
|
||||
|
||||
// Step 1: Initial registration with tags
|
||||
registrationID1 := types.MustAuthID()
|
||||
regEntry1 := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
regEntry1 := types.NewRegisterAuthRequest(types.Node{
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: nodeKey1.Public(),
|
||||
Hostname: "reauth-untag-node",
|
||||
@@ -3660,7 +3660,7 @@ func TestWebAuthReauthWithEmptyTagsRemovesAllTags(t *testing.T) {
|
||||
// Step 2: Reauth with EMPTY tags to untag
|
||||
nodeKey2 := key.NewNode() // New node key for reauth
|
||||
registrationID2 := types.MustAuthID()
|
||||
regEntry2 := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
regEntry2 := types.NewRegisterAuthRequest(types.Node{
|
||||
MachineKey: machineKey.Public(), // Same machine key
|
||||
NodeKey: nodeKey2.Public(), // Different node key (rotation)
|
||||
Hostname: "reauth-untag-node",
|
||||
@@ -3746,7 +3746,7 @@ func TestAuthKeyTaggedToUserOwnedViaReauth(t *testing.T) {
|
||||
// Step 2: Reauth via web auth with EMPTY tags to transition to user-owned
|
||||
nodeKey2 := key.NewNode() // New node key for reauth
|
||||
registrationID := types.MustAuthID()
|
||||
regEntry := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
regEntry := types.NewRegisterAuthRequest(types.Node{
|
||||
MachineKey: machineKey.Public(), // Same machine key
|
||||
NodeKey: nodeKey2.Public(), // Different node key (rotation)
|
||||
Hostname: "authkey-tagged-node",
|
||||
@@ -3945,7 +3945,7 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
|
||||
// This is what happens when running: headscale auth register --auth-id <id> --user alice
|
||||
nodeKey2 := key.NewNode()
|
||||
registrationID := types.MustAuthID()
|
||||
regEntry := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
regEntry := types.NewRegisterAuthRequest(types.Node{
|
||||
MachineKey: machineKey.Public(), // Same machine key as the tagged node
|
||||
NodeKey: nodeKey2.Public(),
|
||||
Hostname: "tagged-orphan-node",
|
||||
|
||||
@@ -41,7 +41,6 @@ var tailscaleToCapVer = map[string]tailcfg.CapabilityVersion{
|
||||
"v1.90": 130,
|
||||
"v1.92": 131,
|
||||
"v1.94": 131,
|
||||
"v1.96": 133,
|
||||
}
|
||||
|
||||
var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{
|
||||
@@ -76,7 +75,6 @@ var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{
|
||||
125: "v1.88",
|
||||
130: "v1.90",
|
||||
131: "v1.92",
|
||||
133: "v1.96",
|
||||
}
|
||||
|
||||
// SupportedMajorMinorVersions is the number of major.minor Tailscale versions supported.
|
||||
@@ -84,4 +82,4 @@ const SupportedMajorMinorVersions = 10
|
||||
|
||||
// MinSupportedCapabilityVersion represents the minimum capability version
|
||||
// supported by this Headscale instance (latest 10 minor versions)
|
||||
const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 109
|
||||
const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 106
|
||||
|
||||
@@ -9,9 +9,10 @@ var tailscaleLatestMajorMinorTests = []struct {
|
||||
stripV bool
|
||||
expected []string
|
||||
}{
|
||||
{3, false, []string{"v1.92", "v1.94", "v1.96"}},
|
||||
{2, true, []string{"1.94", "1.96"}},
|
||||
{3, false, []string{"v1.90", "v1.92", "v1.94"}},
|
||||
{2, true, []string{"1.92", "1.94"}},
|
||||
{10, true, []string{
|
||||
"1.76",
|
||||
"1.78",
|
||||
"1.80",
|
||||
"1.82",
|
||||
@@ -21,7 +22,6 @@ var tailscaleLatestMajorMinorTests = []struct {
|
||||
"1.90",
|
||||
"1.92",
|
||||
"1.94",
|
||||
"1.96",
|
||||
}},
|
||||
{0, false, nil},
|
||||
}
|
||||
@@ -30,7 +30,7 @@ var capVerMinimumTailscaleVersionTests = []struct {
|
||||
input tailcfg.CapabilityVersion
|
||||
expected string
|
||||
}{
|
||||
{109, "v1.78"},
|
||||
{106, "v1.74"},
|
||||
{32, "v1.24"},
|
||||
{41, "v1.30"},
|
||||
{46, "v1.32"},
|
||||
|
||||
+12
-6
@@ -24,6 +24,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
"zgo.at/zcache/v2"
|
||||
)
|
||||
|
||||
//go:embed schema.sql
|
||||
@@ -44,15 +45,19 @@ const (
|
||||
)
|
||||
|
||||
type HSDatabase struct {
|
||||
DB *gorm.DB
|
||||
cfg *types.Config
|
||||
DB *gorm.DB
|
||||
cfg *types.Config
|
||||
regCache *zcache.Cache[types.AuthID, types.AuthRequest]
|
||||
}
|
||||
|
||||
// NewHeadscaleDatabase creates a new database connection and runs migrations.
|
||||
// It accepts the full configuration to allow migrations access to policy settings.
|
||||
//
|
||||
//nolint:gocyclo // complex database initialization with many migrations
|
||||
func NewHeadscaleDatabase(cfg *types.Config) (*HSDatabase, error) {
|
||||
func NewHeadscaleDatabase(
|
||||
cfg *types.Config,
|
||||
regCache *zcache.Cache[types.AuthID, types.AuthRequest],
|
||||
) (*HSDatabase, error) {
|
||||
dbConn, err := openDB(cfg.Database)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -672,7 +677,7 @@ AND auth_key_id NOT IN (
|
||||
continue
|
||||
}
|
||||
|
||||
mergedTags := append(slices.Clone(existingTags), validatedTags...)
|
||||
mergedTags := append(existingTags, validatedTags...)
|
||||
slices.Sort(mergedTags)
|
||||
mergedTags = slices.Compact(mergedTags)
|
||||
|
||||
@@ -833,8 +838,9 @@ WHERE tags IS NOT NULL AND tags != '[]' AND tags != '';
|
||||
}
|
||||
|
||||
db := HSDatabase{
|
||||
DB: dbConn,
|
||||
cfg: cfg,
|
||||
DB: dbConn,
|
||||
cfg: cfg,
|
||||
regCache: regCache,
|
||||
}
|
||||
|
||||
return &db, err
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"zgo.at/zcache/v2"
|
||||
)
|
||||
|
||||
// TestSQLiteMigrationAndDataValidation tests specific SQLite migration scenarios
|
||||
@@ -160,6 +162,10 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func emptyCache() *zcache.Cache[types.AuthID, types.AuthRequest] {
|
||||
return zcache.New[types.AuthID, types.AuthRequest](time.Minute, time.Hour)
|
||||
}
|
||||
|
||||
func createSQLiteFromSQLFile(sqlFilePath, dbPath string) error {
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
@@ -373,6 +379,7 @@ func dbForTestWithPath(t *testing.T, sqlFilePath string) *HSDatabase {
|
||||
Mode: types.PolicyModeDB,
|
||||
},
|
||||
},
|
||||
emptyCache(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("setting up database: %s", err)
|
||||
@@ -432,6 +439,7 @@ func TestSQLiteAllTestdataMigrations(t *testing.T) {
|
||||
Mode: types.PolicyModeDB,
|
||||
},
|
||||
},
|
||||
emptyCache(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
@@ -170,18 +170,6 @@ func ListPreAuthKeys(tx *gorm.DB) ([]types.PreAuthKey, error) {
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// ListPreAuthKeysByUser returns all PreAuthKeys belonging to a specific user.
|
||||
func ListPreAuthKeysByUser(tx *gorm.DB, uid types.UserID) ([]types.PreAuthKey, error) {
|
||||
var keys []types.PreAuthKey
|
||||
|
||||
err := tx.Preload("User").Where("user_id = ?", uint(uid)).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")
|
||||
@@ -331,22 +319,11 @@ func (hsdb *HSDatabase) DeletePreAuthKey(id uint64) error {
|
||||
})
|
||||
}
|
||||
|
||||
// UsePreAuthKey atomically marks a PreAuthKey as used. The UPDATE is
|
||||
// guarded by `used = false` so two concurrent registrations racing for
|
||||
// the same single-use key cannot both succeed: the first commits and
|
||||
// the second returns PAKError("authkey already used"). Without the
|
||||
// guard the previous code (Update("used", true) with no WHERE) would
|
||||
// silently let both transactions claim the key.
|
||||
// UsePreAuthKey marks a PreAuthKey as used.
|
||||
func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
|
||||
res := tx.Model(&types.PreAuthKey{}).
|
||||
Where("id = ? AND used = ?", k.ID, false).
|
||||
Update("used", true)
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("updating key used status in database: %w", res.Error)
|
||||
}
|
||||
|
||||
if res.RowsAffected == 0 {
|
||||
return types.PAKError("authkey already used")
|
||||
err := tx.Model(k).Update("used", true).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating key used status in database: %w", err)
|
||||
}
|
||||
|
||||
k.Used = true
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestCreatePreAuthKey(t *testing.T) {
|
||||
@@ -445,45 +444,3 @@ func TestMultipleLegacyKeysAllowed(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
|
||||
// TestUsePreAuthKeyAtomicCAS verifies that UsePreAuthKey is an atomic
|
||||
// compare-and-set: a second call against an already-used key reports
|
||||
// PAKError("authkey already used") rather than silently succeeding.
|
||||
func TestUsePreAuthKeyAtomicCAS(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
user, err := db.CreateUser(types.User{Name: "atomic-cas"})
|
||||
require.NoError(t, err)
|
||||
|
||||
pakNew, err := db.CreatePreAuthKey(user.TypedID(), false /* reusable */, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
pak, err := db.GetPreAuthKey(pakNew.Key)
|
||||
require.NoError(t, err)
|
||||
require.False(t, pak.Reusable, "test sanity: key must be single-use")
|
||||
|
||||
// First Use should commit cleanly.
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return UsePreAuthKey(tx, pak)
|
||||
})
|
||||
require.NoError(t, err, "first UsePreAuthKey should succeed")
|
||||
|
||||
// Reload from disk to drop the in-memory Used=true the first call
|
||||
// set on the struct, simulating a second concurrent transaction
|
||||
// that loaded the same row before the first one committed.
|
||||
stale, err := db.GetPreAuthKey(pakNew.Key)
|
||||
require.NoError(t, err)
|
||||
|
||||
stale.Used = false
|
||||
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return UsePreAuthKey(tx, stale)
|
||||
})
|
||||
require.Error(t, err, "second UsePreAuthKey on the same single-use key must fail")
|
||||
|
||||
var pakErr types.PAKError
|
||||
require.ErrorAs(t, err, &pakErr,
|
||||
"second UsePreAuthKey error must be a PAKError, got: %v", err)
|
||||
assert.Equal(t, "authkey already used", pakErr.Error())
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ func newSQLiteTestDB() (*HSDatabase, error) {
|
||||
Mode: types.PolicyModeDB,
|
||||
},
|
||||
},
|
||||
emptyCache(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -92,6 +93,7 @@ func newHeadscaleDBFromPostgresURL(t *testing.T, pu *url.URL) *HSDatabase {
|
||||
Mode: types.PolicyModeDB,
|
||||
},
|
||||
},
|
||||
emptyCache(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -65,7 +65,7 @@ func DestroyUser(tx *gorm.DB, uid types.UserID) error {
|
||||
return ErrUserStillHasNodes
|
||||
}
|
||||
|
||||
keys, err := ListPreAuthKeysByUser(tx, uid)
|
||||
keys, err := ListPreAuthKeys(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -160,48 +160,6 @@ func TestDestroyUserErrors(t *testing.T) {
|
||||
require.ErrorIs(t, err, ErrUserStillHasNodes)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Regression test for https://github.com/juanfont/headscale/issues/3154
|
||||
// DestroyUser must only delete the target user's pre-auth keys,
|
||||
// not all pre-auth keys in the database.
|
||||
name: "success_only_deletes_own_preauthkeys",
|
||||
test: func(t *testing.T, db *HSDatabase) {
|
||||
t.Helper()
|
||||
|
||||
userA := db.CreateUserForTest("usera")
|
||||
userB := db.CreateUserForTest("userb")
|
||||
|
||||
// Create 2 keys for userA, 1 key for userB.
|
||||
_, err := db.CreatePreAuthKey(userA.TypedID(), false, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
_, err = db.CreatePreAuthKey(userA.TypedID(), false, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
_, err = db.CreatePreAuthKey(userB.TypedID(), false, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Sanity check: 3 keys exist.
|
||||
allKeys, err := db.ListPreAuthKeys()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, allKeys, 3)
|
||||
|
||||
// Delete userB.
|
||||
err = db.DestroyUser(types.UserID(userB.ID))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Only userA's 2 keys should remain.
|
||||
remaining, err := db.ListPreAuthKeys()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, remaining, 2,
|
||||
"expected 2 keys for userA, got %d — DestroyUser deleted keys from other users",
|
||||
len(remaining))
|
||||
|
||||
for _, key := range remaining {
|
||||
assert.NotNil(t, key.UserID)
|
||||
assert.Equal(t, userA.ID, *key.UserID,
|
||||
"remaining key should belong to userA")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
+1
-37
@@ -3,9 +3,7 @@ package hscontrol
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/arl/statsviz"
|
||||
@@ -14,35 +12,6 @@ import (
|
||||
"tailscale.com/tsweb"
|
||||
)
|
||||
|
||||
// protectedDebugHandler wraps an http.Handler with an access check that
|
||||
// allows requests from loopback, Tailscale CGNAT IPs, and private
|
||||
// (RFC 1918 / RFC 4193) addresses. This extends tsweb.Protected which
|
||||
// only allows loopback and Tailscale IPs.
|
||||
func protectedDebugHandler(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if tsweb.AllowDebugAccess(r) {
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// tsweb.AllowDebugAccess rejects X-Forwarded-For and non-TS IPs.
|
||||
// Additionally allow private/LAN addresses so operators can reach
|
||||
// debug endpoints from their local network without tailscaled.
|
||||
ipStr, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
ip, parseErr := netip.ParseAddr(ipStr)
|
||||
if parseErr == nil && ip.IsPrivate() {
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Error(w, "debug access denied", http.StatusForbidden)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Headscale) debugHTTPServer() *http.Server {
|
||||
debugMux := http.NewServeMux()
|
||||
debug := tsweb.Debugger(debugMux)
|
||||
@@ -324,13 +293,8 @@ func (h *Headscale) debugHTTPServer() *http.Server {
|
||||
}
|
||||
}))
|
||||
|
||||
// statsviz.Register would mount handlers directly on the raw mux,
|
||||
// bypassing the access gate. Build the server by hand and wrap
|
||||
// each handler with protectedDebugHandler.
|
||||
statsvizSrv, err := statsviz.NewServer()
|
||||
err := statsviz.Register(debugMux)
|
||||
if err == nil {
|
||||
debugMux.Handle("/debug/statsviz/", protectedDebugHandler(statsvizSrv.Index()))
|
||||
debugMux.Handle("/debug/statsviz/ws", protectedDebugHandler(statsvizSrv.Ws()))
|
||||
debug.URL("/debug/statsviz", "Statsviz (visualise go metrics)")
|
||||
}
|
||||
|
||||
|
||||
+15
-21
@@ -802,16 +802,27 @@ func (api headscaleV1APIServer) DebugCreateNode(
|
||||
Interface("route-str", request.GetRoutes()).
|
||||
Msg("Creating routes for node")
|
||||
|
||||
hostinfo := tailcfg.Hostinfo{
|
||||
RoutableIPs: routes,
|
||||
OS: "TestOS",
|
||||
Hostname: request.GetName(),
|
||||
}
|
||||
|
||||
registrationId, err := types.AuthIDFromString(request.GetKey())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
regData := &types.RegistrationData{
|
||||
newNode := types.Node{
|
||||
NodeKey: key.NewNode().Public(),
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
Hostname: request.GetName(),
|
||||
Expiry: &time.Time{}, // zero time, not nil — preserves proto JSON round-trip semantics
|
||||
User: user,
|
||||
|
||||
Expiry: &time.Time{},
|
||||
LastSeen: &time.Time{},
|
||||
|
||||
Hostinfo: &hostinfo,
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
@@ -819,27 +830,10 @@ func (api headscaleV1APIServer) DebugCreateNode(
|
||||
Str("registration_id", registrationId.String()).
|
||||
Msg("adding debug machine via CLI, appending to registration cache")
|
||||
|
||||
authRegReq := types.NewRegisterAuthRequest(regData)
|
||||
authRegReq := types.NewRegisterAuthRequest(newNode)
|
||||
api.h.state.SetAuthCacheEntry(registrationId, authRegReq)
|
||||
|
||||
// Echo back a synthetic Node so the debug response surface stays
|
||||
// stable. The actual node is created later by AuthApprove via
|
||||
// HandleNodeFromAuthPath using the cached RegistrationData.
|
||||
echoNode := types.Node{
|
||||
NodeKey: regData.NodeKey,
|
||||
MachineKey: regData.MachineKey,
|
||||
Hostname: regData.Hostname,
|
||||
User: user,
|
||||
Expiry: &time.Time{},
|
||||
LastSeen: &time.Time{},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: request.GetName(),
|
||||
OS: "TestOS",
|
||||
RoutableIPs: routes,
|
||||
},
|
||||
}
|
||||
|
||||
return &v1.DebugCreateNodeResponse{Node: echoNode.Proto()}, nil
|
||||
return &v1.DebugCreateNodeResponse{Node: newNode.Proto()}, nil
|
||||
}
|
||||
|
||||
func (api headscaleV1APIServer) Health(
|
||||
|
||||
@@ -264,284 +264,6 @@ func TestSetTags_CannotRemoveAllTags(t *testing.T) {
|
||||
assert.Nil(t, resp.GetNode())
|
||||
}
|
||||
|
||||
// TestSetTags_ClearsUserIDInDatabase tests that converting a user-owned node
|
||||
// to a tagged node via SetTags correctly persists user_id = NULL in the
|
||||
// database, not just in-memory.
|
||||
// https://github.com/juanfont/headscale/issues/3161
|
||||
func TestSetTags_ClearsUserIDInDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := createTestApp(t)
|
||||
|
||||
user := app.state.CreateUserForTest("tag-owner")
|
||||
err := app.state.UpdatePolicyManagerUsersForTest()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = app.state.SetPolicy([]byte(`{
|
||||
"tagOwners": {"tag:server": ["tag-owner@"]},
|
||||
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]
|
||||
}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Register a user-owned node (untagged PreAuthKey).
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "user-owned-node",
|
||||
},
|
||||
}
|
||||
_, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
require.False(t, node.IsTagged(), "node should start as user-owned")
|
||||
require.True(t, node.UserID().Valid(), "user-owned node must have UserID")
|
||||
|
||||
nodeID := node.ID()
|
||||
|
||||
// Convert to tagged via SetTags API.
|
||||
apiServer := newHeadscaleV1APIServer(app)
|
||||
_, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
|
||||
NodeId: uint64(nodeID),
|
||||
Tags: []string{"tag:server"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify in-memory state is correct.
|
||||
nsNode, found := app.state.GetNodeByID(nodeID)
|
||||
require.True(t, found)
|
||||
assert.True(t, nsNode.IsTagged(), "NodeStore: node should be tagged")
|
||||
assert.False(t, nsNode.UserID().Valid(),
|
||||
"NodeStore: UserID should be nil for tagged node")
|
||||
|
||||
// THE CRITICAL CHECK: verify database has user_id = NULL.
|
||||
dbNode, err := app.state.DB().GetNodeByID(nodeID)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, dbNode.UserID,
|
||||
"Database: user_id must be NULL after converting to tagged node")
|
||||
assert.True(t, dbNode.IsTagged(),
|
||||
"Database: tags must be set")
|
||||
}
|
||||
|
||||
// TestSetTags_NodeDisappearsFromUserListing tests issue #3161:
|
||||
// after converting a user-owned node to tagged, it must no longer appear
|
||||
// when listing nodes filtered by the original user.
|
||||
// https://github.com/juanfont/headscale/issues/3161
|
||||
func TestSetTags_NodeDisappearsFromUserListing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := createTestApp(t)
|
||||
|
||||
user := app.state.CreateUserForTest("list-user")
|
||||
err := app.state.UpdatePolicyManagerUsersForTest()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = app.state.SetPolicy([]byte(`{
|
||||
"tagOwners": {"tag:web": ["list-user@"]},
|
||||
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]
|
||||
}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Register a user-owned node.
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "web-server",
|
||||
},
|
||||
}
|
||||
_, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
|
||||
// Verify node appears under user before tagging.
|
||||
apiServer := newHeadscaleV1APIServer(app)
|
||||
resp, err := apiServer.ListNodes(context.Background(), &v1.ListNodesRequest{
|
||||
User: "list-user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, resp.GetNodes(), 1, "user-owned node should appear under user")
|
||||
|
||||
// Convert to tagged.
|
||||
_, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
|
||||
NodeId: uint64(node.ID()),
|
||||
Tags: []string{"tag:web"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Node must NOT appear when listing by original user.
|
||||
resp, err = apiServer.ListNodes(context.Background(), &v1.ListNodesRequest{
|
||||
User: "list-user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, resp.GetNodes(),
|
||||
"tagged node must not appear when listing nodes for original user")
|
||||
|
||||
// Node must still appear in unfiltered listing.
|
||||
allResp, err := apiServer.ListNodes(context.Background(), &v1.ListNodesRequest{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, allResp.GetNodes(), 1)
|
||||
assert.Contains(t, allResp.GetNodes()[0].GetTags(), "tag:web")
|
||||
}
|
||||
|
||||
// TestSetTags_NodeStoreAndDBConsistency verifies that after SetTags, the
|
||||
// in-memory NodeStore and the database agree on the node's ownership state.
|
||||
// https://github.com/juanfont/headscale/issues/3161
|
||||
func TestSetTags_NodeStoreAndDBConsistency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := createTestApp(t)
|
||||
|
||||
user := app.state.CreateUserForTest("consistency-user")
|
||||
err := app.state.UpdatePolicyManagerUsersForTest()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = app.state.SetPolicy([]byte(`{
|
||||
"tagOwners": {"tag:db": ["consistency-user@"]},
|
||||
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]
|
||||
}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "db-node",
|
||||
},
|
||||
}
|
||||
_, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
|
||||
nodeID := node.ID()
|
||||
|
||||
// Convert to tagged.
|
||||
apiServer := newHeadscaleV1APIServer(app)
|
||||
_, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
|
||||
NodeId: uint64(nodeID),
|
||||
Tags: []string{"tag:db"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// In-memory state.
|
||||
nsNode, found := app.state.GetNodeByID(nodeID)
|
||||
require.True(t, found)
|
||||
|
||||
// Database state.
|
||||
dbNode, err := app.state.DB().GetNodeByID(nodeID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Both must agree: tagged, no UserID.
|
||||
assert.True(t, nsNode.IsTagged(), "NodeStore: should be tagged")
|
||||
assert.True(t, dbNode.IsTagged(), "Database: should be tagged")
|
||||
|
||||
assert.False(t, nsNode.UserID().Valid(),
|
||||
"NodeStore: UserID should be nil")
|
||||
assert.Nil(t, dbNode.UserID,
|
||||
"Database: user_id should be NULL")
|
||||
|
||||
assert.Equal(t,
|
||||
nsNode.UserID().Valid(),
|
||||
dbNode.UserID != nil,
|
||||
"NodeStore and database must agree on UserID state")
|
||||
}
|
||||
|
||||
// TestSetTags_UserDeletionDoesNotCascadeToTaggedNode tests that deleting the
|
||||
// original user does not cascade-delete a node that was converted to tagged
|
||||
// via SetTags. This catches the real-world consequence of stale user_id:
|
||||
// ON DELETE CASCADE would destroy the tagged node.
|
||||
// https://github.com/juanfont/headscale/issues/3161
|
||||
func TestSetTags_UserDeletionDoesNotCascadeToTaggedNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := createTestApp(t)
|
||||
|
||||
user := app.state.CreateUserForTest("doomed-user")
|
||||
err := app.state.UpdatePolicyManagerUsersForTest()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = app.state.SetPolicy([]byte(`{
|
||||
"tagOwners": {"tag:survivor": ["doomed-user@"]},
|
||||
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]
|
||||
}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "survivor-node",
|
||||
},
|
||||
}
|
||||
_, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
|
||||
nodeID := node.ID()
|
||||
|
||||
// Convert to tagged.
|
||||
apiServer := newHeadscaleV1APIServer(app)
|
||||
_, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
|
||||
NodeId: uint64(nodeID),
|
||||
Tags: []string{"tag:survivor"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete the original user.
|
||||
_, err = app.state.DeleteUser(*user.TypedID())
|
||||
require.NoError(t, err)
|
||||
|
||||
// The tagged node must survive in both NodeStore and database.
|
||||
nsNode, found := app.state.GetNodeByID(nodeID)
|
||||
require.True(t, found, "tagged node must survive user deletion in NodeStore")
|
||||
assert.True(t, nsNode.IsTagged())
|
||||
|
||||
dbNode, err := app.state.DB().GetNodeByID(nodeID)
|
||||
require.NoError(t, err, "tagged node must survive user deletion in database")
|
||||
assert.True(t, dbNode.IsTagged())
|
||||
assert.Nil(t, dbNode.UserID)
|
||||
}
|
||||
|
||||
// TestDeleteUser_ReturnsProperChangeSignal tests issue #2967 fix:
|
||||
// When a user is deleted, the state should return a non-empty change signal
|
||||
// to ensure policy manager is updated and clients are notified immediately.
|
||||
|
||||
@@ -80,19 +80,13 @@ func parseCapabilityVersion(req *http.Request) (tailcfg.CapabilityVersion, error
|
||||
return tailcfg.CapabilityVersion(clientCapabilityVersion), nil
|
||||
}
|
||||
|
||||
// verifyBodyLimit caps the request body for /verify. The DERP verify
|
||||
// protocol payload (tailcfg.DERPAdmitClientRequest) is a few hundred
|
||||
// bytes; 4 KiB is generous and prevents an unauthenticated client from
|
||||
// OOMing the public router with arbitrarily large POSTs.
|
||||
const verifyBodyLimit int64 = 4 * 1024
|
||||
|
||||
func (h *Headscale) handleVerifyRequest(
|
||||
req *http.Request,
|
||||
writer io.Writer,
|
||||
) error {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return NewHTTPError(http.StatusRequestEntityTooLarge, "request body too large", fmt.Errorf("reading request body: %w", err))
|
||||
return fmt.Errorf("reading request body: %w", err)
|
||||
}
|
||||
|
||||
var derpAdmitClientRequest tailcfg.DERPAdmitClientRequest
|
||||
@@ -130,8 +124,6 @@ func (h *Headscale) VerifyHandler(
|
||||
return
|
||||
}
|
||||
|
||||
req.Body = http.MaxBytesReader(writer, req.Body, verifyBodyLimit)
|
||||
|
||||
err := h.handleVerifyRequest(req, writer)
|
||||
if err != nil {
|
||||
httpError(writer, err)
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package hscontrol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestHandleVerifyRequest_OversizedBodyRejected verifies that the
|
||||
// /verify handler refuses POST bodies larger than verifyBodyLimit.
|
||||
// The MaxBytesReader is applied in VerifyHandler, so we simulate
|
||||
// the same wrapping here.
|
||||
func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := strings.Repeat("x", int(verifyBodyLimit)+128)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"/verify",
|
||||
bytes.NewReader([]byte(body)),
|
||||
)
|
||||
req.Body = http.MaxBytesReader(rec, req.Body, verifyBodyLimit)
|
||||
|
||||
h := &Headscale{}
|
||||
|
||||
err := h.handleVerifyRequest(req, &bytes.Buffer{})
|
||||
if err == nil {
|
||||
t.Fatal("oversized verify body must be rejected")
|
||||
}
|
||||
|
||||
httpErr, ok := errorAsHTTPError(err)
|
||||
if !ok {
|
||||
t.Fatalf("error must be an HTTPError, got: %T (%v)", err, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusRequestEntityTooLarge, httpErr.Code,
|
||||
"oversized body must surface 413")
|
||||
}
|
||||
|
||||
// errorAsHTTPError is a small local helper that unwraps an HTTPError
|
||||
// from an error chain.
|
||||
func errorAsHTTPError(err error) (HTTPError, bool) {
|
||||
var h HTTPError
|
||||
if errors.As(err, &h) {
|
||||
return h, true
|
||||
}
|
||||
|
||||
return HTTPError{}, false
|
||||
}
|
||||
@@ -128,30 +128,6 @@ func generateMapResponse(nc nodeConnection, mapper *mapper, r change.Change) (*t
|
||||
return nil, fmt.Errorf("generating map response for nodeID %d: %w", nodeID, err)
|
||||
}
|
||||
|
||||
// When a full update (SendAllPeers=true) produces zero visible peers
|
||||
// (e.g., a restrictive policy isolates this node), the resulting
|
||||
// MapResponse has Peers: []*tailcfg.Node{} (empty non-nil slice).
|
||||
//
|
||||
// The Tailscale client only treats Peers as a full authoritative
|
||||
// replacement when len(Peers) > 0 (controlclient/map.go:462).
|
||||
// An empty Peers slice is indistinguishable from a delta response,
|
||||
// so the client silently preserves its existing peer state.
|
||||
//
|
||||
// This matters when a FullUpdate() replaces a pending PolicyChange()
|
||||
// in the batcher (addToBatch short-circuits on HasFull). The
|
||||
// PolicyChange would have computed PeersRemoved via computePeerDiff,
|
||||
// but the FullUpdate path uses WithPeers which sets Peers: [].
|
||||
//
|
||||
// Fix: when a full update results in zero peers, compute the diff
|
||||
// against lastSentPeers and add explicit PeersRemoved entries so
|
||||
// the client correctly clears its stale peer state.
|
||||
if mapResp != nil && r.SendAllPeers && len(mapResp.Peers) == 0 {
|
||||
removedPeers := nc.computePeerDiff(nil)
|
||||
if len(removedPeers) > 0 {
|
||||
mapResp.PeersRemoved = removedPeers
|
||||
}
|
||||
}
|
||||
|
||||
return mapResp, nil
|
||||
}
|
||||
|
||||
@@ -178,20 +154,10 @@ func handleNodeChange(nc nodeConnection, mapper *mapper, r change.Change) error
|
||||
// Send the map response
|
||||
err = nc.send(data)
|
||||
if err != nil {
|
||||
// If the node has no active connections, the data was not
|
||||
// delivered. Do not update lastSentPeers — recording phantom
|
||||
// peer state would corrupt future computePeerDiff calculations,
|
||||
// causing the node to miss peer additions or removals after
|
||||
// reconnection.
|
||||
if errors.Is(err, errNoActiveConnections) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("sending map response to node %d: %w", nodeID, err)
|
||||
}
|
||||
|
||||
// Update peer tracking only after confirmed delivery to at
|
||||
// least one active connection.
|
||||
// Update peer tracking after successful send
|
||||
nc.updateSentPeers(data)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
"zgo.at/zcache/v2"
|
||||
)
|
||||
|
||||
var errNodeNotFoundAfterAdd = errors.New("node not found after adding to batcher")
|
||||
@@ -108,6 +109,11 @@ var allBatcherFunctions = []batcherTestCase{
|
||||
{"Default", NewBatcherAndMapper},
|
||||
}
|
||||
|
||||
// emptyCache creates an empty registration cache for testing.
|
||||
func emptyCache() *zcache.Cache[types.AuthID, types.AuthRequest] {
|
||||
return zcache.New[types.AuthID, types.AuthRequest](time.Minute, time.Hour)
|
||||
}
|
||||
|
||||
// Test configuration constants.
|
||||
const (
|
||||
// Test data configuration.
|
||||
@@ -205,7 +211,10 @@ func setupBatcherWithTestData(
|
||||
}
|
||||
|
||||
// Create database and populate it with test data
|
||||
database, err := db.NewHeadscaleDatabase(cfg)
|
||||
database, err := db.NewHeadscaleDatabase(
|
||||
cfg,
|
||||
emptyCache(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("setting up database: %s", err)
|
||||
}
|
||||
|
||||
@@ -339,9 +339,8 @@ func TestMultiChannelSend_ZeroConnections(t *testing.T) {
|
||||
|
||||
err := mc.send(testMapResponse())
|
||||
|
||||
require.ErrorIs(t, err, errNoActiveConnections,
|
||||
"sending to node with 0 connections should return errNoActiveConnections "+
|
||||
"so callers skip updateSentPeers (prevents phantom peer state)")
|
||||
require.NoError(t, err,
|
||||
"sending to node with 0 connections should succeed silently (rapid reconnection scenario)")
|
||||
}
|
||||
|
||||
func TestMultiChannelSend_NilData(t *testing.T) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package mapper
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
@@ -17,12 +16,6 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// errNoActiveConnections is returned by send when a node has no active
|
||||
// connections (disconnected but kept in the batcher for rapid reconnection).
|
||||
// Callers must not update peer tracking state (lastSentPeers) after this
|
||||
// error because the data was never delivered to any client.
|
||||
var errNoActiveConnections = errors.New("no active connections")
|
||||
|
||||
// connectionEntry represents a single connection to a node.
|
||||
type connectionEntry struct {
|
||||
id string // unique connection ID
|
||||
@@ -250,7 +243,7 @@ func (mc *multiChannelNodeConn) send(data *tailcfg.MapResponse) error {
|
||||
mc.log.Trace().
|
||||
Msg("send: no active connections, skipping")
|
||||
|
||||
return errNoActiveConnections
|
||||
return nil
|
||||
}
|
||||
|
||||
// Copy the slice so we can release the read lock before sending.
|
||||
|
||||
+7
-110
@@ -1,7 +1,6 @@
|
||||
package hscontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -37,28 +36,6 @@ var ErrUnsupportedURLParameterType = errors.New("unsupported URL parameter type"
|
||||
// ErrNoAuthSession is returned when an auth_id does not match any active auth session.
|
||||
var ErrNoAuthSession = errors.New("no auth session found")
|
||||
|
||||
// ErrSSHDstNodeNotFound is returned when the dst node id on a Noise SSH
|
||||
// action request does not match any registered node.
|
||||
var ErrSSHDstNodeNotFound = errors.New("ssh action: unknown dst node id")
|
||||
|
||||
// ErrSSHMachineKeyMismatch is returned when the Noise session's machine
|
||||
// key does not match the dst node referenced in the SSH action URL.
|
||||
var ErrSSHMachineKeyMismatch = errors.New(
|
||||
"ssh action: noise session machine key does not match dst node",
|
||||
)
|
||||
|
||||
// ErrSSHAuthSessionNotBound is returned when an SSH action follow-up
|
||||
// references an auth session that is not bound to an SSH check pair.
|
||||
var ErrSSHAuthSessionNotBound = errors.New(
|
||||
"ssh action: cached auth session is not an SSH-check binding",
|
||||
)
|
||||
|
||||
// ErrSSHBindingMismatch is returned when an SSH action follow-up's
|
||||
// (src, dst) pair does not match the cached binding for its auth_id.
|
||||
var ErrSSHBindingMismatch = errors.New(
|
||||
"ssh action: cached binding does not match request src/dst",
|
||||
)
|
||||
|
||||
const (
|
||||
// ts2021UpgradePath is the path that the server listens on for the WebSockets upgrade.
|
||||
ts2021UpgradePath = "/ts2021"
|
||||
@@ -360,37 +337,6 @@ func (ns *noiseServer) SSHActionHandler(
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate the Noise session: the destination node is the
|
||||
// tailscaled instance asking us whether to permit an incoming SSH
|
||||
// connection, so its Noise session must belong to dst. Without this
|
||||
// check any unauthenticated client could open a Noise tunnel with a
|
||||
// throwaway machine key and pollute lastSSHAuth for arbitrary
|
||||
// (src, dst) pairs, defeating SSH check-mode's stolen-key
|
||||
// protections.
|
||||
dstNode, ok := ns.headscale.state.GetNodeByID(dstNodeID)
|
||||
if !ok {
|
||||
httpError(writer, NewHTTPError(
|
||||
http.StatusNotFound,
|
||||
"dst node not found",
|
||||
fmt.Errorf("%w: %d", ErrSSHDstNodeNotFound, dstNodeID),
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if dstNode.MachineKey() != ns.machineKey {
|
||||
httpError(writer, NewHTTPError(
|
||||
http.StatusUnauthorized,
|
||||
"machine key does not match dst node",
|
||||
fmt.Errorf(
|
||||
"%w: machine key %s, dst node %d",
|
||||
ErrSSHMachineKeyMismatch, ns.machineKey.ShortString(), dstNodeID,
|
||||
),
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reqLog := log.With().
|
||||
Uint64("src_node_id", srcNodeID.Uint64()).
|
||||
Uint64("dst_node_id", dstNodeID.Uint64()).
|
||||
@@ -401,7 +347,6 @@ func (ns *noiseServer) SSHActionHandler(
|
||||
reqLog.Trace().Caller().Msg("SSH action request")
|
||||
|
||||
action, err := ns.sshAction(
|
||||
req.Context(),
|
||||
reqLog,
|
||||
srcNodeID, dstNodeID,
|
||||
req.URL.Query().Get("auth_id"),
|
||||
@@ -439,7 +384,6 @@ func (ns *noiseServer) SSHActionHandler(
|
||||
// 3. Follow-up request — an auth_id is present, wait for the auth
|
||||
// verdict and accept or reject.
|
||||
func (ns *noiseServer) sshAction(
|
||||
ctx context.Context,
|
||||
reqLog zerolog.Logger,
|
||||
srcNodeID, dstNodeID types.NodeID,
|
||||
authIDStr string,
|
||||
@@ -459,7 +403,7 @@ func (ns *noiseServer) sshAction(
|
||||
// Follow-up request with auth_id — wait for the auth verdict.
|
||||
if authIDStr != "" {
|
||||
return ns.sshActionFollowUp(
|
||||
ctx, reqLog, &action, authIDStr,
|
||||
reqLog, &action, authIDStr,
|
||||
srcNodeID, dstNodeID,
|
||||
checkFound,
|
||||
)
|
||||
@@ -482,16 +426,14 @@ func (ns *noiseServer) sshAction(
|
||||
}
|
||||
|
||||
// No auto-approval — create an auth session and hold.
|
||||
return ns.sshActionHoldAndDelegate(reqLog, &action, srcNodeID, dstNodeID)
|
||||
return ns.sshActionHoldAndDelegate(reqLog, &action)
|
||||
}
|
||||
|
||||
// sshActionHoldAndDelegate creates a new auth session bound to the
|
||||
// (src, dst) pair and returns a HoldAndDelegate action that directs the
|
||||
// client to authenticate.
|
||||
// sshActionHoldAndDelegate creates a new auth session and returns a
|
||||
// HoldAndDelegate action that directs the client to authenticate.
|
||||
func (ns *noiseServer) sshActionHoldAndDelegate(
|
||||
reqLog zerolog.Logger,
|
||||
action *tailcfg.SSHAction,
|
||||
srcNodeID, dstNodeID types.NodeID,
|
||||
) (*tailcfg.SSHAction, error) {
|
||||
holdURL, err := url.Parse(
|
||||
ns.headscale.cfg.ServerURL +
|
||||
@@ -515,10 +457,7 @@ func (ns *noiseServer) sshActionHoldAndDelegate(
|
||||
)
|
||||
}
|
||||
|
||||
ns.headscale.state.SetAuthCacheEntry(
|
||||
authID,
|
||||
types.NewSSHCheckAuthRequest(srcNodeID, dstNodeID),
|
||||
)
|
||||
ns.headscale.state.SetAuthCacheEntry(authID, types.NewAuthRequest())
|
||||
|
||||
authURL := ns.headscale.authProvider.AuthURL(authID)
|
||||
|
||||
@@ -545,10 +484,8 @@ func (ns *noiseServer) sshActionHoldAndDelegate(
|
||||
}
|
||||
|
||||
// sshActionFollowUp handles follow-up requests where the client
|
||||
// provides an auth_id. It blocks until the auth session resolves or
|
||||
// the request context is cancelled (e.g. the client disconnects).
|
||||
// provides an auth_id. It blocks until the auth session resolves.
|
||||
func (ns *noiseServer) sshActionFollowUp(
|
||||
ctx context.Context,
|
||||
reqLog zerolog.Logger,
|
||||
action *tailcfg.SSHAction,
|
||||
authIDStr string,
|
||||
@@ -575,49 +512,9 @@ func (ns *noiseServer) sshActionFollowUp(
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the cached binding matches the (src, dst) pair the
|
||||
// follow-up URL claims. Without this check an attacker who knew an
|
||||
// auth_id could submit a follow-up for any other (src, dst) pair
|
||||
// and have its verdict recorded against that pair instead.
|
||||
if !auth.IsSSHCheck() {
|
||||
return nil, NewHTTPError(
|
||||
http.StatusBadRequest,
|
||||
"auth session is not for SSH check",
|
||||
fmt.Errorf("%w: %s", ErrSSHAuthSessionNotBound, authID),
|
||||
)
|
||||
}
|
||||
|
||||
binding := auth.SSHCheckBinding()
|
||||
if binding.SrcNodeID != srcNodeID || binding.DstNodeID != dstNodeID {
|
||||
return nil, NewHTTPError(
|
||||
http.StatusUnauthorized,
|
||||
"src/dst pair does not match auth session",
|
||||
fmt.Errorf(
|
||||
"%w: cached %d->%d, request %d->%d",
|
||||
ErrSSHBindingMismatch,
|
||||
binding.SrcNodeID, binding.DstNodeID,
|
||||
srcNodeID, dstNodeID,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
reqLog.Trace().Caller().Msg("SSH action follow-up")
|
||||
|
||||
var verdict types.AuthVerdict
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// The client disconnected (or its request timed out) before the
|
||||
// auth session resolved. Return an error so the parked goroutine
|
||||
// is freed; without this select sshActionFollowUp would block
|
||||
// until the cache eviction callback signalled FinishAuth, which
|
||||
// could be up to register_cache_expiration (15 minutes).
|
||||
return nil, NewHTTPError(
|
||||
http.StatusUnauthorized,
|
||||
"ssh action follow-up cancelled",
|
||||
ctx.Err(),
|
||||
)
|
||||
case verdict = <-auth.WaitForAuth():
|
||||
}
|
||||
verdict := <-auth.WaitForAuth()
|
||||
|
||||
if !verdict.Accept() {
|
||||
action.Reject = true
|
||||
|
||||
@@ -4,19 +4,15 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
// newNoiseRouterWithBodyLimit builds a chi router with the same body-limit
|
||||
@@ -197,137 +193,3 @@ func TestRegistrationHandler_OversizedBody(t *testing.T) {
|
||||
// for version 0 → returns 400.
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
// newSSHActionRequest builds an httptest request with the chi URL params
|
||||
// SSHActionHandler reads (src_node_id and dst_node_id), so the handler
|
||||
// can be exercised directly without going through the chi router.
|
||||
func newSSHActionRequest(t *testing.T, src, dst types.NodeID) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
url := fmt.Sprintf("/machine/ssh/action/from/%d/to/%d", src.Uint64(), dst.Uint64())
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("src_node_id", strconv.FormatUint(src.Uint64(), 10))
|
||||
rctx.URLParams.Add("dst_node_id", strconv.FormatUint(dst.Uint64(), 10))
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// putTestNodeInStore creates a node via the database test helper and
|
||||
// also stages it into the in-memory NodeStore so handlers that read
|
||||
// NodeStore-backed APIs (e.g. State.GetNodeByID) can see it.
|
||||
func putTestNodeInStore(t *testing.T, app *Headscale, user *types.User, hostname string) *types.Node {
|
||||
t.Helper()
|
||||
|
||||
node := app.state.CreateNodeForTest(user, hostname)
|
||||
app.state.PutNodeInStoreForTest(*node)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
// TestSSHActionHandler_RejectsRogueMachineKey verifies that the SSH
|
||||
// check action endpoint rejects a Noise session whose machine key does
|
||||
// not match the dst node.
|
||||
func TestSSHActionHandler_RejectsRogueMachineKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := createTestApp(t)
|
||||
user := app.state.CreateUserForTest("ssh-handler-user")
|
||||
|
||||
src := putTestNodeInStore(t, app, user, "src-node")
|
||||
dst := putTestNodeInStore(t, app, user, "dst-node")
|
||||
|
||||
// noiseServer carries the wrong machine key — a fresh throwaway key,
|
||||
// not dst.MachineKey.
|
||||
rogue := key.NewMachine().Public()
|
||||
require.NotEqual(t, dst.MachineKey, rogue, "test sanity: rogue key must differ from dst")
|
||||
|
||||
ns := &noiseServer{
|
||||
headscale: app,
|
||||
machineKey: rogue,
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ns.SSHActionHandler(rec, newSSHActionRequest(t, src.ID, dst.ID))
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code,
|
||||
"rogue machine key must be rejected with 401")
|
||||
|
||||
// And the auth cache must not have been mutated by the rejected request.
|
||||
if last, ok := app.state.GetLastSSHAuth(src.ID, dst.ID); ok {
|
||||
t.Fatalf("rejected SSH action must not record lastSSHAuth, got %v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSHActionHandler_RejectsUnknownDst verifies that the handler
|
||||
// rejects a request for a dst_node_id that does not exist with 404.
|
||||
func TestSSHActionHandler_RejectsUnknownDst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := createTestApp(t)
|
||||
user := app.state.CreateUserForTest("ssh-handler-unknown-user")
|
||||
src := putTestNodeInStore(t, app, user, "src-node")
|
||||
|
||||
ns := &noiseServer{
|
||||
headscale: app,
|
||||
machineKey: key.NewMachine().Public(),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ns.SSHActionHandler(rec, newSSHActionRequest(t, src.ID, 9999))
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code,
|
||||
"unknown dst node id must be rejected with 404")
|
||||
}
|
||||
|
||||
// TestSSHActionFollowUp_RejectsBindingMismatch verifies that the
|
||||
// follow-up handler refuses to honour an auth_id whose cached binding
|
||||
// does not match the (src, dst) pair on the request URL. Without this
|
||||
// check an attacker holding any auth_id could route its verdict to a
|
||||
// different node pair.
|
||||
func TestSSHActionFollowUp_RejectsBindingMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := createTestApp(t)
|
||||
user := app.state.CreateUserForTest("ssh-binding-user")
|
||||
|
||||
srcCached := putTestNodeInStore(t, app, user, "src-cached")
|
||||
dstCached := putTestNodeInStore(t, app, user, "dst-cached")
|
||||
srcOther := putTestNodeInStore(t, app, user, "src-other")
|
||||
dstOther := putTestNodeInStore(t, app, user, "dst-other")
|
||||
|
||||
// Mint an SSH-check auth request bound to (srcCached, dstCached).
|
||||
authID := types.MustAuthID()
|
||||
app.state.SetAuthCacheEntry(
|
||||
authID,
|
||||
types.NewSSHCheckAuthRequest(srcCached.ID, dstCached.ID),
|
||||
)
|
||||
|
||||
// Build a follow-up that claims to be for (srcOther, dstOther) but
|
||||
// reuses the bound auth_id. The Noise machineKey matches dstOther so
|
||||
// the outer machine-key check passes — only the binding check
|
||||
// should reject it.
|
||||
ns := &noiseServer{
|
||||
headscale: app,
|
||||
machineKey: dstOther.MachineKey,
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"/machine/ssh/action/from/%d/to/%d?auth_id=%s",
|
||||
srcOther.ID.Uint64(), dstOther.ID.Uint64(), authID.String(),
|
||||
)
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("src_node_id", strconv.FormatUint(srcOther.ID.Uint64(), 10))
|
||||
rctx.URLParams.Add("dst_node_id", strconv.FormatUint(dstOther.ID.Uint64(), 10))
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ns.SSHActionHandler(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code,
|
||||
"binding mismatch must be rejected with 401")
|
||||
}
|
||||
|
||||
+40
-299
@@ -12,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/hashicorp/golang-lru/v2/expirable"
|
||||
"github.com/juanfont/headscale/hscontrol/db"
|
||||
"github.com/juanfont/headscale/hscontrol/templates"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
@@ -20,28 +19,16 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/oauth2"
|
||||
"zgo.at/zcache/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
randomByteSize = 16
|
||||
defaultOAuthOptionsCount = 3
|
||||
authCacheExpiration = time.Minute * 15
|
||||
|
||||
// authCacheMaxEntries bounds the OIDC state→AuthInfo cache to prevent
|
||||
// unauthenticated cache-fill DoS via repeated /register/{auth_id} or
|
||||
// /auth/{auth_id} GETs that mint OIDC state cookies.
|
||||
authCacheMaxEntries = 1024
|
||||
|
||||
// cookieNamePrefixLen is the number of leading characters from a
|
||||
// state/nonce value that getCookieName splices into the cookie name.
|
||||
// State and nonce values that are shorter than this are rejected at
|
||||
// the callback boundary so getCookieName cannot panic on a slice
|
||||
// out-of-range.
|
||||
cookieNamePrefixLen = 6
|
||||
authCacheCleanup = time.Minute * 20
|
||||
)
|
||||
|
||||
var errOIDCStateTooShort = errors.New("oidc state parameter is too short")
|
||||
|
||||
var (
|
||||
errEmptyOIDCCallbackParams = errors.New("empty OIDC callback params")
|
||||
errNoOIDCIDToken = errors.New("extracting ID token")
|
||||
@@ -68,10 +55,9 @@ type AuthProviderOIDC struct {
|
||||
serverURL string
|
||||
cfg *types.OIDCConfig
|
||||
|
||||
// authCache holds auth information between the auth and the callback
|
||||
// steps. It is a bounded LRU keyed by OIDC state, evicting oldest
|
||||
// entries to keep the cache footprint constant under attack.
|
||||
authCache *expirable.LRU[string, AuthInfo]
|
||||
// authCache holds auth information between
|
||||
// the auth and the callback steps.
|
||||
authCache *zcache.Cache[string, AuthInfo]
|
||||
|
||||
oidcProvider *oidc.Provider
|
||||
oauth2Config *oauth2.Config
|
||||
@@ -98,10 +84,9 @@ func NewAuthProviderOIDC(
|
||||
Scopes: cfg.Scope,
|
||||
}
|
||||
|
||||
authCache := expirable.NewLRU[string, AuthInfo](
|
||||
authCacheMaxEntries,
|
||||
nil,
|
||||
authCache := zcache.New[string, AuthInfo](
|
||||
authCacheExpiration,
|
||||
authCacheCleanup,
|
||||
)
|
||||
|
||||
return &AuthProviderOIDC{
|
||||
@@ -203,7 +188,7 @@ func (a *AuthProviderOIDC) authHandler(
|
||||
extras = append(extras, oidc.Nonce(nonce))
|
||||
|
||||
// Cache the registration info
|
||||
a.authCache.Add(state, registrationInfo)
|
||||
a.authCache.Set(state, registrationInfo)
|
||||
|
||||
authURL := a.oauth2Config.AuthCodeURL(state, extras...)
|
||||
log.Debug().Caller().Msgf("redirecting to %s for authentication", authURL)
|
||||
@@ -346,23 +331,36 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
return
|
||||
}
|
||||
|
||||
// If this is a registration flow, render the confirmation
|
||||
// interstitial instead of finalising the registration immediately.
|
||||
// Without an explicit user click, a single GET to
|
||||
// /register/{auth_id} could silently complete a registration when
|
||||
// the IdP allows silent SSO.
|
||||
// If this is a registration flow, then we need to register the node.
|
||||
if authInfo.Registration {
|
||||
a.renderRegistrationConfirmInterstitial(writer, req, authInfo.AuthID, user, nodeExpiry)
|
||||
newNode, err := a.handleRegistration(user, authInfo.AuthID, nodeExpiry)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNodeNotFoundRegistrationCache) {
|
||||
log.Debug().Caller().Str("auth_id", authInfo.AuthID.String()).Msg("registration session expired before authorization completed")
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
httpError(writer, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
content := renderRegistrationSuccessTemplate(user, newNode)
|
||||
|
||||
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
|
||||
if _, err := writer.Write(content.Bytes()); err != nil { //nolint:noinlineerr
|
||||
util.LogErr(err, "Failed to write HTTP response")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// If this is not a registration callback, then it is an SSH
|
||||
// check-mode auth callback. Confirm the OIDC identity is the owner
|
||||
// of the SSH source node before recording approval; without this
|
||||
// check any tailnet user could approve a check-mode prompt for any
|
||||
// other user's node, defeating the stolen-key protection that
|
||||
// check-mode is meant to provide.
|
||||
// If this is not a registration callback, then its a regular authentication callback
|
||||
// and we need to send a response and confirm that the access was allowed.
|
||||
|
||||
authReq, ok := a.h.state.GetAuthCacheEntry(authInfo.AuthID)
|
||||
if !ok {
|
||||
@@ -372,57 +370,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
return
|
||||
}
|
||||
|
||||
if !authReq.IsSSHCheck() {
|
||||
log.Warn().Caller().
|
||||
Str("auth_id", authInfo.AuthID.String()).
|
||||
Msg("OIDC callback hit non-registration path with auth request that is not an SSH check binding")
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for SSH check", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
binding := authReq.SSHCheckBinding()
|
||||
|
||||
srcNode, ok := a.h.state.GetNodeByID(binding.SrcNodeID)
|
||||
if !ok {
|
||||
log.Warn().Caller().
|
||||
Str("auth_id", authInfo.AuthID.String()).
|
||||
Uint64("src_node_id", binding.SrcNodeID.Uint64()).
|
||||
Msg("SSH check src node no longer exists")
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "src node no longer exists", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Strict identity binding: only the user that owns the src node
|
||||
// may approve an SSH check for that node. Tagged source nodes are
|
||||
// rejected because they have no user owner to compare against.
|
||||
if srcNode.IsTagged() || !srcNode.UserID().Valid() {
|
||||
log.Warn().Caller().
|
||||
Str("auth_id", authInfo.AuthID.String()).
|
||||
Uint64("src_node_id", binding.SrcNodeID.Uint64()).
|
||||
Bool("src_is_tagged", srcNode.IsTagged()).
|
||||
Str("oidc_user", user.Username()).
|
||||
Msg("SSH check rejected: src node has no user owner")
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "src node has no user owner", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if srcNode.UserID().Get() != user.ID {
|
||||
log.Warn().Caller().
|
||||
Str("auth_id", authInfo.AuthID.String()).
|
||||
Uint64("src_node_id", binding.SrcNodeID.Uint64()).
|
||||
Uint("src_owner_id", srcNode.UserID().Get()).
|
||||
Uint("oidc_user_id", user.ID).
|
||||
Str("oidc_user", user.Username()).
|
||||
Msg("SSH check rejected: OIDC user is not the owner of src node")
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "OIDC user is not the owner of the SSH source node", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Identity verified — record the verdict for the waiting follow-up.
|
||||
// Send a finish auth verdict with no errors to let the CLI know that the authentication was successful.
|
||||
authReq.FinishAuth(types.AuthVerdict{})
|
||||
|
||||
content := renderAuthSuccessTemplate(user)
|
||||
@@ -435,12 +383,12 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuthProviderOIDC) determineNodeExpiry(idTokenExpiration time.Time) *time.Time {
|
||||
func (a *AuthProviderOIDC) determineNodeExpiry(idTokenExpiration time.Time) time.Time {
|
||||
if a.cfg.UseExpiryFromToken {
|
||||
return &idTokenExpiration
|
||||
return idTokenExpiration
|
||||
}
|
||||
|
||||
return nil
|
||||
return time.Now().Add(a.cfg.Expiry)
|
||||
}
|
||||
|
||||
func extractCodeAndStateParamFromRequest(
|
||||
@@ -453,14 +401,6 @@ func extractCodeAndStateParamFromRequest(
|
||||
return "", "", NewHTTPError(http.StatusBadRequest, "missing code or state parameter", errEmptyOIDCCallbackParams)
|
||||
}
|
||||
|
||||
// Reject states that are too short for getCookieName to splice
|
||||
// into a cookie name. Without this guard a request with
|
||||
// ?state=abc panics on the slice out-of-range and is recovered by
|
||||
// chi's middleware.Recoverer, amplifying small-DoS log noise.
|
||||
if len(state) < cookieNamePrefixLen {
|
||||
return "", "", NewHTTPError(http.StatusBadRequest, "invalid state parameter", errOIDCStateTooShort)
|
||||
}
|
||||
|
||||
return code, state, nil
|
||||
}
|
||||
|
||||
@@ -659,211 +599,15 @@ func (a *AuthProviderOIDC) createOrUpdateUserFromClaim(
|
||||
return user, c, nil
|
||||
}
|
||||
|
||||
// registerConfirmCSRFCookie is the cookie name used to bind the
|
||||
// /register/confirm POST handler's CSRF token to the OIDC callback that
|
||||
// rendered the interstitial. It includes a per-session prefix derived
|
||||
// from the auth ID so cookies for unrelated registrations on the same
|
||||
// browser do not collide.
|
||||
const registerConfirmCSRFCookie = "headscale_register_confirm"
|
||||
|
||||
// renderRegistrationConfirmInterstitial captures the resolved OIDC
|
||||
// identity and node expiry into the cached AuthRequest, sets the CSRF
|
||||
// cookie, and renders the confirmation page that the user must
|
||||
// explicitly submit before the registration is finalised.
|
||||
func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
|
||||
writer http.ResponseWriter,
|
||||
req *http.Request,
|
||||
authID types.AuthID,
|
||||
user *types.User,
|
||||
nodeExpiry *time.Time,
|
||||
) {
|
||||
authReq, ok := a.h.state.GetAuthCacheEntry(authID)
|
||||
if !ok {
|
||||
log.Debug().Caller().Str("auth_id", authID.String()).Msg("registration session expired before authorization completed")
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !authReq.IsRegistration() {
|
||||
log.Warn().Caller().
|
||||
Str("auth_id", authID.String()).
|
||||
Msg("OIDC callback hit registration path with auth request that is not a node registration")
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for node registration", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
csrf, err := util.GenerateRandomStringURLSafe(32)
|
||||
if err != nil {
|
||||
httpError(writer, fmt.Errorf("generating csrf token: %w", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
authReq.SetPendingConfirmation(&types.PendingRegistrationConfirmation{
|
||||
UserID: user.ID,
|
||||
NodeExpiry: nodeExpiry,
|
||||
CSRF: csrf,
|
||||
})
|
||||
|
||||
http.SetCookie(writer, &http.Cookie{
|
||||
Name: registerConfirmCSRFCookie,
|
||||
Value: csrf,
|
||||
Path: "/register/confirm/" + authID.String(),
|
||||
MaxAge: int(authCacheExpiration.Seconds()),
|
||||
Secure: req.TLS != nil,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
regData := authReq.RegistrationData()
|
||||
|
||||
info := templates.RegisterConfirmInfo{
|
||||
FormAction: "/register/confirm/" + authID.String(),
|
||||
CSRFTokenName: registerConfirmCSRFCookie,
|
||||
CSRFToken: csrf,
|
||||
User: user.Display(),
|
||||
Hostname: regData.Hostname,
|
||||
MachineKey: regData.MachineKey.ShortString(),
|
||||
}
|
||||
if regData.Hostinfo != nil {
|
||||
info.OS = regData.Hostinfo.OS
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
|
||||
if _, err := writer.Write([]byte(templates.RegisterConfirm(info).Render())); err != nil { //nolint:noinlineerr
|
||||
util.LogErr(err, "Failed to write HTTP response")
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterConfirmHandler is the POST endpoint behind the OIDC
|
||||
// registration confirmation interstitial. It validates the CSRF cookie
|
||||
// against the form-submitted token, finalises the registration via
|
||||
// handleRegistration, and renders the success page.
|
||||
func (a *AuthProviderOIDC) RegisterConfirmHandler(
|
||||
writer http.ResponseWriter,
|
||||
req *http.Request,
|
||||
) {
|
||||
if req.Method != http.MethodPost {
|
||||
httpError(writer, errMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
authID, err := authIDFromRequest(req)
|
||||
if err != nil {
|
||||
httpError(writer, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Cap the form body. The confirmation form is a single CSRF token,
|
||||
// so 4 KiB is generous and prevents an unauthenticated client from
|
||||
// submitting an arbitrarily large body to ParseForm.
|
||||
req.Body = http.MaxBytesReader(writer, req.Body, 4*1024)
|
||||
|
||||
if err := req.ParseForm(); err != nil { //nolint:noinlineerr,gosec // body is bounded above
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "invalid form", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
formCSRF := req.PostFormValue(registerConfirmCSRFCookie) //nolint:gosec // body is bounded above
|
||||
if formCSRF == "" {
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "missing csrf token", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := req.Cookie(registerConfirmCSRFCookie)
|
||||
if err != nil {
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "missing csrf cookie", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if cookie.Value != formCSRF {
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "csrf token mismatch", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
authReq, ok := a.h.state.GetAuthCacheEntry(authID)
|
||||
if !ok {
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "registration session expired", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pending := authReq.PendingConfirmation()
|
||||
if pending == nil {
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "registration not OIDC-authorized", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if pending.CSRF != cookie.Value {
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "csrf token does not match cached registration", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
user, err := a.h.state.GetUserByID(types.UserID(pending.UserID))
|
||||
if err != nil {
|
||||
httpError(writer, fmt.Errorf("looking up user: %w", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
newNode, err := a.handleRegistration(user, authID, pending.NodeExpiry)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNodeNotFoundRegistrationCache) {
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "registration session expired", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
httpError(writer, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Clear the CSRF cookie now that the registration is final.
|
||||
http.SetCookie(writer, &http.Cookie{
|
||||
Name: registerConfirmCSRFCookie,
|
||||
Value: "",
|
||||
Path: "/register/confirm/" + authID.String(),
|
||||
MaxAge: -1,
|
||||
Secure: req.TLS != nil,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
content := renderRegistrationSuccessTemplate(user, newNode)
|
||||
|
||||
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
|
||||
// renderRegistrationSuccessTemplate's output only embeds
|
||||
// HTML-escaped values from a server-side template, so the gosec
|
||||
// XSS warning is a false positive here.
|
||||
if _, err := writer.Write(content.Bytes()); err != nil { //nolint:noinlineerr,gosec
|
||||
util.LogErr(err, "Failed to write HTTP response")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuthProviderOIDC) handleRegistration(
|
||||
user *types.User,
|
||||
registrationID types.AuthID,
|
||||
expiry *time.Time,
|
||||
expiry time.Time,
|
||||
) (bool, error) {
|
||||
node, nodeChange, err := a.h.state.HandleNodeFromAuthPath(
|
||||
registrationID,
|
||||
types.UserID(user.ID),
|
||||
expiry,
|
||||
&expiry,
|
||||
util.RegisterMethodOIDC,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -927,11 +671,8 @@ func renderAuthSuccessTemplate(
|
||||
}
|
||||
|
||||
// getCookieName generates a unique cookie name based on a cookie value.
|
||||
// Callers must ensure value has at least cookieNamePrefixLen bytes;
|
||||
// extractCodeAndStateParamFromRequest enforces this for the state
|
||||
// parameter, and setCSRFCookie always supplies a 64-byte random value.
|
||||
func getCookieName(baseName, value string) string {
|
||||
return fmt.Sprintf("%s_%s", baseName, value[:cookieNamePrefixLen])
|
||||
return fmt.Sprintf("%s_%s", baseName, value[:6])
|
||||
}
|
||||
|
||||
func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string, error) {
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
package hscontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newConfirmRequest(t *testing.T, authID types.AuthID, formCSRF, cookieCSRF string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
form := strings.NewReader(registerConfirmCSRFCookie + "=" + formCSRF)
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"/register/confirm/"+authID.String(),
|
||||
form,
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: registerConfirmCSRFCookie,
|
||||
Value: cookieCSRF,
|
||||
})
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("auth_id", authID.String())
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// TestRegisterConfirmHandler_RejectsCSRFMismatch verifies that the
|
||||
// /register/confirm POST handler refuses to finalise a pending
|
||||
// registration when the form CSRF token does not match the cookie.
|
||||
func TestRegisterConfirmHandler_RejectsCSRFMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := createTestApp(t)
|
||||
provider := &AuthProviderOIDC{h: app}
|
||||
|
||||
// Mint a pending registration with a stashed pending-confirmation,
|
||||
// as the OIDC callback would have done after resolving the user
|
||||
// identity but before the user clicked the interstitial form.
|
||||
authID := types.MustAuthID()
|
||||
regReq := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
Hostname: "phish-target",
|
||||
})
|
||||
regReq.SetPendingConfirmation(&types.PendingRegistrationConfirmation{
|
||||
UserID: 1,
|
||||
CSRF: "expected-csrf",
|
||||
})
|
||||
app.state.SetAuthCacheEntry(authID, regReq)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
provider.RegisterConfirmHandler(rec,
|
||||
newConfirmRequest(t, authID, "wrong-csrf", "expected-csrf"),
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code,
|
||||
"CSRF cookie/form mismatch must be rejected with 403")
|
||||
|
||||
// And the registration must still be pending — the rejected POST
|
||||
// must not have called handleRegistration.
|
||||
cached, ok := app.state.GetAuthCacheEntry(authID)
|
||||
require.True(t, ok, "rejected POST must not evict the cached registration")
|
||||
require.NotNil(t, cached.PendingConfirmation(),
|
||||
"rejected POST must not clear the pending confirmation")
|
||||
}
|
||||
|
||||
// TestRegisterConfirmHandler_RejectsWithoutPending verifies that
|
||||
// /register/confirm refuses to finalise a registration that did not
|
||||
// first complete the OIDC interstitial. Without this check an attacker
|
||||
// who knew an auth_id could POST directly to the confirm endpoint and
|
||||
// claim the device.
|
||||
func TestRegisterConfirmHandler_RejectsWithoutPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := createTestApp(t)
|
||||
provider := &AuthProviderOIDC{h: app}
|
||||
|
||||
authID := types.MustAuthID()
|
||||
// Cached registration with NO pending confirmation set — i.e. the
|
||||
// OIDC callback has not run yet.
|
||||
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(
|
||||
&types.RegistrationData{Hostname: "no-oidc-yet"},
|
||||
))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
provider.RegisterConfirmHandler(rec,
|
||||
newConfirmRequest(t, authID, "fake", "fake"),
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code,
|
||||
"confirm without prior OIDC pending state must be rejected with 403")
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -47,18 +48,33 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
|
||||
continue DEST_LOOP
|
||||
}
|
||||
|
||||
// If the node has approved subnet routes, preserve
|
||||
// filter rules targeting those routes. SubnetRoutes()
|
||||
// returns only approved, non-exit routes — matching
|
||||
// Tailscale SaaS behavior, which does not generate
|
||||
// filter rules for advertised-but-unapproved routes.
|
||||
// Exit routes (0.0.0.0/0, ::/0) are excluded by
|
||||
// SubnetRoutes() and handled separately via
|
||||
// AllowedIPs/routing.
|
||||
for _, subnetRoute := range node.SubnetRoutes() {
|
||||
if expanded.OverlapsPrefix(subnetRoute) {
|
||||
dests = append(dests, dest)
|
||||
continue DEST_LOOP
|
||||
// If the node advertises routes, ensure filter rules
|
||||
// targeting those routes are preserved. Exit routes
|
||||
// (0.0.0.0/0, ::/0) are skipped because exit nodes
|
||||
// handle traffic via AllowedIPs/routing, not packet
|
||||
// filter rules. This matches Tailscale SaaS behavior.
|
||||
//
|
||||
// NOTE: This uses RoutableIPs (advertised routes)
|
||||
// rather than SubnetRoutes (approved routes). The two
|
||||
// sets are identical in all 98 golden file captures
|
||||
// from Tailscale SaaS, so we cannot determine from
|
||||
// captured data which set Tailscale actually checks.
|
||||
// RoutableIPs is a superset of SubnetRoutes (which
|
||||
// further filters by approval), so this is the more
|
||||
// permissive choice. See MISSING_SAAS_DATA.md for
|
||||
// the capture needed to resolve this ambiguity.
|
||||
if node.Hostinfo().Valid() {
|
||||
routableIPs := node.Hostinfo().RoutableIPs()
|
||||
if routableIPs.Len() > 0 {
|
||||
for _, routableIP := range routableIPs.All() {
|
||||
if tsaddr.IsExitRoute(routableIP) {
|
||||
continue
|
||||
}
|
||||
if expanded.OverlapsPrefix(routableIP) {
|
||||
dests = append(dests, dest)
|
||||
continue DEST_LOOP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,16 +127,23 @@ func reduceCapGrantRule(
|
||||
}
|
||||
}
|
||||
|
||||
// Also check approved subnet routes — nodes serving
|
||||
// approved routes should receive CapGrant rules for
|
||||
// destinations that overlap those routes. SubnetRoutes()
|
||||
// excludes both unapproved and exit routes, matching
|
||||
// Tailscale SaaS behavior.
|
||||
for _, dst := range cg.Dsts {
|
||||
for _, subnetRoute := range node.SubnetRoutes() {
|
||||
if dst.Overlaps(subnetRoute) {
|
||||
// For route overlaps, keep the original prefix.
|
||||
matchingDsts = append(matchingDsts, dst)
|
||||
// Also check routable IPs (subnet routes) — nodes that
|
||||
// advertise routes should receive CapGrant rules for
|
||||
// destinations that overlap their routes.
|
||||
if node.Hostinfo().Valid() {
|
||||
routableIPs := node.Hostinfo().RoutableIPs()
|
||||
if routableIPs.Len() > 0 {
|
||||
for _, dst := range cg.Dsts {
|
||||
for _, routableIP := range routableIPs.All() {
|
||||
if tsaddr.IsExitRoute(routableIP) {
|
||||
continue
|
||||
}
|
||||
|
||||
if dst.Overlaps(routableIP) {
|
||||
// For route overlaps, keep the original prefix.
|
||||
matchingDsts = append(matchingDsts, dst)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,9 +196,6 @@ func TestReduceFilterRules(t *testing.T) {
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.33.0.0/16"),
|
||||
},
|
||||
},
|
||||
peers: types.Nodes{
|
||||
&types.Node{
|
||||
@@ -521,7 +518,6 @@ func TestReduceFilterRules(t *testing.T) {
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("8.0.0.0/16"), netip.MustParsePrefix("16.0.0.0/16")},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("8.0.0.0/16"), netip.MustParsePrefix("16.0.0.0/16")},
|
||||
},
|
||||
peers: types.Nodes{
|
||||
&types.Node{
|
||||
@@ -605,7 +601,6 @@ func TestReduceFilterRules(t *testing.T) {
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("8.0.0.0/8"), netip.MustParsePrefix("16.0.0.0/8")},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("8.0.0.0/8"), netip.MustParsePrefix("16.0.0.0/8")},
|
||||
},
|
||||
peers: types.Nodes{
|
||||
&types.Node{
|
||||
@@ -681,8 +676,7 @@ func TestReduceFilterRules(t *testing.T) {
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("172.16.0.0/24")},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("172.16.0.0/24")},
|
||||
Tags: []string{"tag:access-servers"},
|
||||
Tags: []string{"tag:access-servers"},
|
||||
},
|
||||
peers: types.Nodes{
|
||||
&types.Node{
|
||||
@@ -790,13 +784,16 @@ func TestReduceFilterRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReduceFilterRulesPartialApproval verifies that ReduceFilterRules
|
||||
// only preserves filter rules for routes that are both advertised
|
||||
// (RoutableIPs) AND approved (ApprovedRoutes), matching Tailscale
|
||||
// SaaS behavior. Advertised-but-unapproved routes do not cause rule
|
||||
// preservation: SaaS never generates filter rules for unapproved
|
||||
// routes, and headscale consults node.SubnetRoutes() (which filters
|
||||
// by approval) rather than Hostinfo.RoutableIPs() (which does not).
|
||||
// TestReduceFilterRulesPartialApproval documents the behavior of
|
||||
// ReduceFilterRules when RoutableIPs (advertised) and SubnetRoutes
|
||||
// (approved) differ. In production, SubnetRoutes is always a subset
|
||||
// of RoutableIPs, but the two code paths in ReduceFilterRules handle
|
||||
// them independently:
|
||||
// - Lines 56-68: Check RoutableIPs (advertised, may be unapproved)
|
||||
// - Lines 73-79: Check SubnetRoutes (approved only)
|
||||
//
|
||||
// This test documents that ReduceFilterRules includes filter rules
|
||||
// for advertised-but-unapproved routes via the RoutableIPs check.
|
||||
func TestReduceFilterRulesPartialApproval(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -831,7 +828,7 @@ func TestReduceFilterRulesPartialApproval(t *testing.T) {
|
||||
wantRoutes: []string{"10.33.0.0/16"},
|
||||
},
|
||||
{
|
||||
name: "unapproved-route-excluded",
|
||||
name: "unapproved-route-still-included-via-routableips",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
@@ -856,11 +853,11 @@ func TestReduceFilterRulesPartialApproval(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
// SubnetRoutes() does NOT contain 172.16.0.0/24
|
||||
// (only approved routes), and the ACL dst does not
|
||||
// overlap the node's own IPs, so the rule is
|
||||
// dropped. This matches Tailscale SaaS behavior.
|
||||
wantCount: 0,
|
||||
// The RoutableIPs check (line 63) matches because
|
||||
// 172.16.0.0/24 IS in RoutableIPs. The rule is
|
||||
// kept even though the route is not approved.
|
||||
wantCount: 1,
|
||||
wantRoutes: []string{"172.16.0.0/24"},
|
||||
},
|
||||
{
|
||||
name: "neither-advertised-nor-approved-excluded",
|
||||
|
||||
@@ -414,20 +414,9 @@ func (pol *Policy) compileSSHPolicy(
|
||||
appendRules(taggedPrincipals, 0, false)
|
||||
}
|
||||
} else {
|
||||
// Merge user and tagged principals into a
|
||||
// single list. Tagged principals preserve
|
||||
// per-tag duplication (a node with N tags
|
||||
// appears N times, matching SaaS behavior).
|
||||
var allPrincipals []*tailcfg.SSHPrincipal
|
||||
for _, uid := range userIDs {
|
||||
allPrincipals = append(allPrincipals, principalsByUser[uid]...)
|
||||
}
|
||||
|
||||
allPrincipals = append(allPrincipals, taggedPrincipals...)
|
||||
|
||||
if len(allPrincipals) > 0 {
|
||||
if principals := resolvedAddrsToPrincipals(srcIPs); len(principals) > 0 {
|
||||
rules = append(rules, &tailcfg.SSHRule{
|
||||
Principals: allPrincipals,
|
||||
Principals: principals,
|
||||
SSHUsers: baseUserMap,
|
||||
Action: &action,
|
||||
AcceptEnv: acceptEnv,
|
||||
@@ -568,7 +557,9 @@ func groupSourcesByUser(
|
||||
) ([]uint, map[uint][]*tailcfg.SSHPrincipal, []*tailcfg.SSHPrincipal) {
|
||||
userIPSets := make(map[uint]*netipx.IPSetBuilder)
|
||||
|
||||
var taggedPrincipals []*tailcfg.SSHPrincipal
|
||||
var taggedIPSet netipx.IPSetBuilder
|
||||
|
||||
hasTagged := false
|
||||
|
||||
for _, n := range nodes.All() {
|
||||
if !slices.ContainsFunc(n.IPs(), srcIPs.Contains) {
|
||||
@@ -576,17 +567,9 @@ func groupSourcesByUser(
|
||||
}
|
||||
|
||||
if n.IsTagged() {
|
||||
// Tailscale SaaS resolves autogroup:tagged by
|
||||
// iterating tag membership lists. A node with N
|
||||
// tags produces N copies of its IPs in the
|
||||
// principal list. Match that behavior so the SSH
|
||||
// wire format is identical.
|
||||
for range n.Tags().Len() {
|
||||
for _, ip := range n.IPs() {
|
||||
taggedPrincipals = append(taggedPrincipals,
|
||||
&tailcfg.SSHPrincipal{NodeIP: ip.String()})
|
||||
}
|
||||
}
|
||||
n.AppendToIPSet(&taggedIPSet)
|
||||
|
||||
hasTagged = true
|
||||
|
||||
continue
|
||||
}
|
||||
@@ -622,7 +605,16 @@ func groupSourcesByUser(
|
||||
|
||||
slices.Sort(userIDs)
|
||||
|
||||
return userIDs, principalsByUser, taggedPrincipals
|
||||
var tagged []*tailcfg.SSHPrincipal
|
||||
|
||||
if hasTagged {
|
||||
taggedSet, err := taggedIPSet.IPSet()
|
||||
if err == nil && taggedSet != nil {
|
||||
tagged = ipSetToPrincipals(taggedSet)
|
||||
}
|
||||
}
|
||||
|
||||
return userIDs, principalsByUser, tagged
|
||||
}
|
||||
|
||||
func ipSetToPrefixStringList(ips *netipx.IPSet) []string {
|
||||
|
||||
@@ -45,7 +45,7 @@ func ptrAddr(s string) *netip.Addr {
|
||||
func setupACLCompatUsers() types.Users {
|
||||
return types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.org"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.com"},
|
||||
{Model: gorm.Model{ID: 3}, Name: "freya", Email: "freya@example.com"},
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,27 @@ func cmpOptions() []cmp.Option {
|
||||
|
||||
return a.Bits() < b.Bits()
|
||||
}),
|
||||
// Compare json.RawMessage semantically rather than by exact
|
||||
// bytes to handle indentation differences between the policy
|
||||
// source and the golden capture data.
|
||||
cmp.Comparer(func(a, b json.RawMessage) bool {
|
||||
var va, vb any
|
||||
|
||||
err := json.Unmarshal(a, &va)
|
||||
if err != nil {
|
||||
return string(a) == string(b)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(b, &vb)
|
||||
if err != nil {
|
||||
return string(a) == string(b)
|
||||
}
|
||||
|
||||
ja, _ := json.Marshal(va)
|
||||
jb, _ := json.Marshal(vb)
|
||||
|
||||
return string(ja) == string(jb)
|
||||
}),
|
||||
// Compare tailcfg.RawMessage semantically (it's a string type
|
||||
// containing JSON) to handle indentation differences.
|
||||
cmp.Comparer(func(a, b tailcfg.RawMessage) bool {
|
||||
@@ -461,6 +482,9 @@ func testACLSuccess(
|
||||
|
||||
for nodeName, capture := range tf.Captures {
|
||||
t.Run(nodeName, func(t *testing.T) {
|
||||
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
||||
string(capture.PacketFilterRules) == "null" //nolint:goconst
|
||||
|
||||
node := findNodeByGivenName(nodes, nodeName)
|
||||
if node == nil {
|
||||
t.Skipf(
|
||||
@@ -490,7 +514,21 @@ func testACLSuccess(
|
||||
compiledRules,
|
||||
)
|
||||
|
||||
wantRules := capture.PacketFilterRules
|
||||
// Parse expected rules from JSON
|
||||
var wantRules []tailcfg.FilterRule
|
||||
if !captureIsNull {
|
||||
err = json.Unmarshal(
|
||||
capture.PacketFilterRules,
|
||||
&wantRules,
|
||||
)
|
||||
require.NoError(
|
||||
t,
|
||||
err,
|
||||
"%s/%s: failed to unmarshal expected rules",
|
||||
tf.TestID,
|
||||
nodeName,
|
||||
)
|
||||
}
|
||||
|
||||
// Compare
|
||||
opts := append(
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -40,7 +41,7 @@ import (
|
||||
func setupGrantsCompatUsers() types.Users {
|
||||
return types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.org"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.com"},
|
||||
{Model: gorm.Model{ID: 3}, Name: "freya", Email: "freya@example.com"},
|
||||
}
|
||||
}
|
||||
@@ -334,11 +335,9 @@ func buildGrantsNodesFromCapture(
|
||||
// convertPolicyUserEmails used to map SaaS-side emails to @example.com.
|
||||
// tscap now anonymizes the policy JSON at write time (kratail2tid -> odin,
|
||||
// kristoffer -> thor, monitorpasskeykradalby -> freya), so the captured
|
||||
// FullPolicy is already in its final form and this is a passthrough that
|
||||
// just adapts the captured string value to the []byte that the policy
|
||||
// parser expects.
|
||||
func convertPolicyUserEmails(policyJSON string) []byte {
|
||||
return []byte(policyJSON)
|
||||
// FullPolicy is already in its final form and this is a passthrough.
|
||||
func convertPolicyUserEmails(policyJSON []byte) []byte {
|
||||
return policyJSON
|
||||
}
|
||||
|
||||
// loadGrantTestFile loads and parses a single grant capture HuJSON file.
|
||||
@@ -376,8 +375,8 @@ var grantSkipReasons = map[string]string{
|
||||
// - For success cases: expected packet_filter_rules per node
|
||||
// - For error cases: expected error message
|
||||
//
|
||||
// The test converts Tailscale user email formats to headscale format
|
||||
// (@example.com, @example.org) and runs the policy through unmarshalPolicy,
|
||||
// The test converts Tailscale user email formats (@passkey, @dalby.cc) to
|
||||
// headscale format (@example.com) and runs the policy through unmarshalPolicy,
|
||||
// validate, compileFilterRulesForNode, and ReduceFilterRules.
|
||||
//
|
||||
// 2 tests are skipped for user:*@passkey wildcard (not supported in headscale).
|
||||
@@ -558,7 +557,10 @@ func testGrantSuccess(
|
||||
// kakuna (tag:prod) was frequently offline (132 of 188 success tests).
|
||||
// When offline, packet_filter_rules is null and topology shows
|
||||
// hostname="unknown" with empty tags.
|
||||
if len(capture.PacketFilterRules) == 0 {
|
||||
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
||||
string(capture.PacketFilterRules) == "null"
|
||||
|
||||
if captureIsNull {
|
||||
topoNode, exists := tf.Topology.Nodes[nodeName]
|
||||
if exists && (topoNode.Hostname == "unknown" || topoNode.Hostname == "") {
|
||||
t.Skipf(
|
||||
@@ -569,7 +571,7 @@ func testGrantSuccess(
|
||||
|
||||
return
|
||||
}
|
||||
// Node was online but has empty rules — means Tailscale
|
||||
// Node was online but has null/empty rules — means Tailscale
|
||||
// produced no rules. headscale should also produce no rules.
|
||||
}
|
||||
|
||||
@@ -599,7 +601,21 @@ func testGrantSuccess(
|
||||
|
||||
gotRules = policyutil.ReduceFilterRules(node.View(), gotRules)
|
||||
|
||||
wantRules := capture.PacketFilterRules
|
||||
// Unmarshal Tailscale expected rules from JSON capture
|
||||
var wantRules []tailcfg.FilterRule
|
||||
if !captureIsNull {
|
||||
err = json.Unmarshal(
|
||||
[]byte(capture.PacketFilterRules),
|
||||
&wantRules,
|
||||
)
|
||||
require.NoError(
|
||||
t,
|
||||
err,
|
||||
"%s/%s: failed to unmarshal expected rules from JSON",
|
||||
tf.TestID,
|
||||
nodeName,
|
||||
)
|
||||
}
|
||||
|
||||
// Compare headscale output against Tailscale expected output.
|
||||
// The diff labels show (-tailscale +headscale) to make clear
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -43,6 +43,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"go4.org/netipx"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -244,6 +245,9 @@ func TestRoutesCompat(t *testing.T) {
|
||||
|
||||
for nodeName, capture := range tf.Captures {
|
||||
t.Run(nodeName, func(t *testing.T) {
|
||||
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
||||
string(capture.PacketFilterRules) == "null" //nolint:goconst
|
||||
|
||||
node := findNodeByGivenName(nodes, nodeName)
|
||||
if node == nil {
|
||||
t.Skipf(
|
||||
@@ -272,7 +276,20 @@ func TestRoutesCompat(t *testing.T) {
|
||||
compiledRules,
|
||||
)
|
||||
|
||||
wantRules := capture.PacketFilterRules
|
||||
var wantRules []tailcfg.FilterRule
|
||||
if !captureIsNull {
|
||||
err = json.Unmarshal(
|
||||
capture.PacketFilterRules,
|
||||
&wantRules,
|
||||
)
|
||||
require.NoError(
|
||||
t,
|
||||
err,
|
||||
"%s/%s: failed to unmarshal expected rules",
|
||||
tf.TestID,
|
||||
nodeName,
|
||||
)
|
||||
}
|
||||
|
||||
opts := append(
|
||||
cmpOptions(),
|
||||
@@ -314,11 +331,19 @@ func derivePeerPairsFromCaptures(
|
||||
pairs := make(map[[2]string]bool)
|
||||
|
||||
for dstNodeName, capture := range tf.Captures {
|
||||
if len(capture.PacketFilterRules) == 0 {
|
||||
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
||||
string(capture.PacketFilterRules) == "null"
|
||||
if captureIsNull {
|
||||
continue
|
||||
}
|
||||
|
||||
rules := capture.PacketFilterRules
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
err := json.Unmarshal(capture.PacketFilterRules, &rules)
|
||||
require.NoError(t, err,
|
||||
"%s/%s: failed to unmarshal capture rules",
|
||||
tf.TestID, dstNodeName,
|
||||
)
|
||||
|
||||
// Build an IPSet of all SrcIPs from the capture's filter rules.
|
||||
var srcBuilder netipx.IPSetBuilder
|
||||
@@ -435,11 +460,19 @@ func deriveAllPeerPairsFromCaptures(
|
||||
pairs := make(map[[2]string]bool)
|
||||
|
||||
for dstNodeName, capture := range tf.Captures {
|
||||
if len(capture.PacketFilterRules) == 0 {
|
||||
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
||||
string(capture.PacketFilterRules) == "null"
|
||||
if captureIsNull {
|
||||
continue
|
||||
}
|
||||
|
||||
rules := capture.PacketFilterRules
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
err := json.Unmarshal(capture.PacketFilterRules, &rules)
|
||||
require.NoError(t, err,
|
||||
"%s/%s: failed to unmarshal capture rules",
|
||||
tf.TestID, dstNodeName,
|
||||
)
|
||||
|
||||
// Build an IPSet of all SrcIPs.
|
||||
var srcBuilder netipx.IPSetBuilder
|
||||
@@ -632,11 +665,22 @@ func TestRoutesCompatPeerVisibility(t *testing.T) {
|
||||
// called from a node whose subnet routes overlap the
|
||||
// source CIDRs.
|
||||
for dstNodeName, capture := range tf.Captures {
|
||||
if len(capture.PacketFilterRules) == 0 {
|
||||
captureIsNull := len(
|
||||
capture.PacketFilterRules,
|
||||
) == 0 ||
|
||||
string(
|
||||
capture.PacketFilterRules,
|
||||
) == "null"
|
||||
if captureIsNull {
|
||||
continue
|
||||
}
|
||||
|
||||
rules := capture.PacketFilterRules
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
err := json.Unmarshal(
|
||||
capture.PacketFilterRules, &rules,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Extract destination prefixes from the rules.
|
||||
var dstPrefixes []netip.Prefix
|
||||
@@ -774,6 +818,22 @@ func TestRoutesCompatAutoApproval(t *testing.T) {
|
||||
for _, routeStr := range nodeDef.RoutableIPs {
|
||||
route := netip.MustParsePrefix(routeStr)
|
||||
|
||||
// Skip exit routes (0.0.0.0/0, ::/0).
|
||||
// Tailscale SaaS stores exit routes
|
||||
// under autoApprovers.routes alongside
|
||||
// regular subnets, while headscale uses
|
||||
// a separate autoApprovers.exitNode
|
||||
// field. NodeCanApproveRoute checks the
|
||||
// exitSet (from exitNode) first and
|
||||
// never reaches the autoApproveMap
|
||||
// (from routes), causing a known format
|
||||
// mismatch. This is not a bug — just a
|
||||
// structural difference in where exit
|
||||
// routes are declared.
|
||||
if tsaddr.IsExitRoute(route) {
|
||||
continue
|
||||
}
|
||||
|
||||
wantApproved := approvedSet[route]
|
||||
gotApproved := pm.NodeCanApproveRoute(
|
||||
node.View(), route,
|
||||
@@ -841,12 +901,6 @@ func TestRoutesCompatReduceRoutes(t *testing.T) {
|
||||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if reason, ok := routesSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf("TODO: %s", reason)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Build topology from JSON.
|
||||
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
|
||||
|
||||
@@ -861,45 +915,69 @@ func TestRoutesCompatReduceRoutes(t *testing.T) {
|
||||
"%s: failed to create policy manager", tf.TestID,
|
||||
)
|
||||
|
||||
// For each node that receives filter rules, check that
|
||||
// source nodes can access the destination prefixes via
|
||||
// CanAccessRoute. Match src↔dst pairs PER RULE — a
|
||||
// source IP in rule N should only be tested against
|
||||
// the destinations in rule N, not destinations from
|
||||
// other rules. This matters when tag-based rules
|
||||
// (dst=node-IPs) and CIDR rules (dst=subnets) coexist
|
||||
// on the same node.
|
||||
// For each node that receives filter rules (non-null
|
||||
// capture), extract the DstPort prefixes and SrcIPs.
|
||||
// Then verify that viewer nodes with matching source
|
||||
// identity can access those routes via CanAccessRoute.
|
||||
for dstNodeName, capture := range tf.Captures {
|
||||
if len(capture.PacketFilterRules) == 0 {
|
||||
captureIsNull := len(
|
||||
capture.PacketFilterRules,
|
||||
) == 0 ||
|
||||
string(
|
||||
capture.PacketFilterRules,
|
||||
) == "null"
|
||||
if captureIsNull {
|
||||
continue
|
||||
}
|
||||
|
||||
for ruleIdx, rule := range capture.PacketFilterRules {
|
||||
// Extract destination prefixes from this rule.
|
||||
var dstPrefixes []netip.Prefix
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
err := json.Unmarshal(
|
||||
capture.PacketFilterRules, &rules,
|
||||
)
|
||||
require.NoError(t, err,
|
||||
"%s/%s: failed to unmarshal capture rules",
|
||||
tf.TestID, dstNodeName,
|
||||
)
|
||||
|
||||
// Build the set of destination route prefixes.
|
||||
var dstPrefixes []netip.Prefix
|
||||
|
||||
for _, rule := range rules {
|
||||
for _, dp := range rule.DstPorts {
|
||||
prefix, parseErr := netip.ParsePrefix(dp.IP)
|
||||
prefix, parseErr := netip.ParsePrefix(
|
||||
dp.IP,
|
||||
)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !slices.Contains(dstPrefixes, prefix) {
|
||||
dstPrefixes = append(dstPrefixes, prefix)
|
||||
if !slices.Contains(
|
||||
dstPrefixes, prefix,
|
||||
) {
|
||||
dstPrefixes = append(
|
||||
dstPrefixes, prefix,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(dstPrefixes) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(dstPrefixes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build SrcIPs set from THIS rule only.
|
||||
var srcBuilder netipx.IPSetBuilder
|
||||
// Build SrcIPs set from all rules in this capture.
|
||||
var srcBuilder netipx.IPSetBuilder
|
||||
|
||||
for _, rule := range rules {
|
||||
for _, srcIP := range rule.SrcIPs {
|
||||
prefix, parseErr := netip.ParsePrefix(srcIP)
|
||||
prefix, parseErr := netip.ParsePrefix(
|
||||
srcIP,
|
||||
)
|
||||
if parseErr != nil {
|
||||
addr, parseErr2 := netip.ParseAddr(srcIP)
|
||||
addr, parseErr2 := netip.ParseAddr(
|
||||
srcIP,
|
||||
)
|
||||
if parseErr2 != nil {
|
||||
continue
|
||||
}
|
||||
@@ -911,48 +989,64 @@ func TestRoutesCompatReduceRoutes(t *testing.T) {
|
||||
|
||||
srcBuilder.AddPrefix(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
srcSet, err := srcBuilder.IPSet()
|
||||
require.NoError(t, err)
|
||||
srcSet, err := srcBuilder.IPSet()
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, viewerNode := range nodes {
|
||||
if viewerNode.GivenName == dstNodeName {
|
||||
continue
|
||||
}
|
||||
|
||||
nv := viewerNode.View()
|
||||
|
||||
// Check if viewer matches THIS rule's sources.
|
||||
if !slices.ContainsFunc(nv.IPs(), srcSet.Contains) &&
|
||||
!slices.ContainsFunc(nv.SubnetRoutes(), srcSet.OverlapsPrefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchers, matchErr := pm.MatchersForNode(nv)
|
||||
require.NoError(t, matchErr)
|
||||
|
||||
t.Run(
|
||||
fmt.Sprintf(
|
||||
"%s/rule%d/from_%s",
|
||||
dstNodeName, ruleIdx,
|
||||
viewerNode.GivenName,
|
||||
),
|
||||
func(t *testing.T) {
|
||||
for _, route := range dstPrefixes {
|
||||
canAccess := nv.CanAccessRoute(
|
||||
matchers, route,
|
||||
)
|
||||
assert.Truef(t, canAccess,
|
||||
"%s: viewer %s should "+
|
||||
"access route %s on %s",
|
||||
tf.TestID,
|
||||
viewerNode.GivenName,
|
||||
route, dstNodeName,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
// For each peer node, check if it should be able
|
||||
// to access the dst routes.
|
||||
for _, viewerNode := range nodes {
|
||||
if viewerNode.GivenName == dstNodeName {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine if this viewer has source identity
|
||||
// that matches the capture's SrcIPs.
|
||||
viewerMatchesSrc := false
|
||||
|
||||
nv := viewerNode.View()
|
||||
if slices.ContainsFunc(nv.IPs(), srcSet.Contains) {
|
||||
viewerMatchesSrc = true
|
||||
}
|
||||
|
||||
if !viewerMatchesSrc {
|
||||
if slices.ContainsFunc(nv.SubnetRoutes(), srcSet.OverlapsPrefix) {
|
||||
viewerMatchesSrc = true
|
||||
}
|
||||
}
|
||||
|
||||
if !viewerMatchesSrc {
|
||||
continue
|
||||
}
|
||||
|
||||
matchers, matchErr := pm.MatchersForNode(nv)
|
||||
require.NoError(t, matchErr)
|
||||
|
||||
t.Run(
|
||||
dstNodeName+"/from_"+viewerNode.GivenName,
|
||||
func(t *testing.T) {
|
||||
for _, route := range dstPrefixes {
|
||||
canAccess := nv.CanAccessRoute(
|
||||
matchers, route,
|
||||
)
|
||||
assert.Truef(t, canAccess,
|
||||
"%s: viewer %s (IPs=%v, "+
|
||||
"subnets=%v) should be "+
|
||||
"able to access route "+
|
||||
"%s on node %s (SaaS "+
|
||||
"delivered matching "+
|
||||
"filter rules)",
|
||||
tf.TestID,
|
||||
viewerNode.GivenName,
|
||||
nv.IPs(),
|
||||
nv.SubnetRoutes(),
|
||||
route,
|
||||
dstNodeName,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -979,11 +1073,20 @@ func TestRoutesCompatExitNodePeerVisibility(t *testing.T) {
|
||||
// b8: autogroup:member -> autogroup:internet:* — no filter rules at all
|
||||
exitNodeTests := []struct {
|
||||
testID string
|
||||
// exitNodeNames are nodes with tag:exit
|
||||
exitNodeNames []string
|
||||
// expectedNullAll: if true, all captures should be null
|
||||
expectedNullAll bool
|
||||
}{
|
||||
{testID: "routes-b2-tag-exit-excludes-exit-routes"},
|
||||
{testID: "routes-b8-autogroup-internet-no-filters", expectedNullAll: true},
|
||||
{
|
||||
testID: "routes-b2-tag-exit-excludes-exit-routes",
|
||||
exitNodeNames: []string{"exit-node", "multi-router"},
|
||||
},
|
||||
{
|
||||
testID: "routes-b8-autogroup-internet-no-filters",
|
||||
exitNodeNames: []string{"exit-node", "multi-router"},
|
||||
expectedNullAll: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range exitNodeTests {
|
||||
@@ -992,22 +1095,6 @@ func TestRoutesCompatExitNodePeerVisibility(t *testing.T) {
|
||||
)
|
||||
tf := loadRoutesTestFile(t, file)
|
||||
|
||||
// Derive exit node names from the topology instead of
|
||||
// hard-coding them. The topology's tag arrays are the
|
||||
// source of truth for which nodes are exit nodes.
|
||||
var exitNodeNames []string
|
||||
|
||||
for name, node := range tf.Topology.Nodes {
|
||||
if slices.Contains(node.Tags, "tag:exit") {
|
||||
exitNodeNames = append(exitNodeNames, name)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(exitNodeNames)
|
||||
require.NotEmpty(t, exitNodeNames,
|
||||
"%s: topology has no nodes with tag:exit", tf.TestID,
|
||||
)
|
||||
|
||||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1022,7 +1109,7 @@ func TestRoutesCompatExitNodePeerVisibility(t *testing.T) {
|
||||
if tc.expectedNullAll {
|
||||
// All captures null: verify no CanAccess pairs
|
||||
// involving exit nodes via this ACL alone.
|
||||
for _, exitName := range exitNodeNames {
|
||||
for _, exitName := range tc.exitNodeNames {
|
||||
exitNode := findNodeByGivenName(
|
||||
nodes, exitName,
|
||||
)
|
||||
@@ -1057,9 +1144,9 @@ func TestRoutesCompatExitNodePeerVisibility(t *testing.T) {
|
||||
// For b2: tag:exit -> tag:exit:*, only exit nodes
|
||||
// should see each other. Verify exit<->exit pairs
|
||||
// have CanAccess=true.
|
||||
for i, name1 := range exitNodeNames {
|
||||
for j := i + 1; j < len(exitNodeNames); j++ {
|
||||
name2 := exitNodeNames[j]
|
||||
for i, name1 := range tc.exitNodeNames {
|
||||
for j := i + 1; j < len(tc.exitNodeNames); j++ {
|
||||
name2 := tc.exitNodeNames[j]
|
||||
node1 := findNodeByGivenName(nodes, name1)
|
||||
node2 := findNodeByGivenName(nodes, name2)
|
||||
|
||||
@@ -1093,7 +1180,7 @@ func TestRoutesCompatExitNodePeerVisibility(t *testing.T) {
|
||||
|
||||
// Verify non-exit nodes don't peer with exit nodes
|
||||
// through this restricted ACL.
|
||||
for _, exitName := range exitNodeNames {
|
||||
for _, exitName := range tc.exitNodeNames {
|
||||
exitNode := findNodeByGivenName(
|
||||
nodes, exitName,
|
||||
)
|
||||
@@ -1104,9 +1191,9 @@ func TestRoutesCompatExitNodePeerVisibility(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
isExit := slices.Contains(
|
||||
exitNodeNames, other.GivenName,
|
||||
)
|
||||
// Skip other exit nodes
|
||||
isExit := slices.Contains(tc.exitNodeNames, other.GivenName)
|
||||
|
||||
if isExit {
|
||||
continue
|
||||
}
|
||||
@@ -1178,11 +1265,24 @@ func TestRoutesCompatNoPeersBeyondCaptures(t *testing.T) {
|
||||
// appears in DstPorts of rules delivered to another
|
||||
// node, they must be peers.
|
||||
for dstNodeName, capture := range tf.Captures {
|
||||
if len(capture.PacketFilterRules) == 0 {
|
||||
captureIsNull := len(
|
||||
capture.PacketFilterRules,
|
||||
) == 0 ||
|
||||
string(
|
||||
capture.PacketFilterRules,
|
||||
) == "null"
|
||||
if captureIsNull {
|
||||
continue
|
||||
}
|
||||
|
||||
rules := capture.PacketFilterRules
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
err := json.Unmarshal(
|
||||
capture.PacketFilterRules, &rules,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var dstBuilder netipx.IPSetBuilder
|
||||
|
||||
@@ -1323,7 +1423,9 @@ func TestRoutesCompatNoFalsePositivePeers(t *testing.T) {
|
||||
routerNodes := make(map[string]bool)
|
||||
|
||||
for nodeName, capture := range tf.Captures {
|
||||
if len(capture.PacketFilterRules) > 0 {
|
||||
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
||||
string(capture.PacketFilterRules) == "null"
|
||||
if !captureIsNull {
|
||||
routerNodes[nodeName] = true
|
||||
}
|
||||
}
|
||||
@@ -1364,147 +1466,3 @@ func TestRoutesCompatNoFalsePositivePeers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// prefixStrings converts a slice of netip.Prefix to sorted strings for
|
||||
// readable comparison in test assertions.
|
||||
func prefixStrings(pfxs []netip.Prefix) []string {
|
||||
out := make([]string, len(pfxs))
|
||||
for i, p := range pfxs {
|
||||
out[i] = p.String()
|
||||
}
|
||||
|
||||
sort.Strings(out)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// TestRoutesCompatPeerAllowedIPs validates that headscale computes the same
|
||||
// peer AllowedIPs as Tailscale SaaS. For each golden file that contains
|
||||
// netmap captures, the test compares the AllowedIPs that SaaS delivered
|
||||
// for each peer against what headscale's ReduceRoutes (the core of
|
||||
// RoutesForPeer) would produce.
|
||||
//
|
||||
// This is the authoritative proof that approved exit routes (0.0.0.0/0,
|
||||
// ::/0) belong in peer AllowedIPs: the ea-series captures from SaaS show
|
||||
// exit routes in every peer's AllowedIPs when they are approved, and
|
||||
// absent when they are not (b9 series).
|
||||
func TestRoutesCompatPeerAllowedIPs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files, err := filepath.Glob(
|
||||
filepath.Join("testdata", "routes_results", "routes-*.hujson"),
|
||||
)
|
||||
require.NoError(t, err, "failed to glob test files")
|
||||
require.NotEmpty(t, files)
|
||||
|
||||
// Count how many files actually had netmap data to test.
|
||||
testedFiles := 0
|
||||
|
||||
for _, file := range files {
|
||||
tf := loadRoutesTestFile(t, file)
|
||||
|
||||
// Only test files that have netmap captures with peers.
|
||||
hasNetmap := false
|
||||
|
||||
for _, capture := range tf.Captures {
|
||||
if capture.Netmap != nil && len(capture.Netmap.Peers) > 0 {
|
||||
hasNetmap = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasNetmap {
|
||||
continue
|
||||
}
|
||||
|
||||
testedFiles++
|
||||
|
||||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if reason, ok := routesSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf("TODO: %s", reason)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if tf.Error {
|
||||
t.Skipf("%s: SaaS rejected the policy", tf.TestID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
|
||||
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
|
||||
|
||||
pm, err := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
|
||||
require.NoError(t, err, "%s: failed to create policy manager", tf.TestID)
|
||||
|
||||
for viewerName, capture := range tf.Captures {
|
||||
if capture.Netmap == nil || len(capture.Netmap.Peers) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
viewer := findNodeByGivenName(nodes, viewerName)
|
||||
if viewer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(viewerName, func(t *testing.T) {
|
||||
matchers, err := pm.MatchersForNode(viewer.View())
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, nmPeer := range capture.Netmap.Peers {
|
||||
// Extract the short name from the FQDN.
|
||||
peerName := strings.Split(nmPeer.Name(), ".")[0]
|
||||
|
||||
peer := findNodeByGivenName(nodes, peerName)
|
||||
if peer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute what headscale would put in AllowedIPs.
|
||||
//
|
||||
// The SaaS netmap PrimaryRoutes tells us which
|
||||
// subnet routes won HA election. Exit routes
|
||||
// (0.0.0.0/0, ::/0) are never in PrimaryRoutes
|
||||
// but DO appear in AllowedIPs when approved.
|
||||
// This mirrors RoutesForPeer: primaryRoutes + exitRoutes
|
||||
// filtered through ReduceRoutes.
|
||||
peerPrimaries := nmPeer.PrimaryRoutes().AsSlice()
|
||||
exitRoutes := peer.ExitRoutes()
|
||||
allRoutes := slices.Concat(peerPrimaries, exitRoutes)
|
||||
|
||||
var reducedRoutes []netip.Prefix
|
||||
|
||||
for _, route := range allRoutes {
|
||||
if viewer.View().CanAccessRoute(matchers, route) {
|
||||
reducedRoutes = append(reducedRoutes, route)
|
||||
}
|
||||
}
|
||||
|
||||
gotAllowedIPs := slices.Concat(
|
||||
peer.View().Prefixes(), reducedRoutes,
|
||||
)
|
||||
slices.SortFunc(gotAllowedIPs, netip.Prefix.Compare)
|
||||
|
||||
wantAllowedIPs := nmPeer.AllowedIPs().AsSlice()
|
||||
slices.SortFunc(wantAllowedIPs, netip.Prefix.Compare)
|
||||
|
||||
assert.Equalf(t,
|
||||
prefixStrings(wantAllowedIPs),
|
||||
prefixStrings(gotAllowedIPs),
|
||||
"%s/%s: peer %s AllowedIPs mismatch",
|
||||
tf.TestID, viewerName, peerName,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
require.Positive(t, testedFiles,
|
||||
"no golden files with netmap data found — test is vacuous",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -35,12 +36,6 @@ import (
|
||||
// compatibility tests. Users get norse-god names; nodes get original-151
|
||||
// pokémon names — matching the anonymized identifiers tscap writes into
|
||||
// the capture files (see github.com/kradalby/tscap/anonymize).
|
||||
//
|
||||
// odin and freya live on @example.com; thor lives on @example.org so
|
||||
// that "localpart:*@example.com" resolves to exactly two users
|
||||
// (matching SaaS output) and the "user on a different email domain"
|
||||
// case stays covered by scenarios like ssh-d1 that use
|
||||
// "localpart:*@example.org".
|
||||
func setupSSHDataCompatUsers() types.Users {
|
||||
return types.Users{
|
||||
{
|
||||
@@ -51,7 +46,7 @@ func setupSSHDataCompatUsers() types.Users {
|
||||
{
|
||||
Model: gorm.Model{ID: 2},
|
||||
Name: "thor",
|
||||
Email: "thor@example.org",
|
||||
Email: "thor@example.com",
|
||||
},
|
||||
{
|
||||
Model: gorm.Model{ID: 3},
|
||||
@@ -194,7 +189,7 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
// tscap already rewrites SaaS emails to @example.com.
|
||||
policyJSON := tf.Input.FullPolicy
|
||||
|
||||
pol, err := unmarshalPolicy([]byte(policyJSON))
|
||||
pol, err := unmarshalPolicy(policyJSON)
|
||||
require.NoError(
|
||||
t,
|
||||
err,
|
||||
@@ -226,10 +221,24 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
nodeName,
|
||||
)
|
||||
|
||||
// Build expected SSHPolicy from the typed rules.
|
||||
// Parse expected rules from JSON capture
|
||||
var wantRules []*tailcfg.SSHRule
|
||||
if len(capture.SSHRules) > 0 &&
|
||||
string(capture.SSHRules) != "null" {
|
||||
err = json.Unmarshal(capture.SSHRules, &wantRules)
|
||||
require.NoError(
|
||||
t,
|
||||
err,
|
||||
"%s/%s: failed to unmarshal expected rules",
|
||||
tf.TestID,
|
||||
nodeName,
|
||||
)
|
||||
}
|
||||
|
||||
// Build expected SSHPolicy from the rules
|
||||
var wantSSH *tailcfg.SSHPolicy
|
||||
if len(capture.SSHRules) > 0 {
|
||||
wantSSH = &tailcfg.SSHPolicy{Rules: capture.SSHRules}
|
||||
if len(wantRules) > 0 {
|
||||
wantSSH = &tailcfg.SSHPolicy{Rules: wantRules}
|
||||
}
|
||||
|
||||
// Normalize: treat empty-rules SSHPolicy as nil
|
||||
|
||||
+1059
-1026
File diff suppressed because it is too large
Load Diff
+1372
-1334
File diff suppressed because it is too large
Load Diff
+7313
-7274
File diff suppressed because it is too large
Load Diff
+4639
-4592
File diff suppressed because it is too large
Load Diff
+7088
-7042
File diff suppressed because it is too large
Load Diff
+7991
-7957
File diff suppressed because it is too large
Load Diff
+6683
-6641
File diff suppressed because it is too large
Load Diff
+8358
-8325
File diff suppressed because it is too large
Load Diff
+4637
-4592
File diff suppressed because it is too large
Load Diff
+6265
-6221
File diff suppressed because it is too large
Load Diff
+6263
-6222
File diff suppressed because it is too large
Load Diff
+4637
-4590
File diff suppressed because it is too large
Load Diff
+4639
-4592
File diff suppressed because it is too large
Load Diff
+4699
-4656
File diff suppressed because it is too large
Load Diff
+1716
-1682
File diff suppressed because it is too large
Load Diff
+7480
-7441
File diff suppressed because it is too large
Load Diff
+8251
-8212
File diff suppressed because it is too large
Load Diff
+390
-358
File diff suppressed because it is too large
Load Diff
+6311
-6268
File diff suppressed because it is too large
Load Diff
+7732
-7695
File diff suppressed because it is too large
Load Diff
+7153
-7111
File diff suppressed because it is too large
Load Diff
+7447
-7408
File diff suppressed because it is too large
Load Diff
+7451
-7410
File diff suppressed because it is too large
Load Diff
+5383
-6294
File diff suppressed because it is too large
Load Diff
+6686
-6641
File diff suppressed because it is too large
Load Diff
+6341
-6298
File diff suppressed because it is too large
Load Diff
+6442
-6399
File diff suppressed because it is too large
Load Diff
+6397
-6353
File diff suppressed because it is too large
Load Diff
+6661
-6622
File diff suppressed because it is too large
Load Diff
+1379
-1349
File diff suppressed because it is too large
Load Diff
+8405
-8396
File diff suppressed because it is too large
Load Diff
+7922
-7886
File diff suppressed because it is too large
Load Diff
+7498
-7455
File diff suppressed because it is too large
Load Diff
+5503
-5460
File diff suppressed because it is too large
Load Diff
+5603
-5557
File diff suppressed because it is too large
Load Diff
+6314
-6266
File diff suppressed because it is too large
Load Diff
+6316
-6273
File diff suppressed because it is too large
Load Diff
+7339
-7298
File diff suppressed because it is too large
Load Diff
+7723
-7683
File diff suppressed because it is too large
Load Diff
+7446
-7404
File diff suppressed because it is too large
Load Diff
+7263
-7218
File diff suppressed because it is too large
Load Diff
+6836
-6792
File diff suppressed because it is too large
Load Diff
+6595
-6551
File diff suppressed because it is too large
Load Diff
+5651
-5600
File diff suppressed because it is too large
Load Diff
+5620
-5568
File diff suppressed because it is too large
Load Diff
+1279
-1261
File diff suppressed because it is too large
Load Diff
+7376
-7337
File diff suppressed because it is too large
Load Diff
+6349
-6306
File diff suppressed because it is too large
Load Diff
+8341
-8308
File diff suppressed because it is too large
Load Diff
+6688
-6632
File diff suppressed because it is too large
Load Diff
+1873
-1826
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user