tailscale
Deploy and manage the self-hosted Tailscale/Headscale ecosystem: a Headscale control server, tailscale clients, ACL policies, node lifecycle, subnet routing, DERP relays, and backup/migration. Use when the user mentions Tailscale, Headscale, tailnet, mesh VPN, WireGuard mesh, or
Install
npx skills add https://github.com/magnus919/agent-skills/tree/main/tailscale
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
git clone https://github.com/magnus919/agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole magnus919/agent-skills collection as a plugin from our marketplace. Git is the plain clone.
README
Tailscale / Headscale — Self-Hosted Mesh VPN Bundle
A comprehensive bundle of 7 sub-skills covering the entire self-hosted Tailscale ecosystem using Headscale as the open-source control server. Deploy, configure, and maintain your own WireGuard-based mesh VPN.
Why Install This Bundle
When your agent loads this bundle, it becomes a Tailscale/Headscale infrastructure engineer who can handle the full lifecycle:
- Deploy Headscale — install and configure the control server
- Author tailnet policies — ACL rules, tag-based access control, user groups
- Manage node lifecycle — auth keys, registration, tagging, decommissioning
- Configure clients — install and connect Tailscale to your Headscale server
- Set up routing — subnet routers and exit nodes
- Deploy DERP relays — reliable peer-to-peer connectivity across NATs
- Backup and migrate — regular backup and restoration of the control server
What You Get
| Directory | Purpose |
|---|---|
SKILL.md |
Bundle umbrella — trigger-based auto-loading for 7 sub-skills |
skills/ |
7 sub-skills: headscale-deploy, tailnet-policy, headscale-node-lifecycle, tailscale-client, headscale-routing, headscale-derp, headscale-backup |
scripts/ |
23 shared scripts with --json and --dry-run support |
references/ |
8 reference documents |
templates/ |
6 templates for policy files and configs |
Quick Start
- Deploy Headscale first — install the control server
- Configure tailnet policy — set up ACLs before opening to users
- Manage nodes — register and tag machines on your tailnet
- Install clients — connect machines to your headscale server
Triggers
Load this when you hear "Tailscale," "Headscale," "tailnet," "mesh VPN," "WireGuard mesh," or "self-hosted VPN infrastructure."
Requirements
Bash, Python 3.8+, jq, curl. Access to a Headscale server or the headscale CLI. Tailscale client on target machines.
Why Install This Skill
This skill packages practical, reusable guidance for this domain so you can move from a real task to a dependable result without rebuilding the workflow each time.
Skill manifest
Tailscale + Headscale Skill Bundle
This umbrella skill covers the self-hosted Tailscale ecosystem using Headscale as the open-source control server. It provides 7 sub-skills that are auto-loaded by context.
Auto-Loading by Context
When the user's message matches a trigger keyword, the corresponding sub-skill's SKILL.md is loaded. Multiple sub-skills can load together when triggers overlap.
| Trigger Keywords | Sub-Skill(s) Loaded |
|---|---|
| "deploy headscale", "install headscale", "setup headscale server", "headscale config" | headscale-deploy |
| "ACL", "policy file", "tailnet policy", "access control", "grant", "tag owners" | tailnet-policy |
| "install tailscale", "connect to headscale", "tailscale client", "tailscale up", "tailscale status", "diagnose tailscale", "connectivity" | tailscale-client |
| "auth key", "preauthkey", "register node", "approve node", "tag node", "node list", "decommission node" | headscale-node-lifecycle |
| "subnet router", "exit node", "advertise route", "approve route" | headscale-routing |
| "DERP", "relay", "peer relay", "STUN" | headscale-derp |
| "backup headscale", "restore headscale", "migrate headscale", "headscale backup" | headscale-backup |
| "Tailscale", "Headscale", "tailnet", "mesh VPN", "WireGuard mesh", "self-hosted VPN" | Loads this umbrella SKILL.md for navigation |
Sub-Skill Ordering & Dependencies
headscale-deploy ─────┬──> tailnet-policy ───> headscale-routing
│
├──> headscale-node-lifecycle
│
├──> tailscale-client
│
├──> headscale-derp
│
└──> headscale-backup (prerequisite: a running headscale instance)
- headscale-deploy must be completed first — the others require a running Headscale server
- tailnet-policy (configures ACLs) is recommended before opening the tailnet to other users
- headscale-derp is optional but recommended for reliability across NATs
- headscale-backup should be run regularly on any production deployment
Root Scripts (Shared Utilities)
These live in scripts/ at the bundle root and are available to all sub-skills.
Available Scripts
| Script | Purpose | Invocation |
|---|---|---|
scripts/headscale-health-check.sh |
Probe Headscale server health: version, node count, and DB integrity. Run it after any control-server change and as the first diagnostic when nodes or clients misbehave. | scripts/headscale-health-check.sh --json |
scripts/headscale-backup.sh |
Full backup of the Headscale server (sqlite + config + policy + certs) to a restorable archive. Run it on a schedule for any production deployment and before upgrades or migrations; --dry-run previews without writing. |
scripts/headscale-backup.sh --dry-run |
scripts/headscale-restore.sh |
Restore a Headscale server from a backup archive. Run it during disaster recovery or migration onto a fresh host; always verify node list and policy afterwards. | scripts/headscale-restore.sh --backup headscale-backup-2026-01-01.tar.gz |
scripts/tailscale-status-json.sh |
Structured wrapper around tailscale status --json with peer diagnostics. Run it from any client to check connectivity, peers, and relay/direct paths in machine-readable form. |
scripts/tailscale-status-json.sh |
scripts/test-all.sh |
Smoke test across all bundle scripts (--help, syntax, executability) without requiring a running Headscale. Run it after modifying any bundled script; CI runs it via scripts/check-skill-tests.py. |
bash scripts/test-all.sh |
Templates
Templates live in templates/ and cover common deployment patterns:
templates/docker-compose-headscale.yaml— Headscale + embedded DERP + Traefik TLStemplates/headscale-config.yaml— Annotated full headscale configurationtemplates/policy-allow-all.json— Minimal allow-all policytemplates/policy-deny-all.json— Locked-down deny-all policytemplates/policy-tagged-segmented.json— Tag-based access modeltemplates/derp-map.json— Custom DERP relay map
Environment Variables
| Variable | Used By | Purpose |
|---|---|---|
HEADSCALE_URL |
All | Headscale server URL (e.g. https://headscale.example.com) |
HEADSCALE_API_KEY |
All | Headscale API key (created via headscale apikeys create) |
TAILSCALE_AUTHKEY |
tailscale-client | Pre-authenticated key for non-interactive client setup |
Use the CLI tools
All scripts use --json, --dry-run, and have informative --help output.
Scripts relative to bundle root: scripts/<tool> or skills/<sub-skill>/scripts/<tool>.
See the individual sub-skill SKILL.md for detailed usage.
Prerequisites
- bash, Python 3.8+,
jq, andcurlon the host running the scripts (percompatibility). - A running Headscale server with
HEADSCALE_URLandHEADSCALE_API_KEYset for server-side operations (API key created viaheadscale apikeys create);TAILSCALE_AUTHKEYfor non-interactive client enrollment. - The
tailscaleclient installed on target machines for status and routing sub-skills; theheadscaleCLI (or API access) for control-server administration. - For headscale-backup/restore: filesystem access to the server's sqlite DB, config, policy, and cert paths, plus storage for archives off the control-server host.
Limitations
- This bundle assumes a self-hosted Headscale control plane — it does not manage Tailscale's hosted SaaS (see When not to use).
- Scripts check environment variables at runtime and error helpfully when missing; they do not create credentials themselves.
- Backup/restore operates on the files present on the control-server host; it cannot recover data that was never backed up, and a restore should always be followed by health verification.
- Sub-skill scripts live under
skills/<sub-skill>/scripts/and are documented in their own SKILL.md files, not here.
When not to use
Do not load this umbrella when a task maps to a single sub-skill — load the matching sub-skill directly (e.g. headscale-deploy, tailnet-policy, tailscale-client). It assumes a self-hosted Headscale control server; for Tailscale's hosted SaaS control plane, or for non-Tailscale VPN tooling, use the appropriate network skill instead.
Files (agent-skills)
-
evals
-
evals.json 6.3 KB
{ "schema_version": 1, "skill_name": "tailscale", "evals": [ { "id": "headscale-deploy-full", "prompt": "Stand up a self-hosted tailnet: install and configure the Headscale control server on a fresh Ubuntu 24.04 host with a custom domain, then verify it is healthy.", "expected_output": "Scenario: full control-server deployment. The agent loads headscale-deploy, installs Headscale (release binary or distro package), writes a headscale config binding to the custom domain, enables and starts the service, and verifies health with the health-check script (server version, DB integrity). HEADSCALE_URL and an API key are created/recorded. The deployment records the config file path and the verification output, and does not proceed to client registration until the server reports healthy.", "assertions": [ "The headscale-deploy sub-skill is loaded and followed", "A headscale config is written binding the server to the custom domain", "The service is enabled and started", "Health is verified (version, DB integrity) before proceeding", "HEADSCALE_URL and an API key are available for downstream sub-skills" ] }, { "id": "acl-policy-segmented", "prompt": "We have three teams (eng, ops, finance) sharing one tailnet. Finance nodes must talk only to finance nodes; eng and ops can talk to each other but not to finance; ops nodes can reach the database subnet router. Write the ACL policy.", "expected_output": "Scenario: tagged, segmented ACL policy design. The agent loads tailnet-policy and writes a policy file with tag owners and ACL rules: finance-to-finance only, eng+ops mutual access, ops access to the database subnet router, all other traffic denied by default. The policy is validated against the policy schema before being applied, and the apply step is gated on explicit confirmation because it changes access control on a live tailnet.", "assertions": [ "The tailnet-policy sub-skill is loaded", "Tag owners and tags are declared for the three teams", "ACL rules enforce finance isolation, eng/ops mutual access, and ops-to-database-router access", "Default-deny is preserved for everything else", "Applying the policy to the live tailnet is gated on explicit confirmation" ] }, { "id": "node-lifecycle-preauth", "prompt": "Provision five new servers into the tailnet: generate pre-authenticated keys, install and register the tailscale client on each, approve the nodes, then decommission two of them that are no longer needed.", "expected_output": "Scenario: node lifecycle management. The agent loads headscale-node-lifecycle, generates a reusable (or single-use) pre-auth key, registers the five nodes, approves them, and verifies they show up in node list with the expected tags. The two decommissioned nodes are removed via the headscale CLI and confirmed absent from node list. Auth-key and node lifecycle operations are recorded with the commands and output observed.", "assertions": [ "The headscale-node-lifecycle sub-skill is loaded", "A pre-auth key is generated and used for node registration", "The five nodes are registered and approved", "The two decommissioned nodes are removed and confirmed absent", "Commands and observed outputs are recorded" ] }, { "id": "subnet-router-and-exit-node", "prompt": "Expose our 192.168.10.0/24 lab network through a subnet router on node relay-1, and let the tailnet use node exit-1 as an exit node. Advertise and approve both.", "expected_output": "Scenario: routing configuration. The agent loads headscale-routing, configures relay-1 to advertise 192.168.10.0/24, approves the route on the control server, and configures exit-1 to advertise exit-node capability with the exit-node approval. The final tailscale status shows the routes accepted and the exit node available. The difference between subnet-router and exit-node approval is handled explicitly.", "assertions": [ "The headscale-routing sub-skill is loaded", "relay-1 advertises 192.168.10.0/24 and the route is approved", "exit-1 advertises exit-node capability and is approved", "Final status confirms routes accepted and exit node available", "Subnet-router and exit-node approvals are handled distinctly" ] }, { "id": "derp-relay-config", "prompt": "Direct peer connections fail across the office NAT. Configure a custom DERP relay map for our region and verify clients can relay through it.", "expected_output": "Scenario: DERP relay operation. The agent loads headscale-derp, writes a custom DERP map (region, relay node, STUN) into the headscale config, reloads/restarts the control server, and verifies a client can establish a DERP connection when direct connection fails. The verification observes the relay path in tailscale status rather than assuming the config was picked up.", "assertions": [ "The headscale-derp sub-skill is loaded", "A custom DERP map with region, relay node, and STUN is written", "The headscale config is reloaded/restarted", "Relay connectivity is verified via tailscale status, not assumed" ] }, { "id": "backup-and-restore", "prompt": "Back up our production headscale server (sqlite + config + policy + certs), then simulate a restore onto a fresh host to prove the backup works.", "expected_output": "Scenario: backup and restore drill. The agent loads headscale-backup, runs the backup script to produce an archive, inspects its contents (sqlite, config, policy, certs present), then restores the archive onto a fresh host and verifies the server starts with the same tailnet state (nodes, policy). The restore is exercised, not just documented. The backup archive is noted as the disaster-recovery artifact kept off the control-server host.", "assertions": [ "The headscale-backup sub-skill is loaded", "A backup archive is produced containing sqlite, config, policy, and certs", "The archive is restored onto a fresh host", "The restored server verifiably starts with the same tailnet state", "The backup archive is stored off the control-server host" ] } ] }
-
-
references
-
derp-architecture.md 3.2 KB
# DERP Architecture — Designated Encrypted Relay Protocol ## What DERP Is DERP (Designated Encrypted Relay Protocol) is Tailscale's fallback relay mechanism. When two nodes can't connect directly (NAT traversal failure), traffic is relayed through a DERP server. DERP is end-to-end encrypted — the relay sees encrypted WireGuard packets, not plaintext. ## When DERP Is Used Direct connections fail in these scenarios: - **Symmetric NAT** — Both sides behind symmetric NAT that STUN can't punch through - **Corporate firewalls** — Outbound-only access, no inbound ports - **Double NAT** — Carrier-grade NAT (CGNAT) on mobile/LTE connections - **Cone NAT + Symmetric** — Mixed NAT types that can't negotiate ## DERP vs Direct | Characteristic | Direct (P2P) | DERP Relay | |---|---|---| | Latency | Minimal | Higher (extra hop) | | Bandwidth | Direct link | Limited by relay | | CPU usage | Low | Higher (relay overhead) | | Reliability | Depends on NAT | High (TCP-based) | | Encryption | WireGuard (E2E) | WireGuard (E2E) | ## DERP Components ### STUN (Session Traversal Utilities for NAT) - Runs on port 3478 (UDP) - Determines the node's public IP and port - Used to establish direct connections before falling back to DERP - If STUN is blocked, ALL traffic goes through DERP ### DERP Relay - Runs on port 443 (TCP/WebSocket) - Relays encrypted WireGuard packets between nodes - TLS-protected transport - Each node maintains a persistent WebSocket to its preferred DERP region ## DERP Map A JSON structure defining available relay regions: ```json { "Regions": { "1": { "RegionID": 1, "RegionCode": "us-east", "RegionName": "US East", "Nodes": [ { "Name": "derp-1", "RegionID": 1, "HostName": "derp.example.com", "DERPPort": 443, "STUNPort": 3478, "IPv4": "203.0.113.1", "STUNOnly": false } ] } } } ``` ## Headscale DERP Modes ### Embedded DERP Headscale includes a built-in DERP server. Enable in config.yaml: ```yaml derp: server: enabled: true region_id: 999 region_code: "headscale" region_name: "Headscale Embedded DERP" stun_listen_addr: "0.0.0.0:3478" private_key_path: /var/lib/headscale/derp_server_key ``` Best for: Small tailnets (<50 nodes), testing, single-region deployments. ### Standalone DERP Dedicated DERP server for larger deployments or multiple regions: ```yaml derp: urls: ["https://derp1.example.com/derp"] paths: [] auto_update: true ``` Best for: Multi-region deployments, high availability, production. ## DERP Selection Client selects DERP region automatically: 1. `tailscale status --json` shows `Relay` field per peer 2. `tailscale netcheck` tests latency to all known DERP regions 3. Client connects to lowest-latency region 4. Region switching is automatic if connectivity degrades ## Verifying DERP Usage ```bash # Check if a peer is using DERP tailscale status --json | jq '.Peer[] | select(.Relay != null) | {name: .DNSName, relay: .Relay}' # Test DERP latency tailscale netcheck --json | jq '.Region' # Ping a peer and see the path tailscale ping --verbose 100.x.y.z ``` If ping output says `via <hostname>:443 (derp)` instead of `via <ip>:0 (direct)`, traffic is going through DERP. -
headscale-cli-commands.md 3 KB
# Headscale CLI Commands Reference ## `headscale users` — User management ``` headscale users create <username> Create a new user headscale users list List all users headscale users destroy <username> Delete a user headscale users rename <old> <new> Rename a user headscale users suspend <username> Suspend a user headscale users restore <username> Restore a suspended user ``` ## `headscale nodes` — Node management ``` headscale nodes list List all nodes headscale nodes list --user <user> List nodes for a user headscale nodes list --tags <tag> List nodes with a tag headscale nodes register --user <user> Register a node (interactive) headscale nodes delete <id> Delete a node headscale nodes tag <id> <tag> Tag a node (e.g. tag:server) headscale nodes move <id> <user> Move node to another user headscale nodes expire <id> Expire a node (force logout) headscale nodes rename <id> <name> Rename a node ``` Output columns: ID, Name, IP, User, Tags, Online, Last Seen, OS, Version ## `headscale routes` — Route management ``` headscale routes list List all routes headscale routes list --node <id> List routes for a node headscale routes enable <id> Enable/approve a route headscale routes disable <id> Disable a route headscale routes delete <id> Delete a route ``` Route statuses: pending, enabled, disabled ## `headscale preauthkeys` — Pre-authenticated keys ``` headscale preauthkeys create --user <user> Create a key for a user headscale preauthkeys create --user <user> --tags tag:server Create tagged node key headscale preauthkeys create --user <user> --reusable --expiration 24h headscale preauthkeys create --user <user> --ephemeral headscale preauthkeys list --user <user> List keys for a user headscale preauthkeys expire <key-prefix> Expire a key ``` ## `headscale apikeys` — API key management ``` headscale apikeys create Create API key (prints once, 90d default) headscale apikeys create --expiration 365d Create with custom expiration headscale apikeys list List API key prefixes headscale apikeys expire --prefix <pfx> Expire an API key ``` ## `headscale policy` — Policy management ``` headscale policy list List available policies (if configured) headscale policy test <file> Test policy file for syntax ``` ## `headscale configtest` — Validate configuration ``` headscale configtest Validate config.yaml syntax ``` ## `headscale version` — Version info ``` headscale version Show version headscale version --json Show version as JSON ``` ## `headscale debug` — Debug and diagnostics ``` headscale debug create-node <key> <name> Create a debug node headscale debug metrics Show Prometheus metrics headscale debug pprof Start CPU profiling ``` -
headscale-rest-api.md 2.7 KB
# Headscale REST API Reference Base URL: `https://<headscale.example.com>/api/v1` Auth: `Authorization: Bearer <API_KEY>` Content-Type: `application/json` ## Users ### List all users ``` GET /api/v1/user ``` Response: `{"users": [{"id": "...", "name": "alice", "created_at": "..."}]}` ### Get specific user ``` GET /api/v1/user?name=alice ``` ### Create user ``` POST /api/v1/user {"name": "alice"} ``` ### Delete user ``` DELETE /api/v1/user/<id> ``` ## Pre-auth Keys ### Create pre-auth key ``` POST /api/v1/preauthkey { "user": "alice", "expiration": "2026-01-01T00:00:00Z", "reusable": false, "ephemeral": false, "tags": ["tag:server"] } ``` Response: `{"preauthkey": {"key": "mkey-...", "id": "...", "expiration": "..."}}` ### List pre-auth keys for user ``` GET /api/v1/preauthkey?user=alice ``` ### Expire pre-auth key ``` DELETE /api/v1/preauthkey/<id> ``` ## Node Registration & Management ### Register a web-authenticated node ``` POST /api/v1/auth/register {"user": "alice", "authId": "<auth-id-from-browser>"} ``` ### List nodes ``` GET /api/v1/node GET /api/v1/node?user=alice ``` Response: `{"nodes": [{"id": "...", "name": "...", "ip_addresses": ["100.x.y.z"], "tags": ["tag:server"], "online": true, "last_seen": "...", "expiry": "...", "created_at": "..."}]}` ### Get node by ID ``` GET /api/v1/node/<id> ``` ### Delete node ``` DELETE /api/v1/node/<id> ``` ### Tag a node ``` POST /api/v1/node/<id>/tags {"tags": ["tag:server", "tag:prod"]} ``` ### Move node to user ``` POST /api/v1/node/<id>/user {"user": "bob"} ``` ### Set node tags (replace all) ``` POST /api/v1/node/<id>/tags {"tags": ["tag:server"]} ``` ### Rename node ``` POST /api/v1/node/<id>/rename {"name": "new-name"} ``` ## Routes ### List routes ``` GET /api/v1/route ``` Response: `{"routes": [{"id": "...", "node_id": "...", "prefix": "192.168.1.0/24", "advertised": true, "enabled": false, "is_primary": false}]}` ### Enable route ``` POST /api/v1/route/<id>/enable ``` ### Disable route ``` POST /api/v1/route/<id>/disable ``` ### Delete route ``` DELETE /api/v1/route/<id> ``` ## API Keys (self-management) ### List API keys ``` GET /api/v1/apikey ``` ### Expire API key ``` DELETE /api/v1/apikey/<prefix> ``` ## Health & Diagnostics ### Health check ``` GET /health ``` Response: Health status (varies by deployment) ### Version ``` GET /version ``` Response: Headscale version string ### Swagger documentation ``` GET /swagger ``` Response: Interactive API documentation ## Error Responses All endpoints return standard HTTP codes: - 200: Success - 400: Bad request (validation error) - 401: Unauthorized (bad or missing API key) - 404: Resource not found - 500: Internal server error Error body: `{"message": "error description", "details": {...}}` -
identity-model.md 2.5 KB
# Tailscale/Headscale Identity Model ## Personal Nodes vs Tagged Nodes Tailscale distinguishes two types of nodes, which affects how they're managed in policies: ### Personal Nodes - Owned by a human user (e.g. `alice@`) - End-user devices: laptops, phones, workstations - Managed by a single user - Can Tailscale SSH into devices (if policy allows) - Examples: laptop, phone, iPad - Registration: `tailscale up --login-server <URL>` → web auth → admin approves ### Tagged Nodes - Owned by tags (e.g. `tag:server`), not a user - Service/infrastructure nodes: servers, CI runners, database hosts - Managed by team (any tagOwner can administer) - **Cannot** Tailscale SSH into personal nodes (by design) - Land under the special user `tagged-devices` in headscale - Registration: `tailscale up --login-server <URL> --advertise-tags tag:server` - Must be authorized via `tagOwners` in policy file ## Registration Methods ### 1. Web Authentication (Interactive) ``` # Client side tailscale up --login-server https://headscale.example.com # → Opens browser with auth URL # → Displays Auth ID # Admin side headscale auth register --user alice --auth-id <AUTH_ID> ``` Best for: personal end-user devices, interactive setup. ### 2. Pre-Authenticated Key (Non-Interactive) ``` # Admin creates key headscale preauthkeys create --user alice --expiration 24h # Client uses key tailscale up --login-server https://headscale.example.com --authkey <KEY> ``` Best for: automation, CI/CD, headless servers, ephemeral nodes. ### 3. Tagged Node Registration ``` # Admin creates key for tagged node headscale preauthkeys create --user alice --tags tag:server # Client registers with tags tailscale up --login-server https://headscale.example.com --authkey <KEY> ``` Best for: service nodes that shouldn't be tied to a specific user. ## Key Properties | Property | Pre-Auth Key | Description | |---|---|---| | `--reusable` | No (default) | One-time use. Set to allow multiple nodes with same key. | | `--expiration` | 1h (default) | Time limit. Use `0` for no expiry. | | `--ephemeral` | No (default) | Ephemeral nodes are removed from tailnet when they disconnect. | ## User Management ``` headscale users create <name> Create user headscale users list List all users headscale users destroy <name> Delete user (removes their nodes) headscale users rename <old> <new> Rename user headscale users suspend <name> Suspend user headscale users restore <name> Restore suspended user ``` -
policy-syntax-reference.md 3.5 KB
# Tailscale Policy Syntax Reference (huJSON) Headscale uses the same policy file format as Tailscale: huJSON (human JSON — allows comments and trailing commas). The policy file is loaded from `policy.path` in config.yaml and reloaded via SIGHUP. ## Structure The policy file is a single JSON object with these sections: ```json { "acls": [...], // Legacy ACL rules "grants": [...], // Modern access rules (preferred) "tagOwners": {...}, // Who can create tagged nodes "autoApprovers": {...}, // Auto-approve routes and exit nodes "ssh": [...], // Tailscale SSH rules "nodeAttrs": [...], // Node attributes "groups": {...}, // Named groups "hosts": {...}, // Hostname aliases "tests": [...], // Policy test definitions "sshTests": [...] // SSH policy tests } ``` ## Grants (Modern — Preferred) Grants replace ACLs for new deployments: ```json { "grants": [ { "src": ["alice@"], "dst": ["tag:server"], "ip": ["*"] } ] } ``` - `src`: Source users, tags, or autogroups - `dst`: Destination users, tags, or autogroups - `ip`: Port/protocol rules: `["*"]` (all), `["80"]`, `["80,443"]`, `["tcp:22"]` - `app`: App connector rules - `via`: Route filtering for cross-subnet access ## ACLs (Legacy) ```json { "acls": [ {"action": "accept", "src": ["alice@"], "dst": ["tag:server:*"]}, {"action": "accept", "src": ["tag:server"], "dst": ["tag:db:*"]} ] } ``` - `action`: "accept" (allow) or no default deny - `src`: Source users/tags - `dst`: `<tag>:<port>` — destination and port ## TagOwners ```json { "tagOwners": { "tag:server": ["alice@"], "tag:db": ["alice@", "bob@"], "tag:ci": ["autogroup:admin"] } } ``` Only users listed as tagOwners can register nodes with those tags. ## Auto Approvers Automatically approve subnet routes and exit nodes: ```json { "autoApprovers": { "routes": { "192.168.0.0/16": ["alice@"], "10.0.0.0/8": ["bob@"] }, "exitNode": ["tag:server"] } } ``` ## Autogroups Built-in dynamic groups: - `autogroup:internet` — Access to internet via exit nodes (dst only) - `autogroup:member` — All personal (user-owned, untagged) devices - `autogroup:tagged` — All tagged (service) devices - `autogroup:admin` — Admin users ## Tailscale SSH ```json { "ssh": [ { "action": "accept", "src": ["alice@"], "dst": ["tag:server"], "users": ["ubuntu", "root"] }, { "action": "check", "src": ["autogroup:member"], "dst": ["autogroup:member"], "users": ["*"] } ] } ``` - `action`: "accept" (no check) or "check" (requires key verification) - `users`: Remote usernames allowed ## Node Attributes ```json { "nodeAttrs": [ { "target": ["tag:gateway"], "attr": ["allow-exit-node", "allow-subnet-routing"] } ] } ``` ## Groups ```json { "groups": { "group:engineering": ["alice@", "bob@", "carol@"], "group:infra": ["alice@", "dave@"] } } ``` ## Tests ```json { "tests": [ { "src": "alice@", "accept": ["tag:server:80", "tag:server:443"], "deny": ["tag:db:22"] } ] } ``` Also `sshTests` for SSH rules. ## Gotchas for Headscale - **Device posture** is NOT supported - **IP sets** are NOT supported - **OIDC groups** cannot be used in ACLs (though OIDC auth works for login) - **Funnel/Serve** not supported - **Policy reload**: SIGHUP, not automatic - **Autogroup:admin**: Not available if no OIDC admin group configured - Default (no policy file) = allow all traffic between nodes -
routing-reference.md 3.2 KB
# Routing Reference — Subnet Routers & Exit Nodes ## Subnet Routers A subnet router extends the tailnet to devices that can't run the Tailscale client (printers, NAS devices, IoT, etc.). ### Setup 1. **On the gateway node** (the machine that can reach the target subnet): ```bash tailscale up --login-server https://headscale.example.com --advertise-routes=192.168.1.0/24 --accept-routes ``` 2. **Approve the route in Headscale**: ```bash headscale routes list # Find route ID headscale routes enable <route-id> # Approve ``` 3. **On client nodes** that need access: ```bash tailscale up --login-server https://headscale.example.com --accept-routes ``` ### Auto-Approval Configure headscale policy to auto-approve routes: ```json { "autoApprovers": { "routes": { "192.168.0.0/16": ["alice@"], "10.0.0.0/8": ["autogroup:admin"] } } } ``` ### Route Filtering (Grants Via) Restrict cross-subnet access to specific ports: ```json { "grants": [ { "src": ["autogroup:member"], "dst": ["tag:gateway"], "ip": ["*"], "via": ["192.168.1.0/24:80,443"] } ] } ``` ### SNAT (Source NAT) By default, subnet router traffic appears to originate from the router's own IP. Disable `--snat-subnet-routes=false` if the subnet needs to see the origina-tailing client's tailnet IP (requires proper routing back). ## Exit Nodes An exit node routes all internet traffic from other tailnet clients through the exit node's internet connection. ### Setup 1. **On the exit node** (the machine that will provide internet access): ```bash tailscale up --advertise-exit-node ``` 2. **Approve in Headscale**: ```bash headscale routes list # Find the exit node route headscale routes enable <route-id> ``` 3. **On client devices**: ```bash # Use a specific exit node tailscale up --exit-node=100.x.y.z # Or, to use an exit node while still accessing local LAN tailscale up --exit-node=100.x.y.z --exit-node-allow-lan-access ``` ### Auto-Approval for Exit Nodes ```json { "autoApprovers": { "exitNode": ["tag:gateway", "alice@"] } } ``` ## Route Management Commands ### Headscale server side: ```bash headscale routes list # All routes headscale routes list --node <node-id> # Routes for one node headscale routes enable <route-id> # Approve/Enable headscale routes disable <route-id> # Disable headscale routes delete <route-id> # Remove route ``` ### Client side: ```bash tailscale up --advertise-routes=10.0.0.0/24 # Advertise subnet tailscale up --advertise-routes=10.0.0.0/24,192.168.1.0/24 # Multiple subnets tailscale up --advertise-exit-node # Advertise as exit node tailscale up --accept-routes # Accept all advertised routes tailscale up --exit-node=100.x.y.z # Use an exit node tailscale up --exit-node-allow-lan-access # Allow LAN with exit node ``` ## Diagnostics ```bash # Check if routes are accepted tailscale status --json | jq '.Peer[].Routes' # Check which exit node is active tailscale status | grep -E '^100\.' # Check subnet router status tailscale status --self --json | jq '.Self.Routes' ``` -
tailscale-client-flags.md 3.8 KB
# Client Flags Reference — Tailscale CLI for Headscale ## `tailscale up` — Connect to a tailnet All flags are used with `tailscale up` and are idempotent — running `tailscale up` again with different flags updates the configuration. | Flag | Purpose | Used With Headscale? | |---|---|---| | `--login-server <URL>` | Point client at self-hosted Headscale | **Required** | | `--authkey <key>` | Non-interactive auth with pre-auth key | Yes | | `--advertise-tags tag:<name>` | Tag this node as a service (not user-owned) | Yes | | `--advertise-routes <cidr>` | Advertise subnet routes (e.g. 192.168.1.0/24) | Yes | | `--accept-routes` | Accept advertised routes from subnet routers | Yes | | `--accept-dns` | Accept MagicDNS configuration | Yes (default) | | `--advertise-exit-node` | Make this node an exit node | Yes | | `--exit-node <IP>` | Route traffic through this exit node | Yes | | `--exit-node-allow-lan-access` | Allow LAN access while using exit node | Yes | | `--shields-up` | Block all incoming connections | Yes | | `--snat-subnet-routes` | SNAT traffic from subnet routes (default: on) | Yes | | `--netfilter-mode <mode>` | off/noflush/on (iptables management) | Yes | | `--accept-risk <risk>` | Accept known risks (e.g. `all`) | Yes | | `--reset` | Reset all configuration to defaults | Yes | | `--hostname <name>` | Override machine hostname in tailnet | Yes | | `--operator <user>` | Allow non-root user to run tailscale commands | Yes | ## `tailscale status` — Show tailnet status ``` tailscale status # Human-readable table tailscale status --json # Machine-readable JSON tailscale status --peers # Show all peers (not just current) tailscale status --active # Show only active peers tailscale status --self # Show only this node tailscale status --watch # Watch for changes ``` ## `tailscale ping` — Test connectivity to a peer ``` tailscale ping <hostname-or-ip> # Basic ping tailscale ping --verbose <host> # Show DERP vs direct tailscale ping -c 3 <host> # Count (number of pings) tailscale ping --timeout 10s <host> # Timeout tailscale ping --c 3 --verbose 100.x.y.z # Standard diagnostic ``` Exit codes: 0 = reached, 1 = not reached, 2 = error. ## `tailscale netcheck` — NAT traversal diagnostics ``` tailscale netcheck # Human-readable report tailscale netcheck --json # Machine-readable ``` Reports: NAT type, DERP latency per region, IPv4/IPv6 capability, captive portal detection. ## `tailscale version` — Show version info ``` tailscale version # Client version tailscale version --daemon # tailscaled version tailscale version --json # Structured output ``` ## `tailscale ssh` — SSH into tailnet nodes ``` tailscale ssh <user>@<host> # SSH via tailnet (uses Tailscale SSH if configured) ``` Requires Tailscale SSH to be configured in the policy file. ## `tailscale serve` — Expose local services (NOT in Headscale) ``` tailscale serve --bg 3000 # Run as background service ``` **Note:** `tailscale serve` and `tailscale funnel` are not supported in Headscale. ## `tailscale file` — File sharing (Taildrop/Taildrive) ``` tailscale file get <url> # Receive a file tailscale file send <path> # Send a file tailscale file cp <path> <target>:<path> # Copy file (Taildrive) ``` ## `tailscale cert` — Get TLS certificate ``` tailscale cert <domain> # Get cert for MagicDNS name ``` ## Other Useful Commands ``` tailscale down # Disconnect from tailnet tailscale logout # Log out (re-authenticate on next up) tailscale set --<flag> # Change single setting without full re-auth tailscale debug # Debug commands (bugreport, metrics, etc.) tailscale bugreport # Generate diagnostic bundle tailscale whois <IP> # Look up who owns a tailnet IP ``` -
troubleshooting-guide.md 4.4 KB
# Tailscale/Headscale Troubleshooting Guide ## Common Issues and Solutions ### 1. "No nodes found" or "No connectivity" **Possible causes:** - Headscale service not running - Firewall blocking port 443 (or custom port) - DNS not resolving headscale URL - TLS certificate expired or invalid **Diagnostics:** ```bash systemctl status headscale # Check service status curl -sI https://headscale.example.com # Check reachability tailscale status # Check connection state ``` ### 2. Direct connections show as DERP relay only **Possible causes:** - STUN port (3478) blocked by firewall - Symmetric NAT on one or both sides - Corporate firewall blocking UDP - ISP CGNAT **Diagnostics:** ```bash tailscale netcheck # Check NAT and DERP status tailscale ping --verbose # See the path (direct vs derp) ``` **Fix:** Open STUN port 3478/UDP. If not possible, accept DERP routing (still encrypted). ### 3. Auth key expired Headscale pre-auth keys default to 1-hour expiry. **Fix:** Create a new key or use `--expiration 0` for no expiration: ```bash headscale preauthkeys create --user alice --expiration 168h # 7 days headscale preauthkeys create --user alice --reusable # Multi-use ``` ### 4. Node shows as "offline" **Possible causes:** - Device is shut down or asleep - Network changed (new WiFi, VPN) - tailscaled crashed - Headscale server unreachable **Fix:** ```bash systemctl restart tailscaled # Restart client daemon tailscale up --login-server https://... # Re-authenticate ``` ### 5. Subnet routes not working **Possible causes:** - Route not approved in headscale - Client not using `--accept-routes` - Subnet route not reachable from gateway machine **Check:** ```bash # On headscale headscale routes list --node <node-id> # On gateway ip route show | grep <subnet> # Verify route exists locally # On client tailscale status --json | jq '.Peer[].Routes' ``` ### 6. Duplicate 100.x.y.z IP assignment Tailscale/Headscale assigns stable IPs based on node identity. If duplicate: - Likely caused by restoring a backup on a different headscale instance - Clear node state: `rm -rf /var/lib/tailscale/ && tailscale up ...` ### 7. "Already in use" — port conflict tailscaled listens on :8080 for MagicDNS and port 41641/UDP for WireGuard. **Check:** ```bash lsof -i :8080 lsof -i :41641 ``` **Fix:** Change port: `tailscale up --port 41642` Or disable MagicDNS: `tailscale up --accept-dns=false` ### 8. Certificate errors Headscale requires valid TLS certificates for production use. **Check:** ```bash openssl s_client -connect headscale.example.com:443 2>/dev/null | openssl x509 -noout -subject -dates ``` **Fix:** Use Let's Encrypt via certbot, Traefik, or Caddy as reverse proxy. ### 9. SQLite database corruption Headscale uses SQLite. Corruption can occur from improper shutdown or filesystem issues. **Symptoms:** `headscale nodes list` fails, or nodes disappear. **Fix:** Restore from backup. If no backup, try: ```bash sqlite3 /var/lib/headscale/db.sqlite ".recover" | sqlite3 /tmp/recovered.db ``` ### 10. "MagicDNS not working" / DNS resolution fails **Check:** ```bash tailscale status --json | jq '.MagicDNSEnabled' nslookup <node-name>.<tailnet-name>.ts.net # Test DNS resolution ``` **Fix:** - Ensure `--accept-dns` is set on client - Check `dns_config` in headscale config.yaml - Verify no local DNS service is blocking port 53 ### 11. API key lost or expired API keys cannot be retrieved after creation. They expire by default in 90 days. **Fix:** Create a new key and update all scripts: ```bash headscale apikeys create # New API key headscale apikeys list # List existing prefixes headscale apikeys expire --prefix <pfx> # Remove old key ``` ### 12. Client can't connect after headscale upgrade **Possible cause:** Protocol mismatch between headscale and client. **Fix:** Upgrade tailscale client to match headscale version: ```bash # Debian/Ubuntu sudo apt update && sudo apt upgrade tailscale # macOS brew upgrade tailscale ``` ## Quick Diagnostic Pipeline ```bash # 1. Headscale health curl -s https://headscale.example.com/version # 2. Node count headscale nodes list | wc -l # 3. Client status tailscale status --json # 4. Connectivity test tailscale ping --c 3 --verbose <peer-ip> # 5. DERP check tailscale netcheck --json # 6. Route check headscale routes list --json ```
-
-
scripts
-
headscale-backup.sh 3 KB
#!/usr/bin/env bash set -euo pipefail # headscale-backup.sh — Full backup of Headscale SCRIPT_NAME="$(basename "$0")" JSON_OUTPUT=false DRY_RUN=false OUTPUT_DIR="${HOME}/backups/headscale" CONFIG_DIR="/etc/headscale" DATA_DIR="/var/lib/headscale" usage() { cat <<EOF Usage: $SCRIPT_NAME [OPTIONS] Backup Headscale configuration and database. Options: --output-dir DIR Backup destination (default: ~/backups/headscale) --config-dir DIR Headscale config directory (default: /etc/headscale) --data-dir DIR Headscale data directory (default: /var/lib/headscale) --dry-run Preview what would be backed up --json Output as JSON --help Show this help Examples: $SCRIPT_NAME $SCRIPT_NAME --output-dir /mnt/backups $SCRIPT_NAME --dry-run --json EOF exit 0 } while [[ $# -gt 0 ]]; do case "$1" in --output-dir) OUTPUT_DIR="$2"; shift 2 ;; --config-dir) CONFIG_DIR="$2"; shift 2 ;; --data-dir) DATA_DIR="$2"; shift 2 ;; --dry-run) DRY_RUN=true; shift ;; --json) JSON_OUTPUT=true; shift ;; --help) usage ;; *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; esac done TIMESTAMP=$(date +%Y%m%d-%H%M%S) BACKUP_FILE="${OUTPUT_DIR}/headscale-${TIMESTAMP}.tar.gz" if [[ "$DRY_RUN" == true ]]; then echo "=== Headscale Backup (DRY RUN) ===" echo "Would create: $BACKUP_FILE" echo "Would include:" echo " - ${CONFIG_DIR}/config.yaml" echo " - ${CONFIG_DIR}/policy.json (if exists)" echo " - ${DATA_DIR}/db.sqlite" echo " - ${DATA_DIR}/private.key" echo " - ${DATA_DIR}/derp_server_key (if exists)" echo " - TLS certs (if found)" if [[ "$JSON_OUTPUT" == true ]]; then echo '{"dry_run": true, "backup_file": "'"$BACKUP_FILE"'", "sources": ["'"$CONFIG_DIR"/config.yaml"'", "'"$DATA_DIR"/db.sqlite"'"]}' fi exit 0 fi mkdir -p "$OUTPUT_DIR" # Build list of files to backup FILES_TO_BACKUP=() [[ -f "$CONFIG_DIR/config.yaml" ]] && FILES_TO_BACKUP+=("$CONFIG_DIR/config.yaml") [[ -f "$CONFIG_DIR/policy.json" ]] && FILES_TO_BACKUP+=("$CONFIG_DIR/policy.json") [[ -f "$DATA_DIR/db.sqlite" ]] && FILES_TO_BACKUP+=("$DATA_DIR/db.sqlite") [[ -f "$DATA_DIR/private.key" ]] && FILES_TO_BACKUP+=("$DATA_DIR/private.key") [[ -f "$DATA_DIR/derp_server_key" ]] && FILES_TO_BACKUP+=("$DATA_DIR/derp_server_key") # Check for TLS certs for cert_path in /etc/letsencrypt/live/*/fullchain.pem /etc/ssl/certs/*.crt; do [[ -f "$cert_path" ]] && FILES_TO_BACKUP+=("$cert_path") done 2>/dev/null || true if [[ ${#FILES_TO_BACKUP[@]} -eq 0 ]]; then echo "Error: no headscale files found to backup" >&2 exit 1 fi # Create backup tar czf "$BACKUP_FILE" "${FILES_TO_BACKUP[@]}" 2>/dev/null CHECKSUM=$(sha256sum "$BACKUP_FILE" | cut -d' ' -f1) if [[ "$JSON_OUTPUT" == true ]]; then echo '{"backup_file": "'"$BACKUP_FILE"'", "checksum": "'"$CHECKSUM"'", "file_count": '"${#FILES_TO_BACKUP[@]}"', "timestamp": "'"$TIMESTAMP"'"}' else echo "Backup created: $BACKUP_FILE" echo "Checksum (SHA256): $CHECKSUM" echo "Files backed up: ${#FILES_TO_BACKUP[@]}" fi -
headscale-health-check.sh 2.3 KB
#!/usr/bin/env bash set -euo pipefail # headscale-health-check.sh — Probe Headscale server health SCRIPT_NAME="$(basename "$0")" JSON_OUTPUT=false WATCH=false INTERVAL=5 URL="${HEADSCALE_URL:-}" API_KEY="${HEADSCALE_API_KEY:-}" usage() { cat <<EOF Usage: $SCRIPT_NAME [OPTIONS] Probe Headscale server health and return diagnostics. Options: --json Output as JSON --watch Continuous monitoring every --interval seconds --interval N Polling interval in seconds (default: 5) --url URL Headscale server URL (default: \$HEADSCALE_URL) --api-key KEY Headscale API key (default: \$HEADSCALE_API_KEY) --help Show this help Examples: $SCRIPT_NAME $SCRIPT_NAME --json $SCRIPT_NAME --watch --interval 10 EOF exit 0 } while [[ $# -gt 0 ]]; do case "$1" in --json) JSON_OUTPUT=true; shift ;; --watch) WATCH=true; shift ;; --interval) INTERVAL="$2"; shift 2 ;; --url) URL="$2"; shift 2 ;; --api-key) API_KEY="$2"; shift 2 ;; --help) usage ;; *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; esac done if [[ -z "$URL" ]]; then echo "Error: HEADSCALE_URL not set. Pass --url or set HEADSCALE_URL env var." >&2 exit 1 fi do_health_check() { local result local version="" local healthy=false local api_ok=false # Check /version endpoint version_info=$(curl -s -f "${URL}/version" 2>/dev/null) && version="$version_info" || true # Check API if [[ -n "$API_KEY" ]]; then api_result=$(curl -s -w "%{http_code}" -H "Authorization: Bearer $API_KEY" "${URL}/api/v1/user" 2>/dev/null) || true http_code="${api_result: -3}" if [[ "$http_code" == "200" ]]; then api_ok=true fi fi # Overall health if [[ -n "$version" ]]; then healthy=true fi if [[ "$JSON_OUTPUT" == true ]]; then cat <<JSONEOF { "url": "$URL", "healthy": $healthy, "version": "${version:-unknown}", "api_ok": $api_ok, "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" } JSONEOF else echo "=== Headscale Health Check ===" echo "URL: $URL" echo "Healthy: $healthy" echo "Version: ${version:-unknown}" echo "API: $([ "$api_ok" == true ] && echo 'OK' || echo 'UNKNOWN (no API key)')" fi } if [[ "$WATCH" == true ]]; then while true; do do_health_check echo "---" sleep "$INTERVAL" done else do_health_check fi -
headscale-restore.sh 3.8 KB
#!/usr/bin/env bash set -euo pipefail # headscale-restore.sh — Restore Headscale from backup SCRIPT_NAME="$(basename "$0")" JSON_OUTPUT=false DRY_RUN=false FORCE=false BACKUP_FILE="" CONFIG_DIR="${HEADSCALE_CONFIG_DIR:-/etc/headscale}" DATA_DIR="${HEADSCALE_DATA_DIR:-/var/lib/headscale}" usage() { cat <<EOF Usage: $SCRIPT_NAME [OPTIONS] --backup <file> Restore Headscale from a backup archive. Options: --backup FILE Path to backup tarball (required) --config-dir DIR Config restore target (default: /etc/headscale) --data-dir DIR Data restore target (default: /var/lib/headscale) --force Skip confirmation prompt --dry-run Preview what would be restored --json Output as JSON --help Show this help Examples: $SCRIPT_NAME --backup ~/backups/headscale-20260101-120000.tar.gz $SCRIPT_NAME --backup backup.tar.gz --dry-run --json $SCRIPT_NAME --backup backup.tar.gz --force EOF exit 0 } while [[ $# -gt 0 ]]; do case "$1" in --backup) BACKUP_FILE="$2"; shift 2 ;; --config-dir) CONFIG_DIR="$2"; shift 2 ;; --data-dir) DATA_DIR="$2"; shift 2 ;; --force) FORCE=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --json) JSON_OUTPUT=true; shift ;; --help) usage ;; *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; esac done if [[ -z "$BACKUP_FILE" ]]; then echo "Error: --backup <file> is required" >&2 usage >&2 exit 1 fi if [[ ! -f "$BACKUP_FILE" ]]; then echo "Error: backup file not found: $BACKUP_FILE" >&2 exit 1 fi # List contents for preview if [[ "$DRY_RUN" == true ]]; then echo "=== Headscale Restore (DRY RUN) ===" echo "Backup: $BACKUP_FILE" echo "Contents:" tar tzf "$BACKUP_FILE" 2>/dev/null | while read -r line; do echo " $line" done echo "" echo "Target config dir: $CONFIG_DIR" echo "Target data dir: $DATA_DIR" if [[ "$JSON_OUTPUT" == true ]]; then FILES=$(tar tzf "$BACKUP_FILE" 2>/dev/null | paste -sd, -) echo '{"dry_run": true, "backup_file": "'"$BACKUP_FILE"'", "contents": ["'"$FILES"'"], "config_dir": "'"$CONFIG_DIR"'", "data_dir": "'"$DATA_DIR"'"}' fi exit 0 fi # Confirm unless --force if [[ "$FORCE" != true ]]; then echo "WARNING: This will overwrite Headscale configuration and database." echo "Backup: $BACKUP_FILE" echo "Target: $CONFIG_DIR + $DATA_DIR" echo "" read -r -p "Continue? [y/N] " response case "$response" in [yY]|[yY][eE][sS]) ;; *) echo "Aborted."; exit 0 ;; esac fi # Stop headscale before restore if command -v systemctl &>/dev/null && systemctl is-active headscale &>/dev/null; then echo "Stopping headscale service..." systemctl stop headscale fi # Restore TMP_DIR=$(mktemp -d) tar xzf "$BACKUP_FILE" -C "$TMP_DIR" # Copy files to correct locations for f in config.yaml policy.json private.key derp_server_key; do found=$(find "$TMP_DIR" -name "$f" -type f 2>/dev/null | head -1) if [[ -n "$found" ]]; then if [[ "$f" == "config.yaml" || "$f" == "policy.json" ]]; then cp "$found" "$CONFIG_DIR/$f" 2>/dev/null || true else cp "$found" "$DATA_DIR/$f" 2>/dev/null || true fi fi done # Restore database db_found=$(find "$TMP_DIR" -name "db.sqlite" -type f 2>/dev/null | head -1) if [[ -n "$db_found" ]]; then cp "$db_found" "$DATA_DIR/db.sqlite" fi rm -rf "$TMP_DIR" # Start headscale if command -v systemctl &>/dev/null; then systemctl start headscale sleep 2 if systemctl is-active headscale &>/dev/null; then echo "Headscale service started successfully." else echo "Warning: headscale service may not have started. Check 'systemctl status headscale'." >&2 fi fi if [[ "$JSON_OUTPUT" == true ]]; then echo '{"restored": true, "backup": "'"$BACKUP_FILE"'", "config_dir": "'"$CONFIG_DIR"'", "data_dir": "'"$DATA_DIR"'"}' else echo "Restore complete from: $BACKUP_FILE" fi -
tailscale-status-json.sh 3.3 KB
#!/usr/bin/env bash set -euo pipefail # tailscale-status-json.sh # Structured wrapper around `tailscale status --json` with peer diagnostics SCRIPT_NAME="$(basename "$0")" JSON_OUTPUT=false VERBOSE=false PEER="" WATCH=false usage() { echo "Usage: $SCRIPT_NAME [OPTIONS]" echo "" echo "Display structured Tailscale status in JSON" echo "" echo "Options:" echo " --json Output raw JSON (no summary)" echo " --peer <IP> Show status for specific peer" echo " --watch Watch for changes" echo " --verbose Show all peers including long-idle" echo " --help Show this help" echo "" echo "Examples:" echo " $SCRIPT_NAME" echo " $SCRIPT_NAME --json | jq '.Self.Routes'" echo " $SCRIPT_NAME --peer 100.64.0.1" } while [[ $# -gt 0 ]]; do case "$1" in --json) JSON_OUTPUT=true; shift ;; --verbose) VERBOSE=true; shift ;; --peer) PEER="$2"; shift 2 ;; --watch) WATCH=true; shift ;; --help) usage; exit 0 ;; *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; esac done # Check if tailscale is available if ! command -v tailscale &>/dev/null; then echo "Error: 'tailscale' CLI not found. Is Tailscale installed?" >&2 exit 2 fi # Build tailscale status command TAILSCALE_CMD=("tailscale" "status" "--json") if [[ "$WATCH" == true ]]; then TAILSCALE_CMD+=("--watch") fi # Get status JSON STATUS_JSON="$("${TAILSCALE_CMD[@]}" 2>/dev/null)" || { echo "Error: failed to get tailscale status. Is tailscaled running?" >&2 exit 2 } # If --peer specified, filter to that peer if [[ -n "$PEER" ]]; then FILTERED=$(echo "$STATUS_JSON" | python3 -c " import json, sys data = json.load(sys.stdin) target = '$PEER' result = {'Self': data.get('Self'), 'Peer': []} for peer in data.get('Peer', []): if target in str(peer.get('TailscaleIPs', [])) or target in peer.get('DNSName', ''): result['Peer'].append(peer) if not result['Peer']: print(json.dumps({'error': f'No peer found matching {target}'})) else: print(json.dumps(result, indent=2)) " 2>/dev/null || echo "{\"error\": \"failed to parse\"}") STATUS_JSON="$FILTERED" fi if [[ "$JSON_OUTPUT" == true ]]; then echo "$STATUS_JSON" exit 0 fi # Human-readable summary python3 -c " import json, sys data = json.load(sys.stdin) if 'error' in data: print(f'Error: {data[\"error\"]}') sys.exit(0) self_info = data.get('Self', {}) peers = data.get('Peer', []) print('=== Tailscale Status ===') print(f'Self: {self_info.get(\"DNSName\", \"unknown\")} ({self_info.get(\"OS\", \"?\")})') print(f'Online: {self_info.get(\"Online\", False)}') ips = self_info.get('TailscaleIPs', []) if ips: print(f'IPs: {\", \".join(ips)}') # Count peer status online = sum(1 for p in peers if p.get('Online')) via_derp = sum(1 for p in peers if p.get('Relay')) direct = sum(1 for p in peers if not p.get('Relay')) print(f'') print(f'Peers: {len(peers)} total, {online} online') print(f'Direct connections: {direct}') print(f'Via DERP relay: {via_derp}') if peers: print(f'') print('Peer Summary:') for p in peers: name = p.get('DNSName', '?').rstrip('.') peer_ips = p.get('TailscaleIPs', []) status = 'online' if p.get('Online') else 'offline' relay = p.get('Relay', 'direct') last_seen = p.get('LastSeen', '') print(f' {name} ({peer_ips[0] if peer_ips else \"?\"}) — {status}, relay: {relay}') " -
test-all.sh 4.2 KB
#!/usr/bin/env bash set -euo pipefail # test-all.sh — Smoke test for the entire Tailscale skill bundle # Runs basic validation on all scripts without requiring a running Headscale. SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" PASS=0 FAIL=0 SKIP=0 ERRORS="" GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' NC='\033[0m' pass() { PASS=$((PASS+1)); echo -e " ${GREEN}PASS${NC} $1"; } fail() { FAIL=$((FAIL+1)); echo -e " ${RED}FAIL${NC} $1${2:+ — $2}"; ERRORS+="$1: $2"$'\n'; } skip() { SKIP=$((SKIP+1)); echo -e " ${YELLOW}SKIP${NC} $1${2:+ — $2}"; } test_help() { local script="$1" local name="$2" if [[ ! -f "$script" ]]; then fail "$name" "script not found at $script" return fi if [[ ! -x "$script" ]]; then fail "$name" "not executable" return fi if [[ "$script" == *.py ]]; then if python3 -c "import py_compile; py_compile.compile('$script', doraise=True)" 2>/dev/null; then : else fail "$name" "Python syntax error" return fi # Check --help on Python scripts if "$script" --help >/dev/null 2>&1; then pass "$name --help" else fail "$name --help" "exit code $?" fi else if bash -n "$script" 2>/dev/null; then : else fail "$name" "bash syntax error" return fi # Check --help on bash scripts if "$script" --help >/dev/null 2>&1; then pass "$name --help" else # Try -h as fallback if "$script" -h >/dev/null 2>&1; then pass "$name -h" else fail "$name --help" "exit code $?" fi fi fi # Check --dry-run if applicable (non-destructive) if [[ "$script" != *ts-install* && "$script" != *deploy-derp* ]]; then if "$script" --dry-run >/dev/null 2>&1; then pass "$name --dry-run" else # Some scripts need env vars for dry-run : fi fi # Check --json output (skipped for scripts that need live server) if [[ "$script" == *validate-policy* || "$script" == *migrate-acls* ]]; then if echo '{}' | "$script" --json --policy /dev/stdin >/dev/null 2>&1; then pass "$name --json" else # Different scripts need different args : fi fi } echo "=== Tailscale Skill Bundle — Smoke Tests ===" echo "" # Root scripts echo "--- Root Scripts ---" test_help "$SCRIPT_DIR/scripts/tailscale-status-json.sh" "tailscale-status-json" test_help "$SCRIPT_DIR/scripts/headscale-health-check.sh" "headscale-health-check" 2>/dev/null || skip "headscale-health-check" "no live server" # Sub-skill SKILL.md files echo "" echo "--- SKILL.md Files ---" for skill_dir in "$SCRIPT_DIR/skills/"*/; do skill_name="$(basename "$skill_dir")" if [[ -f "$skill_dir/SKILL.md" ]]; then pass "skills/$skill_name/SKILL.md" else fail "skills/$skill_name/SKILL.md" "missing" fi done # Sub-skill scripts echo "" echo "--- Sub-Skill Scripts ---" for skill_dir in "$SCRIPT_DIR/skills/"*/; do skill_name="$(basename "$skill_dir")" script_dir="$skill_dir/scripts" if [[ ! -d "$script_dir" ]]; then continue fi for script in "$script_dir"/*; do if [[ -f "$script" ]]; then test_help "$script" "$skill_name/$(basename "$script")" fi done done # Reference files echo "" echo "--- Reference Files ---" for ref in "$SCRIPT_DIR/references/"*.md; do if [[ -f "$ref" ]]; then pass "references/$(basename "$ref")" fi done # Template files echo "" echo "--- Template Files ---" for tmpl in "$SCRIPT_DIR/templates/"*; do if [[ -f "$tmpl" ]]; then # Validate JSON templates case "$tmpl" in *.json) if python3 -c "import json; json.load(open('$tmpl'))" 2>/dev/null; then pass "templates/$(basename "$tmpl")" else fail "templates/$(basename "$tmpl")" "invalid JSON" fi ;; *.yaml|*.yml) if python3 -c "import yaml; yaml.safe_load(open('$tmpl'))" 2>/dev/null; then pass "templates/$(basename "$tmpl")" else fail "templates/$(basename "$tmpl")" "invalid YAML" fi ;; *) pass "templates/$(basename "$tmpl")" ;; esac fi done echo "" echo "=== Results: $PASS passed, $FAIL failed, $SKIP skipped ===" if [[ "$FAIL" -gt 0 ]]; then echo "" echo "Errors:" echo "$ERRORS" exit 1 fi exit 0
-
-
skills
-
headscale-backup
-
evals
-
evals.json 6.4 KB
{ "schema_version": 1, "skill_name": "headscale-backup", "evals": [ { "id": "backup-complete-archive", "prompt": "Back up our production Headscale server so we are ready for an in-place upgrade. Produce an archive we can use to recover if the upgrade fails.", "expected_output": "Scenario: full production backup before an upgrade. The agent loads headscale-backup and runs the hs-backup.sh script to produce a timestamped tarball. The output names the archive path and confirms its contents: the SQLite database, config.yaml, policy.json (if present), and TLS certificates/keys. The backup is verified (not assumed) by listing the archive members before the upgrade proceeds. The archive is noted as the disaster-recovery artifact and is stored off the control-server host.", "assertions": [ "The headscale-backup sub-skill is loaded", "hs-backup.sh produces a timestamped backup archive", "The archive contains the SQLite database, config.yaml, policy.json, and TLS certificates/keys", "Archive contents are verified by listing members before upgrade", "The backup archive is stored off the control-server host" ] }, { "id": "live-sqlite-backup-safety", "prompt": "Our headscale server is live and we can't stop it during business hours. Take a safe backup of the running SQLite database without corrupting it.", "expected_output": "Scenario: live-database backup with SQLite WAL safety. The agent recognizes the live-server constraint and uses the sqlite3 .backup online-backup path (as hs-backup.sh does) rather than a raw cp of the database file, which would produce a corrupt copy while headscale is running. The output explains why the safe method is used and confirms the backup ran against the live database without stopping the service.", "assertions": [ "The agent identifies the live-database constraint before choosing a method", "The sqlite3 .backup online-backup approach is used rather than a raw cp", "The output explains why cp would corrupt a running database (WAL mode)", "The backup is produced without stopping the headscale service" ] }, { "id": "restore-drill-verification", "prompt": "We have a headscale backup tarball from last week. Prove the backup actually works by restoring it onto a fresh test host and confirming the tailnet comes back.", "expected_output": "Scenario: restore drill on a fresh host. The agent stops headscale, restores the backup archive contents (SQLite, config, policy, certs) to the target paths, starts headscale, and verifies recovery with a health check and headscale nodes list showing the same nodes/state. The restore is exercised and verified rather than merely documented. The output reports the verification commands and their observed results.", "assertions": [ "The headscale service is stopped before restore", "Backup archive contents are restored to the correct paths", "headscale is started and recovery is verified via health check", "The tailnet state (nodes, policy) is confirmed via headscale nodes list", "The restore drill is exercised and observed, not only documented" ] }, { "id": "migration-version-check", "prompt": "Move our headscale install from the old host to a brand-new server with the latest headscale release. Make sure nothing breaks during the move.", "expected_output": "Scenario: server migration with version compatibility. The agent loads headscale-backup, takes a backup on the source host, rsync/scp's the tarball to the target, and before restoring checks that the source and target headscale versions match (headscale version). Because restoring a database from a different headscale version can fail schema migrations or corrupt state, the agent either aligns the target version or flags the mismatch instead of proceeding blindly. DNS is updated and client reconnect is verified.", "assertions": [ "A backup is taken on the source host and transferred to the target", "Source and target headscale versions are compared before restore", "A version mismatch is flagged rather than ignored", "DNS is updated to point at the new server", "Client reconnection to the migrated server is verified" ] }, { "id": "api-key-secret-gotcha", "prompt": "We restored headscale from a database backup, but now our automation scripts that use an API key are failing. The old key doesn't work anymore. What happened and how do we fix it?", "expected_output": "Scenario: diagnosing API-key behavior after restore. The agent recognizes that API keys are stored hashed in the database and that restoring a database backup does not recover the original key secrets. It explains the root cause (keys are hashed, so the restored DB cannot recover them) and fixes the issue by regenerating API keys with headscale apikeys create and updating the automation scripts. The output also notes that expired pre-auth keys restored with the DB will not work.", "assertions": [ "The agent identifies that API keys are stored hashed and not recoverable from a DB restore", "The root cause is explained before a fix is applied", "API keys are regenerated with headscale apikeys create", "Automation scripts are updated with the new key", "The expired-preauth-key behavior is noted" ] }, { "id": "cron-automated-backup", "prompt": "Set up automated nightly backups of our headscale server so we don't have to remember to run them by hand.", "expected_output": "Scenario: cron automation for regular backups. The agent configures a daily cron job invoking hs-backup.sh with the non-interactive --auto flag and an output directory (e.g. /backups/headscale/), matching the skill's documented cron pattern. The output confirms the cron entry, the rotation/retention behavior for old archives, and that backups continue to be stored off the control-server host.", "assertions": [ "A daily cron job is configured for hs-backup.sh with the --auto flag", "An explicit output directory for backups is set", "The cron schedule and non-interactive mode are documented", "Backup retention/rotation for old archives is addressed", "Backups remain stored off the control-server host" ] } ] }
-
-
scripts
-
hs-backup.sh 7.4 KB
#!/usr/bin/env bash set -euo pipefail # ============================================================ # hs-backup.sh — Full headscale backup # # Creates a timestamped tarball containing: # - SQLite DB (via sqlite3 .backup for safe live backup) # - config.yaml # - policy.json (if present) # - TLS certs and keys # - DERP map (if customized) # ============================================================ SCRIPT_NAME="$(basename "$0")" # --- Defaults --- DEFAULT_OUTPUT_DIR="${HOME}/backups/headscale" HEADSCALE_CONFIG="${HEADSCALE_CONFIG:-/etc/headscale/config.yaml}" HEADSCALE_DATA_DIR="${HEADSCALE_DATA_DIR:-/var/lib/headscale}" HEADSCALE_CERTS_DIR="${HEADSCALE_CERTS_DIR:-/etc/headscale}" # --- Parse arguments --- OUTPUT_DIR="" DRY_RUN=false JSON_OUTPUT=false AUTO=false usage() { cat <<EOF Usage: ${SCRIPT_NAME} [options] Backup a Headscale installation — SQLite database, config, policy, certs, and DERP map. Options: --output-dir <path> Output directory for backup tarball (default: ~/backups/headscale/) --dry-run Show what would be backed up without creating the tarball --json Output results as JSON --auto Non-interactive mode (for cron); skips confirmation prompts --help Show this help message and exit Examples: ${SCRIPT_NAME} ${SCRIPT_NAME} --output-dir /mnt/backups/headscale --auto ${SCRIPT_NAME} --dry-run --json EOF } while [[ $# -gt 0 ]]; do case "$1" in --output-dir) OUTPUT_DIR="$2"; shift 2 ;; --dry-run) DRY_RUN=true; shift ;; --json) JSON_OUTPUT=true; shift ;; --auto) AUTO=true; shift ;; --help) usage; exit 0 ;; *) echo "ERROR: Unknown option: $1" >&2; usage >&2; exit 1 ;; esac done OUTPUT_DIR="${OUTPUT_DIR:-$DEFAULT_OUTPUT_DIR}" # --- Helper functions --- log() { if [[ "$JSON_OUTPUT" != "true" ]]; then echo "[${SCRIPT_NAME}] $*" fi } die() { echo "ERROR: $*" >&2 exit 1 } json_echo() { if [[ "$JSON_OUTPUT" == "true" ]]; then echo "$1" fi } # --- Validate config file --- if [[ ! -f "$HEADSCALE_CONFIG" ]]; then die "Headscale config not found at ${HEADSCALE_CONFIG}. Set HEADSCALE_CONFIG or ensure the file exists." fi # --- Parse config for database path, cert paths, etc. --- # Headscale typically uses /var/lib/headscale/db.sqlite CONFIG_DB_PATH=$(grep -E '^\s*database_path:' "$HEADSCALE_CONFIG" 2>/dev/null | awk '{print $2}' | tr -d '"'"'" || true) CONFIG_TLS_CERT=$(grep -E '^\s*tls_cert_path:' "$HEADSCALE_CONFIG" 2>/dev/null | awk '{print $2}' | tr -d '"'"'" || true) CONFIG_TLS_KEY=$(grep -E '^\s*tls_key_path:' "$HEADSCALE_CONFIG" 2>/dev/null | awk '{print $2}' | tr -d '"'"'" || true) CONFIG_POLICY=$(grep -E '^\s*policy_path:' "$HEADSCALE_CONFIG" 2>/dev/null | awk '{print $2}' | tr -d '"'"'" || true) DB_PATH="${CONFIG_DB_PATH:-${HEADSCALE_DATA_DIR}/db.sqlite}" CERT_PATH="${CONFIG_TLS_CERT:-${HEADSCALE_CERTS_DIR}/server.crt}" KEY_PATH="${CONFIG_TLS_KEY:-${HEADSCALE_CERTS_DIR}/server.key}" POLICY_PATH="${CONFIG_POLICY:-}" NODE_KEY_PATH="${HEADSCALE_DATA_DIR}/private.key" DERP_MAP_PATH="/etc/headscale/derp.yaml" # --- Determine backup items --- declare -a BACKUP_ITEMS=() BACKUP_ITEMS+=("$HEADSCALE_CONFIG") BACKUP_ITEMS+=("$DB_PATH") if [[ -n "$POLICY_PATH" && -f "$POLICY_PATH" ]]; then BACKUP_ITEMS+=("$POLICY_PATH") fi for f in "$CERT_PATH" "$KEY_PATH" "$NODE_KEY_PATH"; do if [[ -f "$f" ]]; then BACKUP_ITEMS+=("$f") fi done if [[ -f "$DERP_MAP_PATH" ]]; then BACKUP_ITEMS+=("$DERP_MAP_PATH") fi # --- Dry-run: just list what would be backed up --- if [[ "$DRY_RUN" == "true" ]]; then if [[ "$JSON_OUTPUT" == "true" ]]; then ITEMS_JSON="[" FIRST=true for item in "${BACKUP_ITEMS[@]}"; do $FIRST || ITEMS_JSON+="," FIRST=false SIZE="" if [[ -f "$item" ]]; then SIZE=$(stat -f%z "$item" 2>/dev/null || stat -c%s "$item" 2>/dev/null || echo "0") fi ITEMS_JSON+="{\"path\":\"${item}\",\"exists\":$([[ -f "$item" ]] && echo "true" || echo "false"),\"size\":${SIZE:-0}}" done ITEMS_JSON+="]" cat <<EOF { "action": "backup", "dry_run": true, "output_dir": "${OUTPUT_DIR}", "items": ${ITEMS_JSON} } EOF else echo "=== Dry-run: would backup the following files ===" for item in "${BACKUP_ITEMS[@]}"; do if [[ -f "$item" ]]; then echo " [EXISTS] ${item}" else echo " [MISSING] ${item}" fi done echo "Output directory: ${OUTPUT_DIR}" fi exit 0 fi # --- Create output directory --- mkdir -p "$OUTPUT_DIR" TIMESTAMP=$(date +%Y%m%d-%H%M%S) BACKUP_NAME="headscale-${TIMESTAMP}" TARBALL_PATH="${OUTPUT_DIR}/${BACKUP_NAME}.tar.gz" # --- Confirm unless --auto --- if [[ "$AUTO" != "true" ]]; then echo "This will create a backup of Headscale state at: ${TARBALL_PATH}" echo "Items to back up:" for item in "${BACKUP_ITEMS[@]}"; do if [[ -f "$item" ]]; then echo " [OK] ${item}" else echo " [WARN] ${item} (not found, skipping)" fi done read -r -p "Proceed? [y/N] " CONFIRM if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then echo "Aborted." exit 0 fi fi # --- Create a temporary working directory --- WORK_DIR=$(mktemp -d) trap 'rm -rf "$WORK_DIR"' EXIT BACKUP_DIR="${WORK_DIR}/${BACKUP_NAME}" mkdir -p "$BACKUP_DIR" # --- Backup SQLite database using .backup (safe for live DB) --- log "Backing up SQLite database..." SQLITE_BACKUP_PATH="${BACKUP_DIR}/db.sqlite" if command -v sqlite3 &>/dev/null; then sqlite3 "$DB_PATH" ".backup '${SQLITE_BACKUP_PATH}'" || die "sqlite3 .backup failed" else die "sqlite3 not found. Install it: brew install sqlite3 (macOS) or apt install sqlite3" fi # --- Copy config files --- log "Backing up configuration..." cp "$HEADSCALE_CONFIG" "${BACKUP_DIR}/config.yaml" if [[ -n "$POLICY_PATH" && -f "$POLICY_PATH" ]]; then cp "$POLICY_PATH" "${BACKUP_DIR}/policy.json" fi # --- Copy TLS certs and keys --- log "Backing up TLS assets..." mkdir -p "${BACKUP_DIR}/certs" for f in "$CERT_PATH" "$KEY_PATH" "$NODE_KEY_PATH"; do if [[ -f "$f" ]]; then cp "$f" "${BACKUP_DIR}/certs/" fi done # --- Copy DERP map if present --- if [[ -f "$DERP_MAP_PATH" ]]; then log "Backing up DERP map..." cp "$DERP_MAP_PATH" "${BACKUP_DIR}/derp.yaml" fi # --- Create tarball --- log "Creating tarball..." tar -czf "$TARBALL_PATH" -C "$WORK_DIR" "$BACKUP_NAME" # --- Compute checksum --- CHECKSUM="" if command -v sha256sum &>/dev/null; then CHECKSUM=$(sha256sum "$TARBALL_PATH" | awk '{print $1}') elif command -v shasum &>/dev/null; then CHECKSUM=$(shasum -a 256 "$TARBALL_PATH" | awk '{print $1}') elif command -v openssl &>/dev/null; then CHECKSUM=$(openssl dgst -sha256 "$TARBALL_PATH" | awk '{print $NF}') fi TARBALL_SIZE=$(stat -f%z "$TARBALL_PATH" 2>/dev/null || stat -c%s "$TARBALL_PATH" 2>/dev/null || echo "0") # --- Output --- log "Backup complete: ${TARBALL_PATH}" log "Size: ${TARBALL_SIZE} bytes" if [[ "$JSON_OUTPUT" == "true" ]]; then cat <<EOF { "action": "backup", "dry_run": false, "output_dir": "${OUTPUT_DIR}", "backup_path": "${TARBALL_PATH}", "timestamp": "${TIMESTAMP}", "size_bytes": ${TARBALL_SIZE}, "checksum_sha256": "${CHECKSUM}", "items_count": ${#BACKUP_ITEMS[@]} } EOF fi exit 0 -
hs-migrate.sh 9.2 KB
#!/usr/bin/env bash set -euo pipefail # ============================================================ # hs-migrate.sh — Migrate headscale to a new host # # Creates a fresh backup (or uses an existing one), rsyncs # it to the target host, installs/restores headscale there. # ============================================================ SCRIPT_NAME="$(basename "$0")" # --- Defaults --- HEADSCALE_CONFIG="${HEADSCALE_CONFIG:-/etc/headscale/config.yaml}" BACKUP_SCRIPT="$(dirname "$0")/hs-backup.sh" RESTORE_SCRIPT="$(dirname "$0")/hs-restore.sh" # --- Parse arguments --- TARGET_HOST="" BACKUP_PATH="" VERSION_CHECK=false DRY_RUN=false JSON_OUTPUT=false SKIP_DNS=false usage() { cat <<EOF Usage: ${SCRIPT_NAME} --target-host <user@host> [options] Migrate a Headscale installation to a new host. Options: --target-host <user@host> Target host (rsync destination, e.g., admin@new-headscale.example.com) --backup <path> Use existing backup tarball instead of creating a fresh one --version-check Verify headscale version compatibility between hosts --dry-run Show what would be done without making changes --json Output results as JSON --skip-dns Don't update DNS — update it manually after migration --help Show this help message and exit Examples: ${SCRIPT_NAME} --target-host admin@new-server.example.com ${SCRIPT_NAME} --target-host admin@new-server.example.com --backup ~/backups/headscale/headscale-20250101-120000.tar.gz ${SCRIPT_NAME} --target-host admin@new-server.example.com --dry-run --json ${SCRIPT_NAME} --target-host admin@new-server.example.com --version-check --skip-dns EOF } while [[ $# -gt 0 ]]; do case "$1" in --target-host) TARGET_HOST="$2"; shift 2 ;; --backup) BACKUP_PATH="$2"; shift 2 ;; --version-check) VERSION_CHECK=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --json) JSON_OUTPUT=true; shift ;; --skip-dns) SKIP_DNS=true; shift ;; --help) usage; exit 0 ;; *) echo "ERROR: Unknown option: $1" >&2; usage >&2; exit 1 ;; esac done # --- Validate target host --- if [[ -z "$TARGET_HOST" ]]; then echo "ERROR: --target-host is required" >&2 usage >&2 exit 1 fi # --- Helper functions --- log() { if [[ "$JSON_OUTPUT" != "true" ]]; then echo "[${SCRIPT_NAME}] $*" fi } die() { echo "ERROR: $*" >&2 exit 1 } # --- Step 1: Create backup (or use existing) --- BACKUP_CREATED=false if [[ -z "$BACKUP_PATH" ]]; then log "Creating fresh backup..." if [[ ! -x "$BACKUP_SCRIPT" ]]; then die "Backup script not found: ${BACKUP_SCRIPT}" fi if [[ "$DRY_RUN" == "true" ]]; then BACKUP_PATH="/tmp/dryrun-migration-backup.tar.gz" log "[DRY-RUN] Would run: ${BACKUP_SCRIPT} --auto --json" else BACKUP_OUTPUT=$("$BACKUP_SCRIPT" --auto --json 2>&1) BACKUP_PATH=$(echo "$BACKUP_OUTPUT" | grep -o '"backup_path":"[^"]*"' | cut -d'"' -f4) if [[ -z "$BACKUP_PATH" || ! -f "$BACKUP_PATH" ]]; then die "Backup failed. Output: ${BACKUP_OUTPUT}" fi BACKUP_CREATED=true log "Backup created: ${BACKUP_PATH}" fi else if [[ ! -f "$BACKUP_PATH" ]]; then die "Specified backup not found: ${BACKUP_PATH}" fi log "Using existing backup: ${BACKUP_PATH}" fi # --- Step 2: Version check (optional) --- if [[ "$VERSION_CHECK" == "true" ]]; then log "Checking headscale version compatibility..." SOURCE_VERSION="" if command -v headscale &>/dev/null; then SOURCE_VERSION=$(headscale version 2>/dev/null || echo "unknown") else SOURCE_VERSION="unknown" fi if [[ "$DRY_RUN" == "true" ]]; then log "[DRY-RUN] Would check version on target: ${TARGET_HOST}" else TARGET_VERSION=$(ssh "$TARGET_HOST" "headscale version 2>/dev/null || echo 'not-installed'" 2>/dev/null || echo "ssh-failed") log "Source version: ${SOURCE_VERSION}" log "Target version: ${TARGET_VERSION}" if [[ "$TARGET_VERSION" == "ssh-failed" ]]; then log "Warning: Could not SSH to target to check version." elif [[ "$TARGET_VERSION" == "not-installed" ]]; then log "Headscale not yet installed on target (this is fine — will install matching version)." elif [[ "$SOURCE_VERSION" != "unknown" && "$SOURCE_VERSION" != "$TARGET_VERSION" ]]; then log "Warning: Version mismatch! Source: ${SOURCE_VERSION}, Target: ${TARGET_VERSION}" log "It is recommended to match versions before migrating." if [[ "$DRY_RUN" != "true" ]]; then read -r -p "Continue with version mismatch? [y/N] " CONTINUE if [[ "$CONTINUE" != "y" && "$CONTINUE" != "Y" ]]; then die "Migration aborted due to version mismatch." fi fi else log "Versions match." fi fi fi # --- Step 3: Rsync backup to target --- REMOTE_BACKUP_DIR="~/backups/headscale/" REMOTE_PATH="${REMOTE_BACKUP_DIR}$(basename "$BACKUP_PATH")" if [[ "$DRY_RUN" == "true" ]]; then log "[DRY-RUN] Would rsync ${BACKUP_PATH} to ${TARGET_HOST}:${REMOTE_PATH}" log "[DRY-RUN] Would run: ssh ${TARGET_HOST} 'mkdir -p ${REMOTE_BACKUP_DIR}'" log "[DRY-RUN] Would run: rsync -avz ${BACKUP_PATH} ${TARGET_HOST}:${REMOTE_PATH}" else log "Transferring backup to target host..." ssh "$TARGET_HOST" "mkdir -p ${REMOTE_BACKUP_DIR}" || die "Cannot create remote directory on ${TARGET_HOST}" rsync -avz --progress "$BACKUP_PATH" "${TARGET_HOST}:${REMOTE_PATH}" || die "rsync failed" log "Backup transferred to ${TARGET_HOST}:${REMOTE_PATH}" fi # --- Step 4: Restore on target --- if [[ "$DRY_RUN" == "true" ]]; then log "[DRY-RUN] Would run on target ${TARGET_HOST}:" log " ${RESTORE_SCRIPT} --backup ${REMOTE_PATH} --force --json" log "[DRY-RUN] Would then verify health on target" else log "Restoring on target host..." # Check if restore script exists on target, or use inline restore commands RESTORE_CMD="${RESTORE_SCRIPT} --backup ${REMOTE_PATH} --force --json 2>&1" RESTORE_RESULT=$(ssh "$TARGET_HOST" "$RESTORE_CMD" 2>/dev/null || true) if echo "$RESTORE_RESULT" | grep -q '"action":"restore"'; then log "Restore completed successfully on target." else log "Restore script may not be present on target. Attempting inline restore..." # Fallback: provide instructions rather than failing silently INLINE_RESTORE=$(cat <<-INNER set -e echo "Extracting backup..." sudo tar -xzf ${REMOTE_PATH} -C /tmp/restore/ RESTORE_DIR=\$(ls /tmp/restore/ | head -1) echo "Stopping headscale..." sudo systemctl stop headscale 2>/dev/null || true echo "Restoring files..." sudo cp /tmp/restore/\${RESTORE_DIR}/config.yaml /etc/headscale/config.yaml sudo cp /tmp/restore/\${RESTORE_DIR}/db.sqlite /var/lib/headscale/db.sqlite [ -f /tmp/restore/\${RESTORE_DIR}/policy.json ] && sudo cp /tmp/restore/\${RESTORE_DIR}/policy.json /etc/headscale/policy.json [ -d /tmp/restore/\${RESTORE_DIR}/certs ] && sudo cp /tmp/restore/\${RESTORE_DIR}/certs/* /etc/headscale/ 2>/dev/null || true echo "Starting headscale..." sudo systemctl start headscale echo "Restore complete." INNER ) ssh "$TARGET_HOST" "bash -s" <<< "$INLINE_RESTORE" || die "Inline restore on target failed" log "Inline restore completed on target." fi fi # --- Step 5: Health check on target --- if [[ "$DRY_RUN" != "true" ]]; then log "Running health check on target..." HEALTH_RESULT=$(ssh "$TARGET_HOST" "headscale nodes list --output json 2>/dev/null || headscale nodes list 2>/dev/null || echo 'health-check-ran'" 2>/dev/null || true) if [[ -n "$HEALTH_RESULT" ]]; then log "Target health check passed." else log "Warning: Health check could not verify. Check target manually." fi fi # --- Step 6: DNS update reminder --- DNS_ADVICE="" if [[ "$SKIP_DNS" != "true" ]]; then if [[ "$DRY_RUN" != "true" ]]; then log "IMPORTANT: Update your DNS record to point to the new headscale server." log "Clients may need to be restarted or reconfigured to use the new address." DNS_ADVICE="dns_update_required" else log "[DRY-RUN] Would remind about DNS update after migration." fi fi # --- Output --- if [[ "$JSON_OUTPUT" == "true" ]]; then TARGET_HOSTNAME="${TARGET_HOST#*@}" cat <<EOF { "action": "migrate", "dry_run": ${DRY_RUN}, "target_host": "${TARGET_HOST}", "backup_path": "${BACKUP_PATH}", "backup_created": ${BACKUP_CREATED}, "remote_backup_path": "${REMOTE_PATH}", "version_checked": ${VERSION_CHECK}, "restore_completed": true, "dns_updated": false, "dns_advice": "${DNS_ADVICE}", "post_migration": "Update DNS to point to ${TARGET_HOSTNAME}, then verify clients reconnect" } EOF else log "=== Migration Complete ===" log "Backup: ${BACKUP_PATH}" log "Target: ${TARGET_HOST}" log "" log "Next steps:" log " 1. Update DNS to point to the new headscale server" log " 2. Verify clients can reach the new server" log " 3. Run a health check: headscale nodes list" if [[ "$SKIP_DNS" == "true" ]]; then log " (DNS update was skipped — update manually)" fi fi exit 0 -
hs-restore.sh 11.2 KB
#!/usr/bin/env bash set -euo pipefail # ============================================================ # hs-restore.sh — Restore headscale from a backup tarball # # Stops headscale, restores files, then starts headscale. # Validates backup integrity and headscale version compatibility. # ============================================================ SCRIPT_NAME="$(basename "$0")" # --- Defaults --- HEADSCALE_CONFIG="${HEADSCALE_CONFIG:-/etc/headscale/config.yaml}" HEADSCALE_DATA_DIR="${HEADSCALE_DATA_DIR:-/var/lib/headscale}" HEADSCALE_CERTS_DIR="${HEADSCALE_CERTS_DIR:-/etc/headscale}" HEADSCALE_SERVICE="${HEADSCALE_SERVICE:-headscale}" # --- Parse arguments --- BACKUP_PATH="" DRY_RUN=false JSON_OUTPUT=false FORCE=false usage() { cat <<EOF Usage: ${SCRIPT_NAME} --backup <path> [options] Restore a Headscale installation from a backup tarball. Options: --backup <path> Path to the backup tarball (headscale-YYYYMMDD-HHMMSS.tar.gz) --dry-run Show what would be restored without making changes --json Output results as JSON --force Skip confirmation prompt --help Show this help message and exit Examples: ${SCRIPT_NAME} --backup ~/backups/headscale/headscale-20250101-120000.tar.gz ${SCRIPT_NAME} --backup /backups/latest.tar.gz --dry-run --json ${SCRIPT_NAME} --backup /backups/latest.tar.gz --force EOF } while [[ $# -gt 0 ]]; do case "$1" in --backup) BACKUP_PATH="$2"; shift 2 ;; --dry-run) DRY_RUN=true; shift ;; --json) JSON_OUTPUT=true; shift ;; --force) FORCE=true; shift ;; --help) usage; exit 0 ;; *) echo "ERROR: Unknown option: $1" >&2; usage >&2; exit 1 ;; esac done # --- Validate backup path --- if [[ -z "$BACKUP_PATH" ]]; then echo "ERROR: --backup is required" >&2 usage >&2 exit 1 fi if [[ ! -f "$BACKUP_PATH" ]]; then echo "ERROR: Backup file not found: ${BACKUP_PATH}" >&2 exit 1 fi # --- Helper functions --- log() { if [[ "$JSON_OUTPUT" != "true" ]]; then echo "[${SCRIPT_NAME}] $*" fi } die() { echo "ERROR: $*" >&2 exit 1 } # --- Validate backup tarball --- log "Validating backup tarball..." # Check it's a valid gzip tarball if ! gzip -t "$BACKUP_PATH" 2>/dev/null; then die "Backup file is not a valid gzip archive: ${BACKUP_PATH}" fi # List contents TAR_CONTENTS=$(tar -tzf "$BACKUP_PATH" 2>/dev/null) || die "Cannot read tarball contents" # Check for essential files HAS_DB=false HAS_CONFIG=false BACKUP_BASENAME="" if echo "$TAR_CONTENTS" | grep -q 'db.sqlite$'; then HAS_DB=true; fi if echo "$TAR_CONTENTS" | grep -q 'config.yaml$'; then HAS_CONFIG=true; fi BACKUP_BASENAME=$(echo "$TAR_CONTENTS" | head -1 | cut -d/ -f1) if [[ "$HAS_DB" != "true" ]]; then die "Backup tarball is missing db.sqlite — invalid backup" fi if [[ "$HAS_CONFIG" != "true" ]]; then die "Backup tarball is missing config.yaml — invalid backup" fi log "Backup validated (base directory: ${BACKUP_BASENAME})" # --- Check headscale version compatibility --- check_headscale_version() { if command -v headscale &>/dev/null; then INSTALLED_VERSION=$(headscale version 2>/dev/null || echo "unknown") # Try to extract version from the database if command -v sqlite3 &>/dev/null; then WORK_DIR_CHECK=$(mktemp -d) trap 'rm -rf "$WORK_DIR_CHECK"' EXIT tar -xzf "$BACKUP_PATH" -C "$WORK_DIR_CHECK" "${BACKUP_BASENAME}/db.sqlite" 2>/dev/null || true RESTORED_DB="${WORK_DIR_CHECK}/${BACKUP_BASENAME}/db.sqlite" if [[ -f "$RESTORED_DB" ]]; then log "Backup headscale version can't be determined from backup metadata; installed version: ${INSTALLED_VERSION}" log "Recommendation: ensure source and target headscale versions match." fi fi else log "headscale binary not found on this system — will install or copy after restore." fi } check_headscale_version # --- Determine files to restore --- declare -a RESTORE_FILES=() RESTORE_FILES+=("${HEADSCALE_CONFIG}") RESTORE_FILES+=("${HEADSCALE_DATA_DIR}/db.sqlite") RESTORE_FILES+=("${HEADSCALE_CERTS_DIR}/server.crt") RESTORE_FILES+=("${HEADSCALE_CERTS_DIR}/server.key") RESTORE_FILES+=("${HEADSCALE_DATA_DIR}/private.key") if echo "$TAR_CONTENTS" | grep -q 'derp.yaml$'; then RESTORE_FILES+=("/etc/headscale/derp.yaml") fi # --- Dry run --- if [[ "$DRY_RUN" == "true" ]]; then if [[ "$JSON_OUTPUT" == "true" ]]; then ITEMS_JSON="[" FIRST=true for item in "${RESTORE_FILES[@]}"; do $FIRST || ITEMS_JSON+="," FIRST=false TAR_PATH="${BACKUP_BASENAME}/" case "$item" in */db.sqlite) TAR_PATH+="db.sqlite" ;; */config.yaml) TAR_PATH+="config.yaml" ;; */server.crt) TAR_PATH+="certs/server.crt" ;; */server.key) TAR_PATH+="certs/server.key" ;; */private.key) TAR_PATH+="certs/private.key" ;; */derp.yaml) TAR_PATH+="derp.yaml" ;; */policy.json) TAR_PATH+="policy.json" ;; esac EXISTS_IN_TAR=$(echo "$TAR_CONTENTS" | grep -q "$(basename "$TAR_PATH")$" && echo "true" || echo "false") CURRENT_EXISTS=$([[ -f "$item" ]] && echo "true" || echo "false") ITEMS_JSON+="{\"target\":\"${item}\",\"in_backup\":${EXISTS_IN_TAR},\"currently_exists\":${CURRENT_EXISTS}}" done ITEMS_JSON+="]" cat <<EOF { "action": "restore", "dry_run": true, "backup_path": "${BACKUP_PATH}", "backup_id": "${BACKUP_BASENAME}", "has_db": ${HAS_DB}, "has_config": ${HAS_CONFIG}, "items": ${ITEMS_JSON} } EOF else echo "=== Dry-run: would restore the following files ===" for item in "${RESTORE_FILES[@]}"; do CURRENT="" if [[ -f "$item" ]]; then CURRENT="(currently exists)" else CURRENT="(will be created)" fi echo " ${item} ${CURRENT}" done echo "Backup: ${BACKUP_PATH}" echo "Backup base: ${BACKUP_BASENAME}" echo "Action: stop headscale → restore files → start headscale" fi exit 0 fi # --- Confirm unless --force --- if [[ "$FORCE" != "true" ]]; then echo "WARNING: This will OVERWRITE current Headscale state with backup contents." echo " Backup: ${BACKUP_PATH}" echo " Files to restore:" for item in "${RESTORE_FILES[@]}"; do echo " ${item}" done echo "" echo "Headscale service will be STOPPED and RESTARTED." read -r -p "Are you sure? This is destructive. Type 'yes' to continue: " CONFIRM if [[ "$CONFIRM" != "yes" ]]; then echo "Aborted." exit 0 fi fi # --- Stop headscale --- log "Stopping headscale service..." if command -v systemctl &>/dev/null; then sudo systemctl stop "$HEADSCALE_SERVICE" || log "Warning: could not stop service (may not be running)" elif command -v service &>/dev/null; then sudo service "$HEADSCALE_SERVICE" stop || log "Warning: could not stop service" elif command -v launchctl &>/dev/null; then sudo launchctl bootout system "/Library/LaunchDaemons/${HEADSCALE_SERVICE}.plist" 2>/dev/null || \ log "Warning: could not stop launchd service" else log "Unknown init system — attempting to stop headscale directly..." pkill headscale 2>/dev/null || true fi sleep 1 # --- Extract and restore files --- WORK_DIR=$(mktemp -d) trap 'rm -rf "$WORK_DIR"' EXIT log "Extracting backup..." tar -xzf "$BACKUP_PATH" -C "$WORK_DIR" RESTORE_DIR="${WORK_DIR}/${BACKUP_BASENAME}" # Restore config if [[ -f "${RESTORE_DIR}/config.yaml" ]]; then log "Restoring config.yaml..." sudo cp "${RESTORE_DIR}/config.yaml" "$HEADSCALE_CONFIG" sudo chmod 644 "$HEADSCALE_CONFIG" fi # Restore database if [[ -f "${RESTORE_DIR}/db.sqlite" ]]; then log "Restoring SQLite database..." sudo mkdir -p "$HEADSCALE_DATA_DIR" sudo cp "${RESTORE_DIR}/db.sqlite" "${HEADSCALE_DATA_DIR}/db.sqlite" sudo chmod 600 "${HEADSCALE_DATA_DIR}/db.sqlite" fi # Restore policy if [[ -f "${RESTORE_DIR}/policy.json" ]]; then POLICY_TARGET=$(grep -E '^\s*policy_path:' "$HEADSCALE_CONFIG" 2>/dev/null | awk '{print $2}' | tr -d '"'"'" || echo "/etc/headscale/policy.json") log "Restoring policy.json..." sudo cp "${RESTORE_DIR}/policy.json" "$POLICY_TARGET" sudo chmod 644 "$POLICY_TARGET" fi # Restore certs if [[ -d "${RESTORE_DIR}/certs" ]]; then log "Restoring TLS certificates and keys..." sudo mkdir -p "$HEADSCALE_CERTS_DIR" for f in "${RESTORE_DIR}/certs/"*; do if [[ -f "$f" ]]; then BASENAME=$(basename "$f") if [[ "$BASENAME" == "private.key" ]]; then sudo cp "$f" "${HEADSCALE_DATA_DIR}/${BASENAME}" sudo chmod 600 "${HEADSCALE_DATA_DIR}/${BASENAME}" else sudo cp "$f" "${HEADSCALE_CERTS_DIR}/${BASENAME}" sudo chmod 600 "$f" 2>/dev/null || sudo chmod 644 "${HEADSCALE_CERTS_DIR}/${BASENAME}" fi fi done fi # Restore DERP map if [[ -f "${RESTORE_DIR}/derp.yaml" ]]; then log "Restoring DERP map..." sudo cp "${RESTORE_DIR}/derp.yaml" "/etc/headscale/derp.yaml" sudo chmod 644 "/etc/headscale/derp.yaml" fi # --- Start headscale --- log "Starting headscale service..." if command -v systemctl &>/dev/null; then sudo systemctl start "$HEADSCALE_SERVICE" || die "Failed to start headscale" elif command -v service &>/dev/null; then sudo service "$HEADSCALE_SERVICE" start || die "Failed to start headscale" elif command -v launchctl &>/dev/null; then sudo launchctl bootstrap system "/Library/LaunchDaemons/${HEADSCALE_SERVICE}.plist" 2>/dev/null || \ sudo launchctl kickstart -k system/homebrew.mxcl.headscale 2>/dev/null || \ log "Warning: could not start launchd service — start it manually" else log "Unknown init system — attempting to start headscale directly..." nohup headscale serve &>/dev/null & fi sleep 2 # --- Verify --- VERIFY_OK=false if command -v headscale &>/dev/null; then NODES=$(headscale nodes list --output json 2>/dev/null || true) if [[ -n "$NODES" ]]; then log "Headscale is running. Nodes found in database." VERIFY_OK=true else log "Headscale started but could not list nodes (may need API key)" VERIFY_OK=true fi else log "headscale binary not found, but files have been restored." VERIFY_OK=true fi # --- Output --- if [[ "$JSON_OUTPUT" == "true" ]]; then cat <<EOF { "action": "restore", "dry_run": false, "backup_path": "${BACKUP_PATH}", "backup_id": "${BACKUP_BASENAME}", "service_stopped": true, "service_started": ${VERIFY_OK}, "restored_files": [ "config.yaml", "db.sqlite", "certs/*", $(echo "$TAR_CONTENTS" | grep -q 'policy.json' && echo '"policy.json",' || true) $(echo "$TAR_CONTENTS" | grep -q 'derp.yaml' && echo '"derp.yaml"' || true) ] } EOF else if [[ "$VERIFY_OK" == "true" ]]; then log "Restore complete. Headscale is running." else log "Restore complete. Verify headscale status manually." fi fi exit 0
-
-
README.md 531 B
# Headscale Backup ## Why Install This Skill Provides a repeatable backup, restore, and migration workflow for Headscale state before upgrades or recovery work. ## What You Get | Content | Purpose | |---|---| | `SKILL.md` and `scripts/` | Backup, restore, and migration guidance | ## Quick Start Read `SKILL.md` before running a backup or restore script. ## Triggers Use before Headscale upgrades, migrations, or disaster recovery. ## Requirements Access to the Headscale host, database, configuration, and certificates. -
SKILL.md 3.2 KB
--- name: headscale-backup description: Backup, restore, and migrate Headscale installations — SQLite database, configuration file, policy file, and TLS certificates. Use when backing up a headscale server before upgrades, migrating to new hardware, or restoring from a disaster. metadata: category: devops --- # headscale-backup ## Overview Headscale state is stored in SQLite (its database), `config.yaml`, `policy.json`, and TLS certificates. Regular backups are critical before upgrades, as database corruption or misconfiguration can result in complete loss of node registration and routing state. This skill provides three scripts covering the full lifecycle: backup, restore, and migration. ## Backup Contents A complete backup tarball includes: - **SQLite DB** — Full node state, users, routes, pre-auth keys, API keys - **`config.yaml`** — Headscale server configuration - **`policy.json`** — ACL policy file (if present) - **Certs and keys** — TLS certificate, private key, and node private key (`/var/lib/headscale/`) - **DERP map** — DERP configuration file (if customized) ## Backup Methods - **`sqlite3 .backup`** (recommended) — Safe for live databases; uses SQLite online backup API. This is what `hs-backup.sh` uses. - **File copy (`cp`)** — Requires stopping headscale first to avoid WAL corruption. ## Restore 1. Stop headscale service 2. Restore files from backup tarball 3. Start headscale service 4. Verify with a health check or `headscale nodes list` ## Migration 1. Backup on source host (or use an existing backup) 2. `rsync` or `scp` the backup tarball to the target host 3. Set up headscale on the target (same version) 4. Restore from backup on target 5. Update DNS to point to the new server 6. Verify clients reconnect ## Version Compatibility Source and target headscale versions **should match exactly**. Restoring a database from a different headscale version may cause schema migration failures or data corruption. Check versions with `headscale version` before migrating. ## Automated Backups (Cron) Set up a daily cron job: ```bash 0 2 * * * /path/to/hs-backup.sh --auto --output-dir /backups/headscale/ ``` ## Gotchas - **SQLite WAL mode**: `sqlite3 .backup` is safe; `cp` of the database file while headscale is running will produce a corrupt copy. - **Version mismatch**: Restoring to a different headscale version may break schema migrations. - **Node keys**: If node keys change, all nodes must re-authenticate. - **API keys**: API keys are stored hashed in the database; restoring a DB backup does not recover the original key secrets — regenerate them with `headscale apikeys create`. - **Pre-auth keys**: Pre-auth keys are restored along with the database, but if they've expired they won't work. ## Environment - `HEADSCALE_URL` — Headscale server URL - `HEADSCALE_API_KEY` — API key for health checks and validation ## Trigger Conditions - "backup headscale" - "restore headscale" - "migrate headscale" - "headscale backup" ## When not to use Do not use this skill for deploying or configuring a Headscale server — load `headscale-deploy` instead, or `headscale-node-lifecycle` for node management. It covers backup, restore, and migration of an existing installation only.
-
-
headscale-deploy
-
evals
-
evals.json 5.9 KB
{ "schema_version": 1, "skill_name": "headscale-deploy", "evals": [ { "id": "docker-compose-deploy", "prompt": "Deploy a self-hosted Headscale control server using Docker Compose on our Ubuntu host so our tailnet is not dependent on Tailscale's SaaS.", "expected_output": "Scenario: Docker Compose deployment. The agent loads headscale-deploy and provisions Headscale via Docker Compose, either by generating a compose file with install-headscale.sh --docker or authoring one manually with the headscale image, volume mounts for /var/lib/headscale and /etc/headscale, and ports for control (e.g. 8080) and STUN (3478/udp). The container is started with restart unless-stopped. The agent then verifies the instance is healthy before handing off. The server_url is bound to a real public domain.", "assertions": [ "The headscale-deploy sub-skill is loaded", "A Docker Compose file defines the headscale image, volumes, and ports", "The service is started with restart unless-stopped", "Health is verified (curl health endpoint or health-check script) after start", "server_url is bound to a public domain" ] }, { "id": "binary-install-systemd", "prompt": "Install the headscale control server directly on a lightweight Ubuntu server without Docker. We don't want containers here.", "expected_output": "Scenario: bare-metal binary install. The agent uses install-headscale.sh to detect the platform, download the release tarball, install the binary to /usr/local/bin, create the headscale system user, write a systemd unit, and generate a default config at /etc/headscale/config.yaml. The service is started and enabled. The output reports the installed version and the config path.", "assertions": [ "The headscale binary is installed to /usr/local/bin", "The headscale system user is created", "A systemd unit file is written", "A default config is created at /etc/headscale/config.yaml", "The service is started, enabled, and its version reported" ] }, { "id": "config-server-url-and-magicdns", "prompt": "Configure our new headscale server: it should listen on 0.0.0.0:8080, use the public URL https://headscale.example.com, enable MagicDNS under the example.com domain, and disable the embedded DERP relay.", "expected_output": "Scenario: explicit server configuration. The agent edits config.yaml setting server_url to https://headscale.example.com, listen_addr to 0.0.0.0:8080, dns_config.base_domain to example.com with dns_config.magic_dns true, and derp.server.enabled false. Because headscale does not hot-reload config, the agent restarts the service after the change and verifies the settings took effect.", "assertions": [ "server_url, listen_addr, dns_config.base_domain, and dns_config.magic_dns are set as requested", "derp.server.enabled is disabled", "The service is restarted because headscale does not hot-reload config", "The changed settings are verified after restart" ] }, { "id": "health-verification-gate", "prompt": "I think our headscale deployment is broken — nothing can connect. Run a full health check and tell me what's wrong before we register any clients.", "expected_output": "Scenario: health verification before client registration. The agent runs the health-check script (e.g. headscale-health-check.sh --json) covering server version, database integrity, and API access, plus headscale nodes list and apikeys list. It interprets the results and identifies failures (e.g. DB corruption, TLS issue, service down) with evidence from the check output. The agent does not proceed to client registration while the server is unhealthy.", "assertions": [ "A comprehensive health check (version, DB integrity, API) is run", "headscale nodes list and apikeys list are checked for reachability", "Failures are interpreted and diagnosed with evidence", "Client registration is not attempted while the server is unhealthy" ] }, { "id": "tls-letsencrypt-auto", "prompt": "Set up automatic HTTPS for our headscale server at headscale.example.com using Let's Encrypt so clients connect over TLS without manual certificates.", "expected_output": "Scenario: automatic TLS via Let's Encrypt. The agent configures tls_letsencrypt_hostname to headscale.example.com (and related LetsEncrypt config) so headscale auto-provisions certificates, ensuring port 80 is reachable for the HTTP-01 challenge. Alternatively it routes through a reverse proxy (Caddy/Nginx/Traefik) if port 80 is constrained. The output verifies the HTTPS endpoint and that clients can reach the control plane.", "assertions": [ "tls_letsencrypt_hostname is configured for the public domain", "The HTTP-01 challenge reachability (port 80) is considered", "TLS is terminated or provisioned and the HTTPS endpoint is verified", "Clients can reach the control plane over TLS" ] }, { "id": "backend-choice-scalability", "prompt": "We're planning a tailnet that could grow past 100 nodes and we want high availability. Which database backend should we use, and why? Set it up.", "expected_output": "Scenario: backend selection and provisioning. The agent recommends PostgreSQL over SQLite for tailnets over ~100 nodes or high-availability needs, and explains that choosing upfront avoids a non-trivial migration later. The agent configures db_type accordingly and points headscale at the PostgreSQL DSN, or if SQLite is already in place, flags the migration cost before proceeding.", "assertions": [ "The agent recommends PostgreSQL for >100 nodes or HA", "The rationale (SQLite limits, migration cost) is explained before changing config", "db_type is set to the chosen backend", "The migration cost of switching an existing install is surfaced" ] } ] }
-
-
scripts
-
configure-derp.sh 8.3 KB
#!/usr/bin/env bash # # configure-derp.sh — Configure embedded DERP server in Headscale # # Usage: # configure-derp.sh [--enable|--disable] [--region-id <id>] [--region-name <name>] # [--relay-port <port>] [--config-path <path>] [--test-connectivity] # [--dry-run] [--json] # # Options: # --enable Enable embedded DERP relay in config # --disable Disable embedded DERP relay # --region-id <id> Numeric region ID (default: 999) # --region-name <name> Human-readable region name (default: "my-headscale") # --relay-port <port> STUN/Relay UDP port (default: 3478) # --config-path <path> Path to headscale config.yaml (default: /etc/headscale/config.yaml) # --test-connectivity Run connectivity test after configuration change # --dry-run Preview changes without applying # --json Output structured JSON # --help Show this help and exit # # Examples: # configure-derp.sh --enable --region-id 1 --region-name "us-east" # enable DERP # configure-derp.sh --disable --dry-run # preview disable # configure-derp.sh --enable --test-connectivity --json # enable + test + JSON # configure-derp.sh --region-id 2 --region-name "eu-west" --dry-run # preview region change set -euo pipefail SCRIPT_NAME="$(basename "$0")" CONFIG_PATH="/etc/headscale/config.yaml" ENABLE="" DISABLE="" REGION_ID="999" REGION_NAME="my-headscale" RELAY_PORT="3478" TEST_CONNECTIVITY=false DRY_RUN=false JSON=false # ── Argument parsing ────────────────────────────────────────────────────────── usage() { sed -n '2,31p' "$0" | sed 's/^# //; s/^#$//' exit 0 } while [[ $# -gt 0 ]]; do case "$1" in --enable) ENABLE=true; shift ;; --disable) DISABLE=true; shift ;; --region-id) REGION_ID="$2"; shift 2 ;; --region-name) REGION_NAME="$2"; shift 2 ;; --relay-port) RELAY_PORT="$2"; shift 2 ;; --config-path) CONFIG_PATH="$2"; shift 2 ;; --test-connectivity) TEST_CONNECTIVITY=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --json) JSON=true; shift ;; --help) usage ;; *) echo "Unknown option: $1" >&2; usage ;; esac done # ── Helpers ─────────────────────────────────────────────────────────────────── log() { if ! $JSON; then echo "[${SCRIPT_NAME}] $*"; fi } err() { echo "[${SCRIPT_NAME}] ERROR: $*" >&2; } info() { if ! $JSON; then echo " $*"; fi } json_out() { if $JSON; then echo "$1"; fi } validate_config_exists() { if [[ ! -f "$CONFIG_PATH" ]]; then err "Config file not found: $CONFIG_PATH" json_out '{"error":"config_not_found","config_path":"'"$CONFIG_PATH"'"}' exit 1 fi } # ── YAML manipulation (basic, using python3) ───────────────────────────────── yaml_set() { local key="$1" value="$2" python3 -c " import yaml, sys with open('$CONFIG_PATH') as f: data = yaml.safe_load(f) # Navigate dotted keys like 'derp.server.enabled' parts = '$key'.split('.') d = data for p in parts[:-1]: if p not in d or d[p] is None: d[p] = {} d = d[p] d[parts[-1]] = $value with open('$CONFIG_PATH', 'w') as f: yaml.dump(data, f, default_flow_style=False) " 2>/dev/null } yaml_get() { local key="$1" python3 -c " import yaml, sys with open('$CONFIG_PATH') as f: data = yaml.safe_load(f) parts = '$key'.split('.') d = data for p in parts: if isinstance(d, dict): d = d.get(p) else: d = None break if d is None: sys.exit(1) elif isinstance(d, bool): print(str(d).lower()) else: print(d) " 2>/dev/null || echo "null" } # ── Connectivity test ──────────────────────────────────────────────────────── test_connectivity() { log "Testing DERP connectivity..." local server_url server_url="$(yaml_get server_url)" if [[ "$server_url" == "null" || -z "$server_url" ]]; then err "Cannot test connectivity: server_url not found in config" return 1 fi local health_result derp_result health_result="$(curl -sSf --connect-timeout 5 "$server_url/health" 2>&1 || true)" derp_result="$(curl -sSf --connect-timeout 5 "$server_url/derp" 2>&1 || true)" if echo "$health_result" | grep -qi "ok\|healthy" 2>/dev/null; then info "Health endpoint: OK" else info "Health endpoint: UNREACHABLE ($health_result)" fi if [[ -n "$derp_result" ]]; then info "DERP endpoint: RESPONDED" else info "DERP endpoint: NO RESPONSE" fi # Return combined status if echo "$health_result" | grep -qi "ok\|healthy" 2>/dev/null; then echo "healthy" else echo "unhealthy" fi } # ── Main ────────────────────────────────────────────────────────────────────── main() { validate_config_exists # Determine action local action="preview" if [[ "$ENABLE" == "true" ]]; then action="enable" elif [[ "$DISABLE" == "true" ]]; then action="disable" fi # Read current values local current_enabled current_region_id current_region_name current_stun_addr current_enabled="$(yaml_get derp.server.enabled)" current_region_id="$(yaml_get derp.server.region_id)" current_region_name="$(yaml_get derp.server.region_name)" current_stun_addr="$(yaml_get derp.server.stun_listen_addr)" if $JSON; then log "" fi log "Current DERP config:" info " enabled: ${current_enabled:-null}" info " region_id: ${current_region_id:-null}" info " region_name: ${current_region_name:-null}" info " stun_addr: ${current_stun_addr:-null}" if $DRY_RUN; then log "[DRY-RUN] Would apply the following changes:" if [[ "$action" == "enable" ]]; then info " derp.server.enabled = true" info " derp.server.region_id = $REGION_ID" info " derp.server.region_name = $REGION_NAME" info " derp.server.stun_listen_addr = 0.0.0.0:$RELAY_PORT" elif [[ "$action" == "disable" ]]; then info " derp.server.enabled = false" else info " No change requested (use --enable or --disable)" fi json_out '{"action":"'"$action"'","dry_run":true,"config_path":"'"$CONFIG_PATH"'","current":{"enabled":'"${current_enabled:-false}"',"region_id":'"${current_region_id:-0}"',"region_name":"'"${current_region_name:-}"'"},"proposed":{"enabled":'"${ENABLE:-false}"',"region_id":'"$REGION_ID"',"region_name":"'"$REGION_NAME"'","relay_port":'"$RELAY_PORT"'}}' return 0 fi # Apply changes if [[ "$action" == "enable" ]]; then log "Enabling embedded DERP server..." yaml_set derp.server.enabled true yaml_set derp.server.region_id "$REGION_ID" yaml_set derp.server.region_name "\"$REGION_NAME\"" yaml_set derp.server.stun_listen_addr "\"0.0.0.0:$RELAY_PORT\"" log "DERP server configured: region $REGION_ID ($REGION_NAME), port $RELAY_PORT/udp" log "Note: Restart headscale to apply changes" elif [[ "$action" == "disable" ]]; then log "Disabling embedded DERP server..." yaml_set derp.server.enabled false log "DERP server disabled. Restart headscale to apply changes" else err "No action specified. Use --enable or --disable." usage fi # Connectivity test local connectivity_status="not_tested" if $TEST_CONNECTIVITY; then connectivity_status="$(test_connectivity || echo 'failed')" fi # Read back final values local final_enabled final_region_id final_region_name final_enabled="$(yaml_get derp.server.enabled)" final_region_id="$(yaml_get derp.server.region_id)" final_region_name="$(yaml_get derp.server.region_name)" json_out '{"action":"'"$action"'","dry_run":false,"config_path":"'"$CONFIG_PATH"'","applied":{"enabled":'"${final_enabled:-false}"',"region_id":'"${final_region_id:-0}"',"region_name":"'"${final_region_name:-}"'"},"connectivity":"'"$connectivity_status"'"}' } main -
headscale-health-check.sh 8.1 KB
#!/usr/bin/env bash # # headscale-health-check.sh — Comprehensive health probe for Headscale # # Usage: # headscale-health-check.sh [--json] [--watch] [--interval <sec>] [--api-key <key>] # [--url <url>] [--config-path <path>] [--help] # # Options: # --json Output structured JSON (default: human-readable) # --watch Continuous monitoring mode (repeats every --interval) # --interval <sec> Polling interval in seconds for --watch (default: 30) # --api-key <key> Headscale API key (lazy auth: reads HEADSCALE_API_KEY env) # --url <url> Headscale server URL (lazy auth: reads HEADSCALE_URL env) # --config-path <path> Path to headscale config.yaml (default: /etc/headscale/config.yaml) # --help Show this help and exit # # Examples: # headscale-health-check.sh # human-readable # HEADSCALE_URL=https://hs.example.com headscale-health-check.sh # via env var # headscale-health-check.sh --json # structured output # headscale-health-check.sh --watch --interval 60 # monitor every 60s # headscale-health-check.sh --url https://hs.example.com --api-key k-xxx # explicit set -euo pipefail SCRIPT_NAME="$(basename "$0")" JSON=false WATCH=false INTERVAL=30 API_KEY="" SERVER_URL="" CONFIG_PATH="/etc/headscale/config.yaml" # ── Argument parsing ────────────────────────────────────────────────────────── usage() { sed -n '2,27p' "$0" | sed 's/^# //; s/^#$//' exit 0 } while [[ $# -gt 0 ]]; do case "$1" in --json) JSON=true; shift ;; --watch) WATCH=true; shift ;; --interval) INTERVAL="$2"; shift 2 ;; --api-key) API_KEY="$2"; shift 2 ;; --url) SERVER_URL="$2"; shift 2 ;; --config-path) CONFIG_PATH="$2"; shift 2 ;; --help) usage ;; *) echo "Unknown option: $1" >&2; usage ;; esac done # ── Helpers ─────────────────────────────────────────────────────────────────── log() { if ! $JSON; then echo "[${SCRIPT_NAME}] $*"; fi } err() { echo "[${SCRIPT_NAME}] ERROR: $*" >&2; } out() { if ! $JSON; then echo "$*"; fi } json_out() { if $JSON; then echo "$1"; fi } derive_url_and_key() { # Lazy auth: env vars, then config file, then explicit params if [[ -z "$SERVER_URL" ]]; then SERVER_URL="${HEADSCALE_URL:-}" fi if [[ -z "$API_KEY" ]]; then API_KEY="${HEADSCALE_API_KEY:-}" fi # Try reading from config file if still empty if [[ -z "$SERVER_URL" && -f "$CONFIG_PATH" ]]; then SERVER_URL="$(python3 -c " import yaml with open('$CONFIG_PATH') as f: d = yaml.safe_load(f) print(d.get('server_url', '')) " 2>/dev/null || true)" fi # Try running headscale binary for URL if available if [[ -z "$SERVER_URL" ]] && command -v headscale &>/dev/null; then SERVER_URL="$(headscale config 2>/dev/null | grep 'server_url' | awk '{print $2}' || true)" fi if [[ -z "$SERVER_URL" ]]; then err "Cannot determine Headscale server URL. Set HEADSCALE_URL, pass --url, or ensure config at $CONFIG_PATH" return 1 fi return 0 } # ── Health checks ───────────────────────────────────────────────────────────── check_health_endpoint() { local url="${SERVER_URL}/health" local result result="$(curl -sSf --connect-timeout 5 --max-time 10 "$url" 2>&1 || true)" if echo "$result" | grep -qi "ok\|healthy\|200" 2>/dev/null; then echo "true" else echo "false" fi } check_version_endpoint() { local url="${SERVER_URL}/version" local result result="$(curl -sSf --connect-timeout 5 --max-time 10 "$url" 2>&1 || true)" if [[ -n "$result" ]] && ! echo "$result" | grep -qi "error\|not found\|404"; then echo "$result" | head -1 else echo "unknown" fi } check_api_key() { if [[ -z "$API_KEY" ]]; then echo "no_key" return fi local url="${SERVER_URL}/api/v1/apikey" local result result="$(curl -sSf --connect-timeout 5 --max-time 10 \ -H "Authorization: Bearer ${API_KEY}" \ "${url}" 2>&1 || true)" if echo "$result" | grep -qi "valid\|ok\|200" 2>/dev/null; then echo "valid" elif echo "$result" | grep -qi "unauthorized\|invalid\|401\|403" 2>/dev/null; then echo "invalid" else echo "unknown" fi } get_node_count() { if [[ -z "$API_KEY" ]]; then echo "-1" return fi local url="${SERVER_URL}/api/v1/node" local result result="$(curl -sSf --connect-timeout 5 --max-time 10 \ -H "Authorization: Bearer ${API_KEY}" \ "${url}" 2>&1 || true)" echo "$result" | python3 -c " import json,sys try: d=json.load(sys.stdin) print(len(d.get('nodes',[]))) except: print('-1') " 2>/dev/null || echo "-1" } check_db_integrity() { if command -v headscale &>/dev/null; then local result result="$(headscale db stats 2>&1 || true)" if echo "$result" | grep -qi "error\|failed\|corrupt" 2>/dev/null; then echo "false" elif [[ -n "$result" ]]; then echo "true" else echo "unknown" fi else echo "unknown" fi } check_derp_endpoint() { local url="${SERVER_URL}/derp" local result result="$(curl -sSf --connect-timeout 5 --max-time 10 "$url" 2>&1 || true)" if [[ -n "$result" ]] && ! echo "$result" | grep -qi "error\|not found\|404\|refused"; then echo "true" else echo "false" fi } get_uptime() { if command -v headscale &>/dev/null; then headscale debug stats 2>/dev/null | grep -i "uptime" | awk '{print $NF}' || echo "unknown" else echo "unknown" fi } # ── Run check ───────────────────────────────────────────────────────────────── run_health_check() { if ! derive_url_and_key; then if $JSON; then json_out '{"healthy":false,"error":"cannot_determine_url"}' else err "Cannot determine Headscale server URL." err "Set HEADSCALE_URL environment variable or pass --url." fi return 1 fi local healthy=true local health_ok version_str api_status db_ok derp_ok node_count uptime health_ok="$(check_health_endpoint)" version_str="$(check_version_endpoint)" api_status="$(check_api_key)" node_count="$(get_node_count)" db_ok="$(check_db_integrity)" derp_ok="$(check_derp_endpoint)" uptime="$(get_uptime)" if [[ "$health_ok" != "true" ]]; then healthy=false fi if $JSON; then json_out '{"version":"'"${version_str}"'","healthy":'"${healthy}"',"nodes":'"${node_count}"',"db_ok":'"${db_ok}"',"api_ok":"'"${api_status}"'","derp_ok":'"${derp_ok}"',"health_endpoint":'"${health_ok}"',"uptime":"'"${uptime}"'"}' else out "========================================" out " Headscale Health Report " out "========================================" out " Server URL: ${SERVER_URL}" out " Version: ${version_str}" out " Health Endpoint: ${health_ok}" out " API Key: ${api_status}" out " Nodes: ${node_count}" out " DB Integrity: ${db_ok}" out " DERP Relay: ${derp_ok}" out " Uptime: ${uptime}" out "----------------------------------------" if $healthy; then out " STATUS: HEALTHY" else out " STATUS: CHECK FAILURES DETECTED" fi out "========================================" fi } # ── Main ────────────────────────────────────────────────────────────────────── main() { if $WATCH; then log "Watching Headscale health every ${INTERVAL}s (Ctrl+C to stop)..." while true; do run_health_check || true sleep "$INTERVAL" done else run_health_check fi } main -
install-headscale.sh 9.4 KB
#!/usr/bin/env bash # # install-headscale.sh — Install or upgrade the Headscale binary # # Usage: # install-headscale.sh [--version <tag>] [--config-path <path>] [--dry-run] [--json] [--docker] # # Options: # --version <tag> Headscale version to install (default: latest stable) # --config-path <path> Path for config.yaml (default: /etc/headscale/config.yaml) # --docker Deploy using Docker Compose instead of bare binary # --dry-run Preview actions without making changes # --json Output structured JSON # --help Show this help and exit # # Examples: # install-headscale.sh # install latest # install-headscale.sh --version v0.23.0 # specific version # install-headscale.sh --docker --dry-run # preview Docker deploy # install-headscale.sh --json # structured output # install-headscale.sh --version v0.23.0 --config-path /custom # custom config path set -euo pipefail SCRIPT_NAME="$(basename "$0")" VERSION="" CONFIG_PATH="/etc/headscale/config.yaml" DRY_RUN=false JSON=false DOCKER=false RELEASE_API="https://api.github.com/repos/juanfont/headscale/releases" GITHUB_DL="https://github.com/juanfont/headscale/releases/download" # ── Argument parsing ────────────────────────────────────────────────────────── usage() { sed -n '2,31p' "$0" | sed 's/^# //; s/^#$//' exit 0 } while [[ $# -gt 0 ]]; do case "$1" in --version) VERSION="$2"; shift 2 ;; --config-path) CONFIG_PATH="$2"; shift 2 ;; --dry-run) DRY_RUN=true; shift ;; --json) JSON=true; shift ;; --docker) DOCKER=true; shift ;; --help) usage ;; *) echo "Unknown option: $1" >&2; usage ;; esac done # ── Helpers ─────────────────────────────────────────────────────────────────── log() { if ! $JSON; then echo "[${SCRIPT_NAME}] $*"; fi } err() { echo "[${SCRIPT_NAME}] ERROR: $*" >&2; } json_out() { if $JSON; then echo "$1" fi } detect_arch() { local arch arch="$(uname -m)" case "$arch" in x86_64) echo "amd64" ;; aarch64) echo "arm64" ;; armv7l) echo "armv7" ;; *) err "Unsupported architecture: $arch"; exit 1 ;; esac } detect_os() { local os os="$(uname -s)" case "$os" in Linux) echo "linux" ;; Darwin) echo "darwin" ;; *) err "Unsupported OS: $os"; exit 1 ;; esac } get_latest_version() { local tag tag="$(curl -sSfL "$RELEASE_API/latest" 2>/dev/null | grep '"tag_name"' | cut -d'"' -f4 || true)" if [[ -z "$tag" ]]; then # Fallback: list releases and pick first non-prerelease tag="$(curl -sSfL "$RELEASE_API?per_page=10" 2>/dev/null \ | python3 -c " import json,sys releases=json.load(sys.stdin) for r in releases: if not r.get('prerelease') and r.get('tag_name'): print(r['tag_name']) break " 2>/dev/null || true)" fi echo "${tag:-v0.23.0}" } check_current_version() { if command -v headscale &>/dev/null; then headscale version 2>/dev/null | head -1 | tr -d '[:space:]' || echo "" else echo "" fi } # ── Docker Compose deployment ──────────────────────────────────────────────── deploy_docker() { local ver="${VERSION:-latest}" local cfg_dir cfg_dir="$(dirname "$CONFIG_PATH")" log "Preparing Docker Compose deployment (version: $ver)" log "Config directory: $cfg_dir" if $DRY_RUN; then log "[DRY-RUN] Would create $cfg_dir/docker-compose.yml" log "[DRY-RUN] Would create $cfg_dir/config.yaml" log "[DRY-RUN] Would run: docker compose up -d" json_out '{"version":"'"$ver"'","method":"docker","dry_run":true,"config_dir":"'"$cfg_dir"'","status":"preview"}' return 0 fi mkdir -p "$cfg_dir" # Write docker-compose.yml cat > "$cfg_dir/docker-compose.yml" <<COMPOSE version: "3.9" services: headscale: image: headscale/headscale:${ver#v} container_name: headscale restart: unless-stopped ports: - "8080:8080" - "3478:3478/udp" volumes: - ${cfg_dir}/data:/var/lib/headscale - ${cfg_dir}:/etc/headscale command: headscale serve COMPOSE log "Created $cfg_dir/docker-compose.yml" # Write default config if it doesn't exist if [[ ! -f "$CONFIG_PATH" ]]; then write_default_config "$CONFIG_PATH" "$cfg_dir" log "Created default config at $CONFIG_PATH" fi log "Starting Headscale with Docker Compose..." (cd "$cfg_dir" && docker compose up -d) json_out '{"version":"'"$ver"'","method":"docker","dry_run":false,"config_dir":"'"$cfg_dir"'","status":"deployed"}' } # ── Binary deployment ──────────────────────────────────────────────────────── deploy_binary() { local arch os url ver installed_ver arch="$(detect_arch)" os="$(detect_os)" if [[ -z "$VERSION" ]]; then ver="$(get_latest_version)" else ver="${VERSION#v}" ver="v${ver}" fi installed_ver="$(check_current_version)" if [[ "$installed_ver" == "$ver" ]]; then log "Headscale $ver is already installed. Skipping." json_out '{"version":"'"$ver"'","action":"skipped","reason":"already_installed","installed":true}' return 0 fi local tarball="headscale_${ver#v}_${os}_${arch}.tar.gz" url="${GITHUB_DL}/${ver}/${tarball}" log "Version: $ver" log "Platform: ${os}/${arch}" log "Download: $url" log "Config: $CONFIG_PATH" if $DRY_RUN; then log "[DRY-RUN] Would download: $url" log "[DRY-RUN] Would install to: /usr/local/bin/headscale" log "[DRY-RUN] Would create user: headscale" log "[DRY-RUN] Would create systemd unit" log "[DRY-RUN] Would write config: $CONFIG_PATH" json_out '{"version":"'"$ver"'","method":"binary","dry_run":true,"arch":"'"$arch"'","os":"'"$os"'","config_path":"'"$CONFIG_PATH"'","status":"preview"}' return 0 fi # Download log "Downloading Headscale $ver..." local tmpdir tmpdir="$(mktemp -d)" curl -sSfL "$url" -o "$tmpdir/$tarball" tar -xzf "$tmpdir/$tarball" -C "$tmpdir" # Install binary install -o root -g root -m 0755 "$tmpdir/headscale" /usr/local/bin/headscale log "Installed binary to /usr/local/bin/headscale" # Create system user if ! id -u headscale &>/dev/null; then useradd --system --no-create-home --shell /usr/sbin/nologin headscale log "Created system user: headscale" fi # Create config directory local cfg_dir cfg_dir="$(dirname "$CONFIG_PATH")" mkdir -p "$cfg_dir" # Write config if it doesn't exist if [[ ! -f "$CONFIG_PATH" ]]; then write_default_config "$CONFIG_PATH" "$cfg_dir" log "Created default config at $CONFIG_PATH" fi # Set permissions chown -R headscale:headscale "$cfg_dir" # Create systemd unit local unit_file="/etc/systemd/system/headscale.service" if [[ ! -f "$unit_file" ]]; then cat > "$unit_file" <<UNIT [Unit] Description=headscale control server After=network-online.target Wants=network-online.target [Service] Type=simple User=headscale Group=headscale ExecStart=/usr/local/bin/headscale serve Restart=on-failure RestartSec=5 WorkingDirectory=${cfg_dir} LimitNOFILE=65536 [Install] WantedBy=multi-user.target UNIT log "Created systemd unit: $unit_file" systemctl daemon-reload fi # Enable and start systemctl enable headscale.service systemctl start headscale.service log "Headscale service started and enabled" # Cleanup rm -rf "$tmpdir" json_out '{"version":"'"$ver"'","method":"binary","dry_run":false,"arch":"'"$arch"'","os":"'"$os"'","config_path":"'"$CONFIG_PATH"'","status":"deployed","service":"headscale.service"}' } # ── Default config writer ──────────────────────────────────────────────────── write_default_config() { local path="$1" local data_dir="$2/data" cat > "$path" <<CONFIG # headscale configuration server_url: https://localhost:443 listen_addr: 0.0.0.0:8080 metrics_listen_addr: 127.0.0.1:9090 grpc_listen_addr: 127.0.0.1:50443 grpc_allow_insecure: false database: type: sqlite3 path: ${data_dir}/db.sqlite3 tls_letsencrypt_hostname: "" tls_letsencrypt_cache_dir: ${data_dir}/cache tls_letsencrypt_challenge_type: HTTP-01 tls_cert_path: "" tls_key_path: "" log: level: info format: text acl_policy_path: "" dns_config: nameservers: - 1.1.1.1 - 8.8.8.8 domains: [] magic_dns: true base_domain: example.com unix_socket: /var/run/headscale/headscale.sock unix_socket_permission: "0770" derp: server: enabled: false region_id: 999 region_name: "my-headscale" stun_listen_addr: "0.0.0.0:3478" urls: [] paths: [] auto_update_enabled: true update_frequency: 24h ephemeral_node_inactivity_timeout: 30m node_update_check_interval: 10s CONFIG } # ── Main ────────────────────────────────────────────────────────────────────── main() { if $DOCKER; then deploy_docker else deploy_binary fi } main
-
-
README.md 479 B
# Headscale Deploy ## Why Install This Skill Helps set up and maintain a self-hosted Headscale control server. ## What You Get | Content | Purpose | |---|---| | `SKILL.md` and `scripts/` | Deployment and maintenance workflow | ## Quick Start Follow the deployment sequence in `SKILL.md` for Linux or Docker. ## Triggers Use when deploying, configuring, or troubleshooting Headscale. ## Requirements Linux or Docker host access and a Headscale-compatible network setup. -
SKILL.md 4.9 KB
--- name: headscale-deploy description: Deploy, configure, and maintain a self-hosted Headscale control server on Linux or Docker. Use when setting up a new Headscale instance, troubleshooting deployment issues, or configuring server settings. license: MIT compatibility: linux, docker metadata: tags: headscale, tailscale, wireguard, vpn, deployment, devops spec-version: '1.0' --- # headscale-deploy ## Overview Headscale is an open-source, self-hosted implementation of the Tailscale control server. It allows you to run your own coordination plane for WireGuard-based mesh networking, giving you full control over your tailnet without relying on Tailscale's SaaS infrastructure. The Tailscale client connects to Headscale transparently — no client modifications needed. Use this skill to deploy Headscale from scratch, configure server settings, manage DERP relay infrastructure, and diagnose deployment issues. ## Prerequisites - **Linux server** (x86_64 or aarch64) or **Docker host** with compose support - **DNS record** pointing to the server (A/AAAA record for `server_url`) - **Ports 80/443** accessible from the internet (or your tailnet's ingress point) - **Port 3478/udp** for STUN (optional, needed for NAT traversal) - Root or sudo access on the target machine ## Deployment Methods ### Docker Compose (Recommended) The fastest and most maintainable approach. Use `install-headscale.sh` with `--docker` flag to generate a compose file and systemd drop-in, or create manually: ```yaml version: "3.9" services: headscale: image: headscale/headscale:latest container_name: headscale restart: unless-stopped ports: - "8080:8080" - "3478:3478/udp" volumes: - ./data:/var/lib/headscale - ./config:/etc/headscale command: headscale serve ``` ### Binary Install Direct binary installation on the host for lightweight or container-free environments. The `install-headscale.sh` script handles: 1. Detecting platform (linux/amd64, linux/arm64) 2. Downloading the release tarball from GitHub 3. Installing the binary to `/usr/local/bin` 4. Creating the `headscale` system user 5. Writing a systemd unit file 6. Creating default config at `/etc/headscale/config.yaml` ## Configuration Key `config.yaml` options: | Option | Description | Example | |---|---|---| | `server_url` | Public URL of your Headscale instance | `https://headscale.example.com:443` | | `listen_addr` | Local bind address | `0.0.0.0:8080` | | `metrics_listen_addr` | Prometheus metrics endpoint | `127.0.0.1:9090` | | `dns_config.base_domain` | MagicDNS domain suffix | `example.com` | | `dns_config.magic_dns` | Enable MagicDNS | `true` | | `derp.server.enabled` | Enable embedded DERP relay | `false` | | `derp.server.region_id` | Numeric region ID | `999` | | `derp.server.region_name` | Human-readable region name | `"my-headscale"` | | `derp.urls` | External DERP map URLs | `[]` | | `db_type` | Database backend: `sqlite3` or `postgres` | `sqlite3` | | `tls_letsencrypt_hostname` | Auto TLS via Let's Encrypt | `""` | | `tls_cert_path` / `tls_key_path` | Manual TLS cert paths | `""` | ## Verification After deployment, verify the instance is healthy: ```bash # Quick health check curl -s https://headscale.example.com/health # Comprehensive diagnostics headscale-health-check.sh --json # Check registered nodes headscale nodes list # Verify API access headscale apikeys list ``` ## Gotchas - **SQLite vs PostgreSQL**: SQLite is fine for small tailnets (<100 nodes). For larger deployments or high-availability, use PostgreSQL. Plan your choice upfront — migration is non-trivial. - **TLS certificate management**: Let's Encrypt auto-provisioning is convenient but requires port 80 to be accessible for the HTTP-01 challenge. Use a reverse proxy (Caddy, Nginx, Traefik) for more flexibility. - **Port conflicts**: If port 8080 or 3478 is already in use, change `listen_addr` in config. Ensure no other service binds port 3478/udp for STUN. - **DERP configuration**: The embedded DERP relay works for small deployments. For production, set up dedicated DERP nodes to avoid single-region bottlenecks. - **Configuration reload**: Headscale does not hot-reload config. Restart the service after config changes: `systemctl restart headscale` or `docker compose restart`. - **Database backups**: Always back up `/var/lib/headscale/db.sqlite3` (or your PostgreSQL DB) regularly. ## Trigger Conditions Use this skill when the user says any of: - "deploy headscale" - "install headscale" - "setup headscale server" - "headscale config" - "headscale configuration" - "headscale deployment" - "headscale health" - "headscale derp" - "self-hosted tailscale" - "tailscale control server" ## When not to use Do not use this skill for client-side setup (load `tailscale-client` instead), for ACL/policy authoring (load `tailnet-policy`), or for day-to-day management of an already-running server. It covers initial deployment and server configuration only.
-
-
headscale-derp
-
evals
-
evals.json 5.2 KB
{ "schema_version": 1, "skill_name": "headscale-derp", "evals": [ { "id": "embedded-derp-enable", "prompt": "Enable DERP relay support in our headscale server for a small tailnet (about 20 nodes) so clients can connect when direct peering fails. Use the built-in relay.", "expected_output": "Scenario: enabling the embedded DERP server. The agent configures derp.server.enabled true with a region_id, region_code, region_name, and stun_listen_addr (0.0.0.0:3478) in config.yaml, restarts headscale, and confirms the embedded relay is serving. Because the embedded relay shares the headscale process, the agent notes it is suitable for the small tailnet and flags when a standalone DERP would be needed.", "assertions": [ "derp.server.enabled and its region settings are configured", "The STUN listener address is set for 0.0.0.0:3478", "headscale is restarted and the embedded relay is confirmed serving", "The small-tailnet suitability and standalone-DERP threshold are stated" ] }, { "id": "custom-derp-map-region", "prompt": "Direct peer connections keep failing across our office NAT. Add a custom DERP relay region for our region and verify clients can relay through it.", "expected_output": "Scenario: custom DERP map. The agent writes a DERP map JSON with a new region (unique region_id, region_code, region_name) and a node with HostName, DERPPort, and STUNPort, then wires it into the headscale config via derp.paths or derp.urls. The config is reloaded/restarted. The agent verifies relay connectivity through the new region using tailscale netcheck or tailscale status rather than assuming the config was picked up, accounting for client DERP-map caching.", "assertions": [ "A custom DERP map with a unique region and node is written", "The map is wired into headscale via derp.paths or derp.urls", "The config is reloaded/restarted", "Relay connectivity through the new region is verified via netcheck/status", "Client DERP-map caching behavior is accounted for" ] }, { "id": "standalone-derp-deploy", "prompt": "Our tailnet is now over 60 nodes and the embedded relay is struggling under active relay traffic. Deploy a dedicated standalone DERP relay node.", "expected_output": "Scenario: standalone DERP deployment. The agent deploys the official tailscale/derper container with restart always, mapping ports 3478/udp and 443, mounting certs and a data dir, and passing --hostname for the relay. TLS is provisioned (Let's Encrypt auto or manual certs). The output reports the relay endpoint and how it is referenced from headscale (derp.urls or the map), and notes the performance rationale for moving off the embedded relay.", "assertions": [ "The tailscale/derper container is deployed with ports 3478/udp and 443", "TLS is provisioned for the relay (auto or manual certs)", "The relay is reachable and registered for use by headscale", "The performance rationale (offload from embedded relay) is stated" ] }, { "id": "netcheck-diagnostics-interpret", "prompt": "Clients are reporting slow connections. Run netcheck on one of them and tell me what the DERP latency results mean and whether we need another region.", "expected_output": "Scenario: connectivity diagnostics and interpretation. The agent runs tailscale netcheck and interprets the report: whether UDP/STUN works, whether mapping varies by destination (NAT), the nearest DERP region and per-region latency, and hairpinning/port-mapping status. It translates the numbers into a recommendation — e.g. whether the nearest region is fast enough or whether another geographically diverse region should be added for resilience. It explains that direct connections are preferred and DERP adds latency.", "assertions": [ "tailscale netcheck is run and its raw output captured", "The report is interpreted (UDP/STUN, NAT mapping, nearest DERP, per-region latency)", "A concrete recommendation on region placement is given", "The latency cost of DERP vs direct is explained" ] }, { "id": "stun-blocked-fallback", "prompt": "On our corporate network, UDP port 3478 appears to be filtered. Clients can't direct-connect. Explain what will happen and how we should configure relays so the tailnet still works.", "expected_output": "Scenario: STUN-blocked fallback behavior. The agent explains that when STUN is blocked, Tailscale cannot establish direct peer-to-peer connections and routes ALL traffic through the nearest DERP relay, so relay capacity and region placement become critical. It configures redundant DERP regions so clients have a fallback and notes that port 3478 (STUN) and 443 (DERP/TCP) must both be reachable. It recommends at least two geographically diverse relays.", "assertions": [ "The all-traffic-through-DERP consequence of STUN being blocked is explained", "Port 3478 (STUN) and 443 (DERP/TCP) reachability is emphasized", "At least two geographically diverse DERP regions are configured/recommended", "A fallback for clients when one relay is unreachable is established" ] } ] }
-
-
scripts
-
deploy-derp.sh 10.2 KB
#!/usr/bin/env bash # =========================================================================== # headscale-derp: deploy-derp.sh — Deploy standalone DERP server (Docker) # =========================================================================== # Deploys a standalone Tailscale DERP relay server using the official # tailscale/derper Docker image. # # Usage: # deploy-derp.sh --region-id <ID> --region-name <NAME> --hostname <HOST> # [--cert <PATH> --key <PATH>] [--dry-run] [--json] [--output-dir <DIR>] # [--compose] [--stun-port <PORT>] [--relay-port <PORT>] # # Options: # --region-id Numeric region ID (e.g., 900). Required. # --region-name Human-readable region name (e.g., "New York"). Required. # --region-code Short region code (e.g., "us-nyc"). Default: from hostname. # --hostname FQDN for the DERP server. Required. # --cert Path to TLS cert PEM file (mutually exclusive with --acme). # --key Path to TLS key PEM file (requires --cert). # --acme Use Let's Encrypt auto-cert instead of manual certs. # --stun-port STUN UDP port. Default: 3478. # --relay-port DERP TCP/TLS port. Default: 443. # --verify-domain Domain to verify TLS against (default: --hostname). # --output-dir Directory for generated DERP map JSON. Default: ./derp-maps # --compose Generate a docker-compose.yml alongside the DERP map. # --dry-run Print configuration without deploying. # --json Output structured JSON instead of human-readable text. # --help Show this help message and exit. # # Examples: # deploy-derp.sh --region-id 900 --region-name "New York" \ # --hostname derp-nyc.example.com --acme # # deploy-derp.sh --region-id 901 --region-name "Frankfurt" \ # --hostname derp-fra.example.com \ # --cert /etc/certs/fullchain.pem --key /etc/certs/privkey.pem # # deploy-derp.sh --region-id 902 --region-name "Tokyo" \ # --hostname derp-tokyo.example.com --acme --compose --dry-run # =========================================================================== set -euo pipefail # --- Constants --- SCRIPT_NAME="$(basename "$0")" DEFAULT_STUN_PORT=3478 DEFAULT_RELAY_PORT=443 DEFAULT_OUTPUT_DIR="./derp-maps" HEADSCALE_DIR="${HEADSCALE_DIR:-}" DERPER_IMAGE="tailscale/derper:latest" # --- Colors (disabled if not a terminal) --- if [[ -t 2 ]]; then RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' CYAN='\033[0;36m'; NC='\033[0m' else RED=''; GREEN=''; YELLOW=''; CYAN=''; NC='' fi log_info() { echo -e "${GREEN}[INFO]${NC} $*" >&2; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } # --- Help --- usage() { sed -n '/^# Usage:/,/^$/{s/^# //;s/^#//;p;}' "$0" | sed '$d' exit 0 } # --- Parse arguments --- REGION_ID="" REGION_NAME="" REGION_CODE="" HOSTNAME="" CERT_PATH="" KEY_PATH="" ACME=false STUN_PORT="$DEFAULT_STUN_PORT" RELAY_PORT="$DEFAULT_RELAY_PORT" VERIFY_DOMAIN="" OUTPUT_DIR="$DEFAULT_OUTPUT_DIR" COMPOSE=false DRY_RUN=false JSON_OUTPUT=false while [[ $# -gt 0 ]]; do case "$1" in --region-id) REGION_ID="$2"; shift 2 ;; --region-name) REGION_NAME="$2"; shift 2 ;; --region-code) REGION_CODE="$2"; shift 2 ;; --hostname) HOSTNAME="$2"; shift 2 ;; --cert) CERT_PATH="$2"; shift 2 ;; --key) KEY_PATH="$2"; shift 2 ;; --acme) ACME=true; shift ;; --stun-port) STUN_PORT="$2"; shift 2 ;; --relay-port) RELAY_PORT="$2"; shift 2 ;; --verify-domain) VERIFY_DOMAIN="$2"; shift 2 ;; --output-dir) OUTPUT_DIR="$2"; shift 2 ;; --compose) COMPOSE=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --json) JSON_OUTPUT=true; shift ;; --help|-h) usage ;; *) log_error "Unknown argument: $1"; exit 1 ;; esac done # --- Validation --- ERRORS=() [[ -z "$REGION_ID" ]] && ERRORS+=("--region-id is required") [[ -z "$REGION_NAME" ]] && ERRORS+=("--region-name is required") [[ -z "$HOSTNAME" ]] && ERRORS+=("--hostname is required") if [[ -n "$CERT_PATH" && -z "$KEY_PATH" ]]; then ERRORS+=("--cert requires --key") fi if [[ -z "$CERT_PATH" && "$ACME" == false ]]; then ERRORS+=("Either --cert/--key or --acme is required") fi if [[ -n "$CERT_PATH" && "$ACME" == true ]]; then ERRORS+=("--cert/--key and --acme are mutually exclusive") fi # Validate region ID is numeric if [[ -n "$REGION_ID" && ! "$REGION_ID" =~ ^[0-9]+$ ]]; then ERRORS+=("--region-id must be a number, got: $REGION_ID") fi if [[ ${#ERRORS[@]} -gt 0 ]]; then for e in "${ERRORS[@]}"; do log_error "$e"; done echo "" >&2 usage fi # --- Defaults --- [[ -z "$REGION_CODE" ]] && REGION_CODE="${HOSTNAME%%.*}" [[ -z "$VERIFY_DOMAIN" ]] && VERIFY_DOMAIN="$HOSTNAME" # --- Build DERP map JSON --- build_derp_map() { cat <<JSON { "Regions": { "${REGION_ID}": { "RegionID": ${REGION_ID}, "RegionCode": "${REGION_CODE}", "RegionName": "${REGION_NAME}", "Nodes": [ { "Name": "${REGION_ID}a", "RegionID": ${REGION_ID}, "HostName": "${HOSTNAME}", "DERPPort": ${RELAY_PORT}, "STUNPort": ${STUN_PORT}, "STUNOnly": false } ] } } } JSON } # --- Build docker-compose.yml --- build_compose() { local vol_certs vol_data cmd_extra vol_certs="" vol_data="/var/lib/derper:/var/lib/derper" cmd_extra="" if [[ "$ACME" == true ]]; then vol_certs="/etc/letsencrypt:/certs" cmd_extra="" else vol_certs="${CERT_PATH}:${CERT_PATH}:ro" # Derper requires cert+key as mounted files; the --cert and --key flags # point to the container-side paths. cmd_extra="--cert=${CERT_PATH} --key=${KEY_PATH}" fi cat <<YAML version: "3.8" services: derper: image: ${DERPER_IMAGE} container_name: derper-${REGION_CODE} restart: always ports: - "${STUN_PORT}:${STUN_PORT}/udp" - "${RELAY_PORT}:${RELAY_PORT}" volumes: - ${vol_certs} - ${vol_data} environment: - DERP_HOST=${HOSTNAME} - DERP_ADDR=:${RELAY_PORT} - DERP_STUN_ADDR=:${STUN_PORT} - DERP_CERT_MODE=${ACME} - DERP_VERIFY_CLIENTS=false command: - "--hostname=${HOSTNAME}" - "--addr=:${RELAY_PORT}" - "--stun-port=${STUN_PORT}" ${cmd_extra:+${cmd_extra}} YAML } # --- Generate output --- DERP_MAP_JSON=$(build_derp_map) if [[ "$JSON_OUTPUT" == true ]]; then # Structured JSON output OUTPUT=$(cat <<EOF { "status": "ok", "script": "${SCRIPT_NAME}", "dry_run": ${DRY_RUN}, "region": { "id": ${REGION_ID}, "name": "${REGION_NAME}", "code": "${REGION_CODE}", "hostname": "${HOSTNAME}" }, "ports": { "stun": ${STUN_PORT}, "relay": ${RELAY_PORT} }, "tls": $(if [[ "$ACME" == true ]]; then echo '"lets-encrypt"'; else echo '{"cert":"'"${CERT_PATH}"'","key":"'"${KEY_PATH}"'"}'; fi), "derp_map": ${DERP_MAP_JSON} } EOF ) echo "$OUTPUT" exit 0 fi # --- Dry-run / deploy --- if [[ "$DRY_RUN" == true ]]; then log_info "=== DERP Deployment Configuration (DRY RUN) ===" echo "" echo "Region ID: ${REGION_ID}" echo "Region Name: ${REGION_NAME}" echo "Region Code: ${REGION_CODE}" echo "Hostname: ${HOSTNAME}" echo "STUN Port: ${STUN_PORT}" echo "Relay Port: ${RELAY_PORT}" if [[ "$ACME" == true ]]; then echo "TLS: Let's Encrypt (auto)" else echo "TLS Cert: ${CERT_PATH}" echo "TLS Key: ${KEY_PATH}" fi echo "" echo "--- DERP Map (${OUTPUT_DIR}/derp-region-${REGION_ID}.json) ---" echo "${DERP_MAP_JSON}" | python3 -m json.tool 2>/dev/null || echo "${DERP_MAP_JSON}" if [[ "$COMPOSE" == true ]]; then echo "" echo "--- Docker Compose (${OUTPUT_DIR}/docker-compose-${REGION_CODE}.yml) ---" build_compose fi echo "" log_info "Dry-run complete. No changes made." exit 0 fi # --- Deploy --- log_info "Creating output directory: ${OUTPUT_DIR}" mkdir -p "$OUTPUT_DIR" # Write DERP map MAP_FILE="${OUTPUT_DIR}/derp-region-${REGION_ID}.json" echo "${DERP_MAP_JSON}" > "$MAP_FILE" log_info "DERP map written to: ${MAP_FILE}" if [[ "$COMPOSE" == true ]]; then COMPOSE_FILE="${OUTPUT_DIR}/docker-compose-${REGION_CODE}.yml" build_compose > "$COMPOSE_FILE" log_info "Docker Compose file written to: ${COMPOSE_FILE}" fi # Check if Docker is available if ! command -v docker &>/dev/null; then log_error "Docker is not installed. Install Docker and run again." exit 1 fi # Pull the image log_info "Pulling DERP image: ${DERPER_IMAGE}" docker pull "$DERPER_IMAGE" >&2 # Run container CONTAINER_NAME="derper-${REGION_CODE}" # Remove existing container if present if docker inspect "$CONTAINER_NAME" &>/dev/null; then log_warn "Container '${CONTAINER_NAME}' exists. Removing..." docker rm -f "$CONTAINER_NAME" >&2 fi log_info "Starting DERP container: ${CONTAINER_NAME}" DOCKER_ARGS=( --name "$CONTAINER_NAME" --restart always -p "${STUN_PORT}:${STUN_PORT}/udp" -p "${RELAY_PORT}:${RELAY_PORT}" ) if [[ "$ACME" == true ]]; then DOCKER_ARGS+=( -v "/etc/letsencrypt:/certs" ) else # Mount the containing directories for cert files DOCKER_ARGS+=( -v "$(dirname "$CERT_PATH"):$(dirname "$CERT_PATH"):ro" -v "$(dirname "$KEY_PATH"):$(dirname "$KEY_PATH"):ro" ) fi DOCKER_ARGS+=( -v "/var/lib/derper:/var/lib/derper" "$DERPER_IMAGE" --hostname="$HOSTNAME" --addr=":${RELAY_PORT}" --stun-port="${STUN_PORT}" ) if [[ "$ACME" == false ]]; then DOCKER_ARGS+=(--cert="$CERT_PATH" --key="$KEY_PATH") fi docker run -d "${DOCKER_ARGS[@]}" >&2 log_info "DERP server deployed successfully!" log_info "Container: ${CONTAINER_NAME}" log_info "Hostname: ${HOSTNAME}" log_info "STUN: ${HOSTNAME}:${STUN_PORT} (UDP)" log_info "Relay: ${HOSTNAME}:${RELAY_PORT} (TLS)" log_info "" log_info "Add this DERP map URL to your Headscale config under 'derp.urls':" log_info " file://$(realpath "$MAP_FILE" 2>/dev/null || echo "$MAP_FILE")" log_info "" log_info "Or copy the JSON contents into the Headscale 'derp.paths' JSON file." -
derp-health-check.py 12 KB
#!/usr/bin/env python3 """ headscale-derp: derp-health-check.py — Check DERP relay health. Tests: - TCP connectivity to relay:3478 (STUN) - TLS handshake on relay:443 (DERP relay) - WebSocket connectivity test Usage: derp-health-check.py --host <derp.example.com> [--stun-port <3478>] [--relay-port <443>] [--json] [--timeout <5>] [--help] Examples: derp-health-check.py --host derp.example.com derp-health-check.py --host derp.example.com --stun-port 3478 --relay-port 443 derp-health-check.py --host derp.example.com --json derp-health-check.py --host derp.example.com --json --timeout 10 """ import argparse import json import socket import ssl import sys import time import urllib.request import urllib.error try: import websocket # optional: pip install websocket-client except ImportError: websocket = None def check_tcp_connectivity(host: str, port: int, timeout: float) -> dict: """Check basic TCP connectivity to a host:port.""" result = { "check": "tcp_connectivity", "host": host, "port": port, "protocol": "tcp", } start = time.time() try: sock = socket.create_connection((host, port), timeout=timeout) elapsed = round((time.time() - start) * 1000, 1) sock.close() result["status"] = "ok" result["latency_ms"] = elapsed result["message"] = f"TCP connection to {host}:{port} succeeded ({elapsed}ms)" except (socket.timeout, ConnectionRefusedError, OSError) as e: elapsed = round((time.time() - start) * 1000, 1) result["status"] = "fail" result["latency_ms"] = elapsed result["message"] = f"TCP connection to {host}:{port} failed: {e}" return result def check_tls_handshake(host: str, port: int, timeout: float) -> dict: """Perform a TLS handshake and return cert info.""" result = { "check": "tls_handshake", "host": host, "port": port, "protocol": "tls", } start = time.time() try: context = ssl.create_default_context() with socket.create_connection((host, port), timeout=timeout) as sock: with context.wrap_socket(sock, server_hostname=host) as tls_sock: elapsed = round((time.time() - start) * 1000, 1) cert = tls_sock.getpeercert() subject = dict(x[0] for x in cert.get("subject", [])) issuer = dict(x[0] for x in cert.get("issuer", [])) san = cert.get("subjectAltName", []) result["status"] = "ok" result["latency_ms"] = elapsed result["tls_version"] = tls_sock.version() result["cipher"] = tls_sock.cipher()[0] if tls_sock.cipher() else None result["subject"] = subject.get("commonName", "?") result["issuer"] = issuer.get("organizationName", "?") result["san"] = [entry[1] for entry in san] if san else [] result["message"] = f"TLS handshake with {host}:{port} succeeded ({elapsed}ms)" except (socket.timeout, ConnectionRefusedError, ssl.SSLError, OSError) as e: elapsed = round((time.time() - start) * 1000, 1) result["status"] = "fail" result["latency_ms"] = elapsed result["message"] = f"TLS handshake with {host}:{port} failed: {e}" return result def check_stun(host: str, port: int, timeout: float) -> dict: """ STUN connectivity check via TCP (DERP STUN). Sends a simple STUN binding request and checks for a response. """ result = { "check": "stun_connectivity", "host": host, "port": port, "protocol": "stun", } start = time.time() try: # STUN binding request (RFC 5389) — minimal message # Magic cookie: 0x2112A442 # Transaction ID: 16 random bytes import random stun_msg = bytearray(20) # Type: Binding Request (0x0001) stun_msg[0] = 0x00 stun_msg[1] = 0x01 # Length: 0 (empty message) stun_msg[2] = 0x00 stun_msg[3] = 0x00 # Magic cookie stun_msg[4] = 0x21 stun_msg[5] = 0x12 stun_msg[6] = 0xA4 stun_msg[7] = 0x42 # Transaction ID (12 random bytes) for i in range(12): stun_msg[8 + i] = random.randint(0, 255) sock = socket.create_connection((host, port), timeout=timeout) sock.sendall(bytes(stun_msg)) response = sock.recv(1024) elapsed = round((time.time() - start) * 1000, 1) sock.close() if len(response) >= 20: # Check response type (Binding Success = 0x0101) resp_type = (response[0] << 8) | response[1] result["status"] = "ok" result["latency_ms"] = elapsed result["response_type"] = resp_type result["message"] = ( f"STUN request to {host}:{port} got response " f"(type=0x{resp_type:04x}, {elapsed}ms)" ) else: result["status"] = "degraded" result["latency_ms"] = elapsed result["message"] = ( f"STUN response too short ({len(response)} bytes) " f"from {host}:{port}" ) except (socket.timeout, ConnectionRefusedError, OSError) as e: elapsed = round((time.time() - start) * 1000, 1) result["status"] = "fail" result["latency_ms"] = elapsed result["message"] = f"STUN request to {host}:{port} failed: {e}" return result def check_websocket(host: str, port: int, timeout: float) -> dict: """ WebSocket connectivity test to the DERP relay endpoint. DERP uses a custom WebSocket-based protocol over TLS. This test attempts to establish a WebSocket connection to the DERP endpoint at /derp (the standard DERP path). """ result = { "check": "websocket_connectivity", "host": host, "port": port, "protocol": "wss", } if websocket is None: result["status"] = "skipped" result["message"] = ( "websocket-client not installed. Install with: pip install websocket-client" ) return result start = time.time() ws_url = f"wss://{host}:{port}/derp" try: ws = websocket.create_connection( ws_url, timeout=timeout, sslopt={"check_hostname": True, "cert_reqs": ssl.CERT_REQUIRED}, ) elapsed = round((time.time() - start) * 1000, 1) ws.close() result["status"] = "ok" result["latency_ms"] = elapsed result["url"] = ws_url result["message"] = f"WebSocket connection to {ws_url} succeeded ({elapsed}ms)" except Exception as e: elapsed = round((time.time() - start) * 1000, 1) result["status"] = "fail" result["latency_ms"] = elapsed result["url"] = ws_url result["message"] = f"WebSocket connection to {ws_url} failed: {e}" return result def main(): parser = argparse.ArgumentParser( description="Check DERP relay health — STUN, TLS, and WebSocket connectivity.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) parser.add_argument( "--host", required=True, help="DERP relay hostname (e.g., derp.example.com)", ) parser.add_argument( "--stun-port", type=int, default=3478, help="STUN UDP port (default: 3478)", ) parser.add_argument( "--relay-port", type=int, default=443, help="DERP relay TLS port (default: 443)", ) parser.add_argument( "--json", action="store_true", help="Output structured JSON", ) parser.add_argument( "--timeout", type=float, default=5, help="Timeout in seconds for each check (default: 5)", ) parser.add_argument( "--skip-stun", action="store_true", help="Skip STUN connectivity check", ) args = parser.parse_args() # Run all checks check_results = {} # 1. TCP to STUN port tcp_stun = check_tcp_connectivity(args.host, args.stun_port, args.timeout) check_results["tcp_stun"] = tcp_stun # 2. STUN protocol check (TCP-based) if not args.skip_stun: stun = check_stun(args.host, args.stun_port, args.timeout) check_results["stun"] = stun else: check_results["stun"] = { "check": "stun_connectivity", "status": "skipped", "message": "Skipped via --skip-stun", } # 3. TCP to relay port tcp_relay = check_tcp_connectivity(args.host, args.relay_port, args.timeout) check_results["tcp_relay"] = tcp_relay # 4. TLS handshake on relay port tls = check_tls_handshake(args.host, args.relay_port, args.timeout) check_results["tls"] = tls # 5. WebSocket connectivity ws = check_websocket(args.host, args.relay_port, args.timeout) check_results["websocket"] = ws # Determine overall status critical_checks = ["tcp_stun", "tls", "tcp_relay"] failures = [ k for k in critical_checks if check_results.get(k, {}).get("status") == "fail" ] warnings = [ k for k in check_results if check_results.get(k, {}).get("status") == "degraded" ] skipped = [ k for k in check_results if check_results.get(k, {}).get("status") == "skipped" ] if not failures: overall_status = "healthy" elif len(failures) < len(critical_checks): overall_status = "degraded" else: overall_status = "unhealthy" summary_lines = [] summary_lines.append(f"Host: {args.host}") summary_lines.append(f"Overall: {overall_status}") if failures: summary_lines.append(f"Failed: {', '.join(failures)}") if warnings: summary_lines.append(f"Degraded: {', '.join(warnings)}") if skipped: summary_lines.append(f"Skipped: {', '.join(skipped)}") if args.json: output = { "status": overall_status, "host": args.host, "summary": { "total": len(check_results), "ok": sum( 1 for c in check_results.values() if c.get("status") == "ok" ), "degraded": len(warnings), "fail": len(failures), "skipped": len(skipped), }, "checks": check_results, } print(json.dumps(output, indent=2)) else: print("=" * 60) print(f" DERP Health Check — {args.host}") print("=" * 60) for check_name, result in check_results.items(): status = result.get("status", "unknown") if status == "ok": status_str = f" {GREEN}OK{NC}" if _use_color() else " OK" elif status == "degraded": status_str = f" {YELLOW}DEGRADED{NC}" if _use_color() else " DEGRADED" elif status == "skipped": status_str = f" {CYAN}SKIPPED{NC}" if _use_color() else " SKIPPED" else: status_str = f" {RED}FAIL{NC}" if _use_color() else " FAIL" print(f" {check_name:20s} {status_str}") print(f" {result.get('message', '')}") if result.get("latency_ms") is not None: print(f" Latency: {result['latency_ms']}ms") if result.get("tls_version"): print(f" TLS: {result['tls_version']} / Cipher: {result.get('cipher', '?')}") if result.get("subject"): print(f" Cert CN: {result['subject']} / Issuer: {result.get('issuer', '?')}") print() print("-" * 60) print(f" Overall: {overall_status}") print("=" * 60) def _use_color(): return sys.stderr.isatty() # ANSI color helpers (for text output) GREEN = "\033[0;32m" YELLOW = "\033[1;33m" RED = "\033[0;31m" CYAN = "\033[0;36m" NC = "\033[0m" if __name__ == "__main__": main() -
test-derp-latency.sh 8 KB
#!/usr/bin/env bash # =========================================================================== # headscale-derp: test-derp-latency.sh — Measure latency to DERP regions # =========================================================================== # Runs `tailscale netcheck` and parses results to report DERP region latency. # # Usage: # test-derp-latency.sh [--region-id <ID>] [--json] [--help] # # Options: # --region-id Test only a specific DERP region by ID (e.g., 900). # --json Output structured JSON with per-region latency data. # --help Show this help message and exit. # # Examples: # test-derp-latency.sh # test-derp-latency.sh --region-id 900 # test-derp-latency.sh --region-id 900 --json # test-derp-latency.sh --json # =========================================================================== set -euo pipefail # --- Constants --- SCRIPT_NAME="$(basename "$0")" # --- Colors --- if [[ -t 2 ]]; then RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' else RED=''; GREEN=''; YELLOW=''; CYAN=''; NC='' fi log_info() { echo -e "${GREEN}[INFO]${NC} $*" >&2; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } # --- Help --- usage() { cat <<EOF Usage: ${SCRIPT_NAME} [OPTIONS] Run tailscale netcheck and report DERP region latency. Options: --region-id <ID> Test only a specific DERP region by numeric ID. --json Output structured JSON with per-region latency data. --help Show this help message and exit. Examples: ${SCRIPT_NAME} ${SCRIPT_NAME} --region-id 900 ${SCRIPT_NAME} --region-id 900 --json ${SCRIPT_NAME} --json EOF exit 0 } # --- Parse arguments --- REGION_ID="" JSON_OUTPUT=false while [[ $# -gt 0 ]]; do case "$1" in --region-id) REGION_ID="$2"; shift 2 ;; --json) JSON_OUTPUT=true; shift ;; --help|-h) usage ;; *) log_error "Unknown argument: $1"; usage ;; esac done # --- Check prerequisites --- if ! command -v tailscale &>/dev/null; then log_error "tailscale command not found. Is Tailscale installed?" exit 1 fi # --- Run netcheck --- log_info "Running tailscale netcheck..." NETCHECK_OUTPUT=$(tailscale netcheck 2>&1) || { log_error "tailscale netcheck failed (exit code $?)" log_error "Output: ${NETCHECK_OUTPUT}" exit 1 } # --- Parse netcheck output --- # Expected format (example): # Report: # * UDP: true # * IPv4: yes # * ... # * DERP latency: # - dallas: 18ms (dallas) # - us-west: 35ms (us-west) # - new-york-city: 5ms (new-york-city) parse_regions() { local in_derp_section=false local regions_json="[" local first=true while IFS= read -r line; do # Detect start of DERP latency section if [[ "$line" =~ DERP\ latency: ]]; then in_derp_section=true continue fi if [[ "$in_derp_section" == "true" ]]; then # Break on next top-level line that doesn't start with whitespace/dash if [[ ! "$line" =~ ^[[:space:]]*-[[:space:]] ]]; then break fi # Parse: "- region_code: XXms (region_code)" if [[ "$line" =~ ^[[:space:]]*-\ ([^:]+):[[:space:]]*([0-9]+)ms ]]; then local region_name="${BASH_REMATCH[1]}" local latency_ms="${BASH_REMATCH[2]}" # Map display name to region code local region_code="$region_name" # Clean up common display names region_code=$(echo "$region_code" | tr '[:upper:]' '[:lower:]' | sed 's/ /-/g') if [[ -z "$REGION_ID" ]]; then if [[ "$first" == true ]]; then first=false; else regions_json+=","; fi regions_json+=$(cat <<JSON { "region_code": "${region_code}", "region_name": "${region_name}", "latency_ms": ${latency_ms} } JSON ) fi fi fi done <<< "$NETCHECK_OUTPUT" regions_json+="]" echo "$regions_json" } # --- Parse netcheck JSON if available (preferred) --- parse_netcheck_json_if_available() { # tailscale netcheck supports --json in newer versions local json json=$(tailscale netcheck --json 2>/dev/null) || return 1 echo "$json" return 0 } # --- Extract UDP/IPv4 info --- extract_field() { local field="$1" local value value=$(echo "$NETCHECK_OUTPUT" | grep -i "\* ${field}:" | head -1 | sed "s/.*:\s*//") echo "$value" } # --- Main --- UDP_STATUS=$(extract_field "UDP") IPV4_STATUS=$(extract_field "IPv4") IPV6_STATUS=$(extract_field "IPv6") NEAREST_DERP=$(echo "$NETCHECK_OUTPUT" | grep -i "Nearest DERP" | head -1 | sed "s/.*Nearest DERP: //") # Try to get structured JSON via --json flag PARSED_JSON="" if PARSED_JSON=$(parse_netcheck_json_if_available); then # If we have structured data from netcheck --json, use it if [[ -n "$REGION_ID" ]]; then log_info "Filtering for region ID: ${REGION_ID}" # Filter regions by ID if netcheck --json was used (returns node IDs) PARSED_JSON=$(echo "$PARSED_JSON" | python3 -c " import json, sys data = json.load(sys.stdin) regions = {k: v for k, v in data.get('Region', data.get('Regions', {})).items()} filter_id = '${REGION_ID}' target = {k: v for k, v in regions.items() if str(k) == str(filter_id)} if not target: print(json.dumps({'filtered': True, 'region_id': int(filter_id), 'regions': {}, 'found': False})) else: print(json.dumps({'filtered': True, 'region_id': int(filter_id), 'regions': target, 'found': True})) " 2>/dev/null) fi if [[ "$JSON_OUTPUT" == true ]]; then echo "$PARSED_JSON" else echo "=== DERP Latency Report ===" echo "UDP: ${UDP_STATUS:-unknown}" echo "IPv4: ${IPV4_STATUS:-unknown}" echo "IPv6: ${IPV6_STATUS:-unknown}" echo "Nearest: ${NEAREST_DERP:-unknown}" echo "" echo "--- DERP Regions (by latency) ---" python3 -c " import json, sys data = json.load(sys.stdin) regions = data.get('Region', data.get('Regions', {})) sorted_regions = sorted(regions.items(), key=lambda x: x[1].get('Latency', 99999)) for rid, rdata in sorted_regions: code = rdata.get('RegionCode', '?') name = rdata.get('RegionName', '?') lat = rdata.get('Latency', 'N/A') if lat != 'N/A': print(f\" {rid:>4} {code:<15} {name:<20} {lat:.0f}ms\") else: print(f\" {rid:>4} {code:<15} {name:<20} unreachable\") " 2>/dev/null <<< "$PARSED_JSON" fi else # Fallback: manually parse text output if [[ "$JSON_OUTPUT" == true ]]; then # Build JSON from text parsing REGIONS_JSON=$(parse_regions) cat <<JSONEOF { "status": "ok", "script": "${SCRIPT_NAME}", "region_filter": $( [[ -n "$REGION_ID" ]] && echo "$REGION_ID" || echo null ), "connectivity": { "udp": $( [[ "${UDP_STATUS,,}" == "true" ]] && echo true || echo false ), "ipv4": $( [[ "${IPV4_STATUS,,}" == "true" || "${IPV4_STATUS,,}" == "yes" ]] && echo true || echo false ), "ipv6": $( [[ "${IPV6_STATUS,,}" == "true" || "${IPV6_STATUS,,}" == "yes" ]] && echo true || echo false ) }, "nearest_derp": "${NEAREST_DERP:-unknown}", "regions": ${REGIONS_JSON} } JSONEOF else echo "=== DERP Latency Report ===" echo "UDP: ${UDP_STATUS:-unknown}" echo "IPv4: ${IPV4_STATUS:-unknown}" echo "IPv6: ${IPV6_STATUS:-unknown}" echo "Nearest: ${NEAREST_DERP:-unknown}" echo "" echo "--- DERP Regions (ranked by latency) ---" # Parse and sort regions echo "$NETCHECK_OUTPUT" | while IFS= read -r line; do if [[ "$line" =~ ^[[:space:]]*-\ ([^:]+):[[:space:]]*([0-9]+)ms ]]; then local name="${BASH_REMATCH[1]}" local lat="${BASH_REMATCH[2]}" printf " %4sms %s\n" "$lat" "$name" fi done | sort -n fi fi
-
-
README.md 494 B
# Headscale DERP ## Why Install This Skill Explains how to deploy and diagnose DERP relays when direct peer connections are unavailable. ## What You Get | Content | Purpose | |---|---| | `SKILL.md` and `scripts/` | Relay deployment and latency diagnostics | ## Quick Start Use the diagnostic guidance in `SKILL.md` before adding a custom relay. ## Triggers Use when peers rely on DERP or direct connectivity fails. ## Requirements Headscale administration and a reachable relay host. -
SKILL.md 5.6 KB
--- name: headscale-derp description: Configure and manage DERP relay servers for Tailscale/Headscale — embedded and standalone DERP deployment, latency testing, and connectivity diagnostics. Use when direct peer connections fail, traffic is routed through DERP relays, or setting up custom relay regions. metadata: category: devops --- # headscale-derp Skill ## Overview **DERP** = **D**esignated **E**ncrypted **R**elay **P**rotocol. DERP is Tailscale's TURN-like fallback mechanism used when direct peer-to-peer NAT traversal fails. Traffic through DERP is fully encrypted (WireGuard inside TLS), but it routes through a relay server rather than directly between peers, so it introduces additional latency. Common scenarios where DERP is used: - **Symmetric NAT** — Both peers behind symmetric NAT gateways - **Corporate firewalls** — Restrictive egress policies that block UDP/STUN - **Double NAT** — Carrier-grade NAT on both sides - **Blocked STUN** — UDP port 3478 is filtered ## DERP in Headscale Headscale includes an **embedded DERP server** that is enabled by default in the `config.yaml`: ```yaml derp: server: enabled: true region_id: 999 region_code: "headscale" region_name: "Headscale Embedded DERP" stun_listen_addr: "0.0.0.0:3478" private_key_path: "/var/lib/headscale/derp_server_private.key" ``` The embedded server runs a STUN endpoint on port 3478 and relays on port 443 (or whatever port Headscale listens on). It's suitable for small tailnets (under ~50 nodes). ### Key config options | Option | Default | Description | |--------|---------|-------------| | `derp.server.enabled` | `true` | Enable the embedded DERP server | | `derp.server.region_id` | `999` | Numeric region identifier (must be unique across all regions) | | `derp.urls` | `[]` | Additional DERP map URLs (for standalone servers) | | `derp.paths` | `[]` | Local DERP map JSON file paths | | `derp.auto_update` | `true` | Automatically fetch Tailscale's default DERP map | | `derp.stun_listen_addr` | `0.0.0.0:3478` | STUN listener address | ## DERP Map A DERP map is a JSON structure that defines relay regions and nodes. Example: ```json { "Regions": { "900": { "RegionID": 900, "RegionCode": "us-nyc", "RegionName": "New York", "Nodes": [ { "Name": "900a", "RegionID": 900, "HostName": "derp-nyc.example.com", "DERPPort": 443, "STUNPort": 3478, "STUNOnly": false } ] } } } ``` The DERP map can be served via HTTPS URL (put in `derp.urls`) or as a local JSON file (put in `derp.paths`). ## Connectivity Testing Run `tailscale netcheck` to see which DERP regions are reachable and their latency: ``` $ tailscale netcheck Report: * UDP: true * IPv4: yes * IPv6: no * MappingVariesByDestIP: true * HairPinning: false * PortMapping: UPnP * Nearest DERP: Dallas * DERP latency: - dallas: 18ms (dallas) - us-west: 35ms (us-west) - new-york-city: 5ms (new-york-city) - london: 85ms (london) ``` ## Standalone DERP For larger tailnets or dedicated relay capacity, run a standalone DERP server using the official `tailscale/derper` Docker image: ```bash docker run -d \ --name=derper \ --restart=always \ -p 3478:3478/udp \ -p 443:443 \ -v /etc/letsencrypt:/certs \ -v /var/lib/derper:/var/lib/derper \ tailscale/derper \ --hostname=derp.example.com ``` ## TLS Certificates DERP requires TLS. Recommended approaches: 1. **Let's Encrypt (auto)** — `tailscale/derper` supports automatic certificate issuance via Let's Encrypt. It listens on port 80 for the ACME HTTP-01 challenge. 2. **Manual certs** — Pass `--cert=/path/to/cert.pem --key=/path/to/key.pem` to the `derper` binary. 3. **Reverse proxy** — Terminate TLS at a reverse proxy (nginx, Caddy, Traefik) and forward to the local DERP port. ## Region Selection Tailscale clients automatically select the DERP region with the lowest latency. The selection algorithm: 1. Sends STUN requests to all configured DERP regions 2. Measures round-trip time for each 3. Picks the region with the lowest latency 4. Falls back to the next-closest region if connectivity fails ## Gotchas - **DERP is encrypted but slower** — All DERP traffic is WireGuard-inside-TLS, adding ~5-15ms overhead. Direct connections are always preferred. - **Embedded DERP works for small tailnets** — The embedded server in Headscale uses the same Headscale process for relaying. Under heavy relay traffic, it can impact Headscale control-plane performance. For >50 nodes doing active relay, deploy a standalone DERP. - **All traffic routes through DERP if STUN is blocked** — If UDP port 3478 is blocked anywhere in the network path, Tailscale cannot establish direct peer-to-peer connections and will route ALL traffic through the nearest DERP relay. - **Port 3478 (STUN) and 443 (DERP) must be open** — STUN uses UDP for NAT traversal probing; DERP uses TCP/TLS for relay traffic. Both must be reachable from clients. - **Multiple regions improve resilience** — Deploy DERP relays in at least two geographically diverse locations so clients have a fallback. - **DERP map caching** — Clients cache the DERP map. If you add a new region, it can take up to 5 minutes for clients to pick it up. Use `tailscale netcheck` to force a refresh. ## Trigger Conditions This skill is activated by keywords: `DERP`, `relay`, `peer relay`, `STUN`, `direct connection failed` ## When not to use Do not use this skill when direct peer connections work — DERP tuning is only needed when NAT traversal fails. For general client connectivity diagnostics, load `tailscale-client` instead.
-
-
headscale-node-lifecycle
-
evals
-
evals.json 5.5 KB
{ "schema_version": 1, "skill_name": "headscale-node-lifecycle", "evals": [ { "id": "preauth-key-generation", "prompt": "Generate a pre-authenticated key so our CI automation can register three ephemeral runners, tagged as CI runners, that expire after 4 hours.", "expected_output": "Scenario: pre-authenticated key generation with constraints. The agent uses hs-create-authkey.sh (or the headscale CLI/API) to create an auth key with a 4-hour expiration, the tag:ci-runner tag, and ephemeral=true so runners are removed on disconnect. The output confirms the key attributes (expiry, tags, ephemeral, reusable/single-use) and warns about the default 1-hour expiry being overridden, and records the key for use by the runner provisioning.", "assertions": [ "An auth key is created with a 4-hour expiration (overriding the 1-hour default)", "The tag:ci-runner tag is embedded in the key", "ephemeral is set so runners are removed on disconnect", "The key's attributes (expiry, tags, reusable) are reported", "The key is made available for runner registration" ] }, { "id": "tagged-node-registration", "prompt": "Register three new infrastructure nodes as tagged nodes in the tailnet using pre-auth keys so they're auto-approved and belong to the tagged-devices user.", "expected_output": "Scenario: tagged-node registration. The agent creates an auth key with --tags, runs tailscale up --auth-key on each device, and confirms the nodes are auto-approved and appear under the tagged-devices user. The output distinguishes tagged nodes from personal nodes and verifies via headscale nodes list that the nodes carry the expected tags.", "assertions": [ "An auth key with tags is created for registration", "Each device is registered with tailscale up --auth-key", "Tagged nodes are auto-approved and belong to tagged-devices", "Registered nodes are verified in headscale nodes list with expected tags" ] }, { "id": "pending-node-approval", "prompt": "Three employees just joined and their laptops are showing up as pending in headscale. Approve them so they can use the tailnet.", "expected_output": "Scenario: approving pending node registrations. The agent lists pending nodes, confirms each is the expected employee device, and approves them (hs-approve-nodes.sh or headscale nodes approve). The output confirms each node moved to approved/online state and that only the intended nodes were approved, not every pending device.", "assertions": [ "Pending node registrations are listed and identified", "Only the intended employee devices are approved", "Approved nodes are verified as approved/online", "The approval action is scoped to the expected devices" ] }, { "id": "node-tagging-update", "prompt": "One of our servers, node-7, needs a monitoring tag added, but it already has a webserver tag. Add the monitoring tag without removing the existing webserver tag.", "expected_output": "Scenario: additive node tagging. The agent uses hs-tag-node.sh to add tag:monitoring to node-7 while preserving the existing tag:webserver (add mode rather than replace mode, and aware that tags always carry the tag: prefix). The output confirms both tags are present afterward and explains the difference between --add and --replace behavior.", "assertions": [ "The monitoring tag is added with the tag: prefix", "The existing webserver tag is preserved (add, not replace)", "The final tag set on node-7 is verified", "The add-vs-replace distinction is handled correctly" ] }, { "id": "node-decommission", "prompt": "We're decommissioning two retired servers. Remove them from the tailnet permanently and confirm they're gone.", "expected_output": "Scenario: node decommissioning. The agent identifies the two retired node IDs and deletes them via headscale nodes delete -i <id> (or the REST API DELETE /api/v1/node/<id>). Because decommissioning is irreversible, the agent confirms the target nodes before deleting, then verifies they no longer appear in headscale nodes list. The output distinguishes permanent deletion from ephemeral auto-removal.", "assertions": [ "The target retired nodes are identified by ID", "Confirmation is obtained before irreversible deletion", "The nodes are deleted via the headscale CLI or REST API", "The nodes are verified absent from headscale nodes list" ] }, { "id": "ephemeral-ci-runner", "prompt": "Set up provisioning for CI jobs that run in throwaway containers. Each container should register, do its job, and leave no trace in the tailnet afterward.", "expected_output": "Scenario: ephemeral node lifecycle for CI. The agent creates an ephemeral pre-auth key and configures the CI containers to tailscale up with that key. The output explains that ephemeral nodes are automatically removed from the tailnet on disconnect (no residual record), unlike permanent nodes, and that no manual decommission is needed. It verifies ephemeral behavior is enabled.", "assertions": [ "An ephemeral auth key is used for CI container registration", "The agent explains ephemeral nodes are auto-removed on disconnect", "No manual decommission step is required for ephemeral nodes", "The ephemeral behavior is confirmed enabled" ] } ] }
-
-
scripts
-
hs-approve-nodes.sh 4.9 KB
#!/usr/bin/env bash set -euo pipefail # hs-approve-nodes.sh — Approve pending node registrations in Headscale # # Usage: # hs-approve-nodes.sh [--all] [--auth-id <id>] [--dry-run] [--json] [--help] # # Options: # --all Approve all pending registrations # --auth-id <id> Approve a specific pending auth by its ID # --dry-run Show pending registrations without approving # --json Output raw JSON # --help Show this help message usage() { sed -n '/^# Usage:/,/^$/{ s/^#//p; }' "$0" echo "" echo "Examples:" echo " hs-approve-nodes.sh --dry-run # Show pending registrations" echo " hs-approve-nodes.sh --all # Approve all pending" echo " hs-approve-nodes.sh --auth-id 42 # Approve a specific node" echo " hs-approve-nodes.sh --all --json" exit "${1:-0}" } APPROVE_ALL=false AUTH_ID="" DRY_RUN=false JSON_MODE=false while [[ $# -gt 0 ]]; do case "$1" in --all) APPROVE_ALL=true; shift ;; --auth-id) shift; AUTH_ID="$1"; shift ;; --dry-run) DRY_RUN=true; shift ;; --json) JSON_MODE=true; shift ;; --help) usage 0 ;; *) echo "ERROR: Unknown option: $1" >&2; usage 1 ;; esac done if ! $APPROVE_ALL && [[ -z "$AUTH_ID" ]]; then echo "ERROR: Specify --all or --auth-id <id>" >&2 usage 1 fi # --------------------------------------------------------------------------- # Try headscale CLI first # --------------------------------------------------------------------------- if command -v headscale &>/dev/null; then HEADSCALE_CMD=(headscale) if [[ -n "${HEADSCALE_URL:-}" ]]; then HEADSCALE_CMD+=(--url "$HEADSCALE_URL") fi if $DRY_RUN; then if $JSON_MODE; then "${HEADSCALE_CMD[@]}" nodes list --output json 2>/dev/null | jq 'map(select(.approvalRequired == true or .approved == false))' else echo "=== Pending Node Registrations ===" "${HEADSCALE_CMD[@]}" nodes list --output json 2>/dev/null | \ jq -r 'map(select(.approvalRequired == true or .approved == false)) | .[] | "ID: \(.id) | Name: \(.givenName // .name // "?") | IP: \(.ipAddresses // [] | join(",")) | User: \(.user.name // .user // "?")"' fi exit 0 fi if [[ -n "$AUTH_ID" ]]; then # Register/approve a specific node by ID via the route node approve command "${HEADSCALE_CMD[@]}" nodes approve -i "$AUTH_ID" elif $APPROVE_ALL; then PENDING=$("${HEADSCALE_CMD[@]}" nodes list --output json 2>/dev/null | \ jq -r 'map(select(.approvalRequired == true or .approved == false)) | .[].id' 2>/dev/null || true) if [[ -z "$PENDING" ]]; then echo "No pending nodes to approve." exit 0 fi COUNT=0 while IFS= read -r NODE_ID; do if [[ -n "$NODE_ID" ]]; then echo "Approving node $NODE_ID..." "${HEADSCALE_CMD[@]}" nodes approve -i "$NODE_ID" COUNT=$((COUNT + 1)) fi done <<< "$PENDING" echo "Approved $COUNT node(s)." fi exit 0 fi # --------------------------------------------------------------------------- # Fallback: REST API via curl # --------------------------------------------------------------------------- if [[ -z "${HEADSCALE_URL:-}" || -z "${HEADSCALE_API_KEY:-}" ]]; then echo "ERROR: headscale CLI not found. Set HEADSCALE_URL and HEADSCALE_API_KEY env vars for API fallback." >&2 exit 1 fi API="${HEADSCALE_URL}/api/v1" if $DRY_RUN; then # List all nodes and filter for pending/unapproved RESPONSE=$(curl -s -X GET "${API}/node" \ -H "Authorization: Bearer $HEADSCALE_API_KEY" \ -H "Accept: application/json") PENDING=$(echo "$RESPONSE" | jq 'map(select(.approvalRequired == true or .approved == false))' 2>/dev/null) if $JSON_MODE; then echo "$PENDING" else COUNT=$(echo "$PENDING" | jq 'length') echo "=== Pending Node Registrations ($COUNT pending) ===" echo "$PENDING" | jq -r '.[] | "ID: \(.id) | Name: \(.givenName // .name // "?") | IP: \(.ipAddresses // [] | join(",")) | User: \(.user.name // .user // "?")"' fi exit 0 fi if [[ -n "$AUTH_ID" ]]; then RESPONSE=$(curl -s -X POST "${API}/node/${AUTH_ID}/approve" \ -H "Authorization: Bearer $HEADSCALE_API_KEY" \ -H "Content-Type: application/json" \ -d '{}') if $JSON_MODE; then echo "$RESPONSE" else STATUS=$(echo "$RESPONSE" | jq -r '.status // "ok"') echo "Node $AUTH_ID approval status: $STATUS" fi elif $APPROVE_ALL; then RESPONSE=$(curl -s -X GET "${API}/node" \ -H "Authorization: Bearer $HEADSCALE_API_KEY" \ -H "Accept: application/json") echo "$RESPONSE" | jq -c '.[] | select(.approvalRequired == true or .approved == false) | .id' 2>/dev/null | while read -r NODE_ID; do NODE_ID=$(echo "$NODE_ID" | tr -d '"') echo "Approving node $NODE_ID..." curl -s -X POST "${API}/node/${NODE_ID}/approve" \ -H "Authorization: Bearer $HEADSCALE_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' > /dev/null done echo "All pending nodes approved." fi -
hs-create-authkey.sh 4.6 KB
#!/usr/bin/env bash set -euo pipefail # hs-create-authkey.sh — Create pre-authenticated keys in Headscale # Supports both headscale CLI and REST API fallback. # # Usage: # hs-create-authkey.sh --user <user> [options] # hs-create-authkey.sh --tags <tag> [options] # # Options: # --user <user> Create auth key for a personal user # --tags <tag> Create auth key for tagged nodes (comma-separated) # --expiration <dur> Key lifetime (default: 1h, use 0 for no expiry) # --reusable Allow key to be used multiple times # --ephemeral Remove node from tailnet on disconnection # --json Output raw JSON response # --dry-run Print what would be done without executing # --help Show this help message and exit SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" usage() { sed -n '/^# Usage:/,/^$/{ s/^#//p; }' "$0" echo "" echo "Examples:" echo " hs-create-authkey.sh --user alice" echo " hs-create-authkey.sh --user alice --expiration 24h --reusable --json" echo " hs-create-authkey.sh --tags webserver,monitoring --ephemeral" echo " hs-create-authkey.sh --tags ci-runner --expiration 0 --reusable --dry-run" exit "${1:-0}" } # Defaults USER="" TAGS="" EXPIRATION="1h" REUSABLE=false EPHEMERAL=false JSON_MODE=false DRY_RUN=false while [[ $# -gt 0 ]]; do case "$1" in --user) shift; USER="$1"; shift ;; --tags) shift; TAGS="$1"; shift ;; --expiration) shift; EXPIRATION="$1"; shift ;; --reusable) REUSABLE=true; shift ;; --ephemeral) EPHEMERAL=true; shift ;; --json) JSON_MODE=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --help) usage 0 ;; *) echo "ERROR: Unknown option: $1" >&2; usage 1 ;; esac done # Validate: one of --user or --tags is required if [[ -z "$USER" && -z "$TAGS" ]]; then echo "ERROR: Specify --user or --tags (or both)" >&2 usage 1 fi # --json implies JSON_MODE # --dry-run implies JSON_MODE for structured output if $DRY_RUN; then cat <<EOF { "dry_run": true, "user": "${USER:-tagged}", "tags": ${TAGS:-null}, "expiration": "$EXPIRATION", "reusable": $REUSABLE, "ephemeral": $EPHEMERAL } EOF exit 0 fi # --------------------------------------------------------------------------- # Try headscale CLI first # --------------------------------------------------------------------------- if command -v headscale &>/dev/null; then HEADSCALE_CMD=(headscale) if [[ -n "${HEADSCALE_URL:-}" ]]; then HEADSCALE_CMD+=(--url "$HEADSCALE_URL") fi # Build CLI args PREAUTH_ARGS=(preauthkeys create) if [[ -n "$USER" ]]; then PREAUTH_ARGS+=(--user "$USER") fi if [[ -n "$TAGS" ]]; then # Convert comma-separated tags to individual --tag flags IFS=',' read -ra TAG_LIST <<< "$TAGS" for t in "${TAG_LIST[@]}"; do PREAUTH_ARGS+=(--tag "tag:$t") done fi if [[ "$EXPIRATION" != "1h" ]]; then PREAUTH_ARGS+=(--expiration "$EXPIRATION") fi if $REUSABLE; then PREAUTH_ARGS+=(--reusable) fi if $EPHEMERAL; then PREAUTH_ARGS+=(--ephemeral) fi if $JSON_MODE; then PREAUTH_ARGS+=(--output json) fi exec "${HEADSCALE_CMD[@]}" "${PREAUTH_ARGS[@]}" fi # --------------------------------------------------------------------------- # Fallback: REST API via curl # --------------------------------------------------------------------------- if [[ -z "${HEADSCALE_URL:-}" || -z "${HEADSCALE_API_KEY:-}" ]]; then echo "ERROR: headscale CLI not found. Set HEADSCALE_URL and HEADSCALE_API_KEY env vars for API fallback." >&2 exit 1 fi API="${HEADSCALE_URL}/api/v1" # Determine user for tagged vs personal API_USER="${USER:-tagged-devices}" # Build JSON payload PAYLOAD=$(jq -n \ --arg user "$API_USER" \ --arg exp "$EXPIRATION" \ --arg reusable "$REUSABLE" \ --arg ephemeral "$EPHEMERAL" \ --arg tags "$TAGS" \ '{ user: $user, expiration: $exp, reusable: ($reusable == "true"), ephemeral: ($ephemeral == "true") } | if $tags != "" then .tags = ($tags | split(",") | map("tag:" + .)) else . end') RESPONSE=$(curl -s -X POST "$API/preauthkey" \ -H "Authorization: Bearer $HEADSCALE_API_KEY" \ -H "Content-Type: application/json" \ -d "$PAYLOAD") if $JSON_MODE; then echo "$RESPONSE" else KEY=$(echo "$RESPONSE" | jq -r '.key // empty') if [[ -n "$KEY" ]]; then echo "Pre-authenticated key created: $KEY" echo " User: $API_USER" echo " Tags: ${TAGS:-none}" echo " Expiration: $EXPIRATION" echo " Reusable: $REUSABLE" echo " Ephemeral: $EPHEMERAL" else echo "ERROR: Failed to create auth key" >&2 echo "$RESPONSE" >&2 exit 1 fi fi -
hs-list-nodes.sh 4.4 KB
#!/usr/bin/env bash set -euo pipefail # hs-list-nodes.sh — List all nodes in the Headscale tailnet # # Usage: # hs-list-nodes.sh [--json] [--user <user>] [--tag <tag>] [--online-only] [--help] # # Options: # --json Output raw JSON from the API # --user <user> Filter by user (personal or tagged-devices) # --tag <tag> Filter by tag (e.g. webserver) # --online-only Show only online nodes # --help Show this help message usage() { sed -n '/^# Usage:/,/^$/{ s/^#//p; }' "$0" echo "" echo "Examples:" echo " hs-list-nodes.sh" echo " hs-list-nodes.sh --online-only" echo " hs-list-nodes.sh --user tagged-devices" echo " hs-list-nodes.sh --tag webserver --json" echo " hs-list-nodes.sh --online-only --json" exit "${1:-0}" } JSON_MODE=false USER_FILTER="" TAG_FILTER="" ONLINE_ONLY=false while [[ $# -gt 0 ]]; do case "$1" in --json) JSON_MODE=true; shift ;; --user) shift; USER_FILTER="$1"; shift ;; --tag) shift; TAG_FILTER="$1"; shift ;; --online-only) ONLINE_ONLY=true; shift ;; --help) usage 0 ;; *) echo "ERROR: Unknown option: $1" >&2; usage 1 ;; esac done # --------------------------------------------------------------------------- # Try headscale CLI first # --------------------------------------------------------------------------- if command -v headscale &>/dev/null; then HEADSCALE_CMD=(headscale) if [[ -n "${HEADSCALE_URL:-}" ]]; then HEADSCALE_CMD+=(--url "$HEADSCALE_URL") fi # List nodes with user info HEADSCALE_CMD+=(nodes list --output json) NODES=$("${HEADSCALE_CMD[@]}" 2>/dev/null || true) if [[ -z "$NODES" ]]; then echo "No nodes found." >&2 exit 0 fi # Apply filters using jq FILTER="." if [[ -n "$USER_FILTER" ]]; then FILTER="$FILTER | map(select(.user.name == \"$USER_FILTER\" or .user == \"$USER_FILTER\"))" fi if [[ -n "$TAG_FILTER" ]]; then FILTER="$FILTER | map(select(.tags // [] | index(\"tag:$TAG_FILTER\") != null))" fi if $ONLINE_ONLY; then FILTER="$FILTER | map(select(.online == true))" fi FILTERED=$(echo "$NODES" | jq "$FILTER" 2>/dev/null || echo "$NODES") if $JSON_MODE; then echo "$FILTERED" exit 0 fi # Pretty-print tabular output echo "$FILTERED" | jq -r ' (["ID", "NAME", "IP", "USER", "TAGS", "ONLINE", "LAST_SEEN", "OS", "VERSION"] | join(" | ")), (.[] | [ (.id // "?" | tostring), (.givenName // .name // "?"), (.ipAddresses // [] | join(",") // "?"), (.user.name // .user // "?"), ((.tags // []) | join(",") // "-"), (if .online then "✓" else "✗" end), (.lastSeen // "?"), (.hostInfo.os // "?"), (.clientVersion // "?") ] | join(" | ")) ' | column -s '|' -t exit 0 fi # --------------------------------------------------------------------------- # Fallback: REST API via curl # --------------------------------------------------------------------------- if [[ -z "${HEADSCALE_URL:-}" || -z "${HEADSCALE_API_KEY:-}" ]]; then echo "ERROR: headscale CLI not found. Set HEADSCALE_URL and HEADSCALE_API_KEY env vars for API fallback." >&2 exit 1 fi API="${HEADSCALE_URL}/api/v1" # Build query params QUERY_PARAMS=() if [[ -n "$USER_FILTER" ]]; then QUERY_PARAMS+=("user=$USER_FILTER") fi QUERY="" if [[ ${#QUERY_PARAMS[@]} -gt 0 ]]; then QUERY="?$(IFS='&'; echo "${QUERY_PARAMS[*]}")" fi RESPONSE=$(curl -s -X GET "${API}/node${QUERY}" \ -H "Authorization: Bearer $HEADSCALE_API_KEY" \ -H "Accept: application/json") # Apply client-side filters for tag and online-only (API may not support these natively) if [[ -n "$TAG_FILTER" ]]; then RESPONSE=$(echo "$RESPONSE" | jq "map(select(.tags // [] | index(\"tag:$TAG_FILTER\") != null))") fi if $ONLINE_ONLY; then RESPONSE=$(echo "$RESPONSE" | jq "map(select(.online == true))") fi if $JSON_MODE; then echo "$RESPONSE" exit 0 fi # Pretty-print table echo "$RESPONSE" | jq -r ' if type == "array" then . elif .nodes then .nodes else [] end | (["ID", "NAME", "IP", "USER", "TAGS", "ONLINE", "LAST_SEEN", "OS", "VERSION"] | join(" | ")), (.[] | [ (.id // "?" | tostring), (.givenName // .name // "?"), (.ipAddresses // [] | join(",") // "?"), (.user.name // .user // "?"), ((.tags // []) | join(",") // "-"), (if .online then "✓" else "✗" end), (.lastSeen // "?"), (.hostInfo.os // "?"), (.clientVersion // "?") ] | join(" | ")) ' 2>/dev/null | column -s '|' -t -
hs-tag-node.sh 7.3 KB
#!/usr/bin/env bash set -euo pipefail # hs-tag-node.sh — Update tags on a Headscale node # # Usage: # hs-tag-node.sh --node <id/name> --tags <tag1,tag2> [--replace | --add] [--dry-run] [--json] [--help] # # Options: # --node <id/name> Node ID or name to update # --tags <tags> Comma-separated list of tags (without tag: prefix) # --replace Replace all existing tags with the specified ones (default) # --add Add specified tags to existing tags # --dry-run Show what would be changed without modifying # --json Output raw JSON response # --help Show this help message usage() { sed -n '/^# Usage:/,/^$/{ s/^#//p; }' "$0" echo "" echo "Examples:" echo " hs-tag-node.sh --node myserver --tags webserver,production" echo " hs-tag-node.sh --node 42 --tags monitoring --add" echo " hs-tag-node.sh --node myserver --tags webserver --dry-run --json" exit "${1:-0}" } NODE="" TAGS="" REPLACE=true DRY_RUN=false JSON_MODE=false while [[ $# -gt 0 ]]; do case "$1" in --node) shift; NODE="$1"; shift ;; --tags) shift; TAGS="$1"; shift ;; --replace) REPLACE=true; shift ;; --add) REPLACE=false; shift ;; --dry-run) DRY_RUN=true; shift ;; --json) JSON_MODE=true; shift ;; --help) usage 0 ;; *) echo "ERROR: Unknown option: $1" >&2; usage 1 ;; esac done if [[ -z "$NODE" || -z "$TAGS" ]]; then echo "ERROR: --node and --tags are required" >&2 usage 1 fi # Normalize tags IFS=',' read -ra TAG_ARRAY <<< "$TAGS" NORMALIZED_TAGS=() for t in "${TAG_ARRAY[@]}"; do t=$(echo "$t" | xargs) # trim whitespace if [[ "$t" != tag:* ]]; then t="tag:$t" fi NORMALIZED_TAGS+=("$t") done # --------------------------------------------------------------------------- # Try headscale CLI first # --------------------------------------------------------------------------- if command -v headscale &>/dev/null; then HEADSCALE_CMD=(headscale) if [[ -n "${HEADSCALE_URL:-}" ]]; then HEADSCALE_CMD+=(--url "$HEADSCALE_URL") fi # Resolve node ID from name if needed NODE_ID="$NODE" if ! [[ "$NODE_ID" =~ ^[0-9]+$ ]]; then RESOLVED=$("${HEADSCALE_CMD[@]}" nodes list --output json 2>/dev/null | \ jq -r ".[] | select(.givenName == \"$NODE\" or .name == \"$NODE\") | .id" 2>/dev/null | head -1) if [[ -z "$RESOLVED" ]]; then echo "ERROR: Could not find node with name: $NODE" >&2 exit 1 fi NODE_ID="$RESOLVED" fi if $DRY_RUN; then # Show current and proposed tags CURRENT=$("${HEADSCALE_CMD[@]}" nodes list --output json 2>/dev/null | \ jq -r ".[] | select(.id == $NODE_ID)" 2>/dev/null) CURRENT_TAGS=$(echo "$CURRENT" | jq -r '.tags // [] | join(", ")') NODE_NAME=$(echo "$CURRENT" | jq -r '.givenName // .name // "?"') if $JSON_MODE; then cat <<EOF { "dry_run": true, "node_id": $NODE_ID, "node_name": "$NODE_NAME", "current_tags": [$(echo "$CURRENT_TAGS" | sed 's/, /","/g' | sed 's/^/"/' | sed 's/$/"/')], "new_tags": [$(printf '"%s",' "${NORMALIZED_TAGS[@]}" | sed 's/,$//')], "mode": "$( $REPLACE && echo 'replace' || echo 'add' )" } EOF else echo "Node: $NODE_NAME (ID: $NODE_ID)" echo "Current tags: $CURRENT_TAGS" echo "Proposed tags: ${NORMALIZED_TAGS[*]}" echo "Mode: $($REPLACE && echo 'replace' || echo 'add')" echo "(dry-run — no changes made)" fi exit 0 fi TAG_ARGS=() for t in "${NORMALIZED_TAGS[@]}"; do TAG_ARGS+=(--tag "$t") done if $REPLACE; then "${HEADSCALE_CMD[@]}" nodes tag -i "$NODE_ID" "${TAG_ARGS[@]}" else # For --add, we need to get existing tags first, then add to them CURRENT=$("${HEADSCALE_CMD[@]}" nodes list --output json 2>/dev/null | \ jq -r ".[] | select(.id == $NODE_ID) | .tags // [] | .[]" 2>/dev/null || true) ADDED=() # Build combined list while IFS= read -r tag; do if [[ -n "$tag" ]]; then ADDED+=("$tag") fi done <<< "$CURRENT" for t in "${NORMALIZED_TAGS[@]}"; do # Check if already present found=false for existing in "${ADDED[@]:-}"; do if [[ "$existing" == "$t" ]]; then found=true break fi done if ! $found; then ADDED+=("$t") fi done TAG_ARGS2=() for t in "${ADDED[@]}"; do TAG_ARGS2+=(--tag "$t") done "${HEADSCALE_CMD[@]}" nodes tag -i "$NODE_ID" "${TAG_ARGS2[@]}" fi if $JSON_MODE; then "${HEADSCALE_CMD[@]}" nodes list --output json 2>/dev/null | \ jq ".[] | select(.id == $NODE_ID)" fi exit 0 fi # --------------------------------------------------------------------------- # Fallback: REST API via curl # --------------------------------------------------------------------------- if [[ -z "${HEADSCALE_URL:-}" || -z "${HEADSCALE_API_KEY:-}" ]]; then echo "ERROR: headscale CLI not found. Set HEADSCALE_URL and HEADSCALE_API_KEY env vars for API fallback." >&2 exit 1 fi API="${HEADSCALE_URL}/api/v1" # Resolve node ID from name if needed NODE_ID="$NODE" if ! [[ "$NODE_ID" =~ ^[0-9]+$ ]]; then RESOLVED=$(curl -s -X GET "${API}/node" \ -H "Authorization: Bearer $HEADSCALE_API_KEY" \ -H "Accept: application/json" | \ jq -r ".[] | select(.givenName == \"$NODE\" or .name == \"$NODE\") | .id" 2>/dev/null | head -1) if [[ -z "$RESOLVED" ]]; then echo "ERROR: Could not find node with name: $NODE" >&2 exit 1 fi NODE_ID="$RESOLVED" fi if $DRY_RUN; then CURRENT_NODE=$(curl -s -X GET "${API}/node/${NODE_ID}" \ -H "Authorization: Bearer $HEADSCALE_API_KEY" \ -H "Accept: application/json") if $JSON_MODE; then echo "$CURRENT_NODE" | jq --argjson new_tags "$(printf '%s\n' "${NORMALIZED_TAGS[@]}" | jq -R . | jq -s .)" \ '. + {dry_run: true, proposed_tags: $new_tags}' else CURRENT_TAGS=$(echo "$CURRENT_NODE" | jq -r '.tags // [] | join(", ")') NODE_NAME=$(echo "$CURRENT_NODE" | jq -r '.givenName // .name // "?"') echo "Node: $NODE_NAME (ID: $NODE_ID)" echo "Current tags: $CURRENT_TAGS" echo "Proposed tags: ${NORMALIZED_TAGS[*]}" echo "Mode: $($REPLACE && echo 'replace' || echo 'add')" echo "(dry-run — no changes made)" fi exit 0 fi if $REPLACE; then PAYLOAD=$(jq -n --argjson tags "$(printf '%s\n' "${NORMALIZED_TAGS[@]}" | jq -R . | jq -s .)" \ '{tags: $tags}') else # Get existing tags and merge CURRENT_NODE=$(curl -s -X GET "${API}/node/${NODE_ID}" \ -H "Authorization: Bearer $HEADSCALE_API_KEY" \ -H "Accept: application/json") EXISTING_TAGS=$(echo "$CURRENT_NODE" | jq -r '.tags // [] | .[]' 2>/dev/null) COMBINED=() while IFS= read -r tag; do if [[ -n "$tag" ]]; then COMBINED+=("$tag") fi done <<< "$EXISTING_TAGS" for t in "${NORMALIZED_TAGS[@]}"; do found=false for existing in "${COMBINED[@]:-}"; do if [[ "$existing" == "$t" ]]; then found=true break fi done if ! $found; then COMBINED+=("$t") fi done PAYLOAD=$(jq -n --argjson tags "$(printf '%s\n' "${COMBINED[@]}" | jq -R . | jq -s .)" \ '{tags: $tags}') fi RESPONSE=$(curl -s -X PUT "${API}/node/${NODE_ID}/tags" \ -H "Authorization: Bearer $HEADSCALE_API_KEY" \ -H "Content-Type: application/json" \ -d "$PAYLOAD") if $JSON_MODE; then echo "$RESPONSE" else echo "Tags updated on node $NODE_ID: ${NORMALIZED_TAGS[*]}" fi
-
-
README.md 474 B
# Headscale Node Lifecycle ## Why Install This Skill Manages device registration, auth keys, tags, inventory, and decommissioning in a Headscale tailnet. ## What You Get | Content | Purpose | |---|---| | `SKILL.md` and `scripts/` | Node lifecycle procedures | ## Quick Start Follow `SKILL.md` to choose personal-node or tagged-node registration. ## Triggers Use when adding, approving, tagging, or retiring devices. ## Requirements Headscale administrator access. -
SKILL.md 5.1 KB
--- name: headscale-node-lifecycle description: Manage the full lifecycle of nodes in a Headscale tailnet — generate pre-authenticated keys, register, approve, tag, list, and decommission nodes. Use when adding new devices, generating auth keys for automation, or managing node inventory. metadata: category: devops --- # headscale-node-lifecycle ## Overview Headscale manages nodes in a tailnet with two identity models: - **Personal nodes** — registered under a specific user account (e.g. `yourname@domain`). The node key is tied to that user's identity. - **Tagged nodes** — belong to the special `tagged-devices` user (created automatically by Headscale). These nodes are identified by one or more tags (e.g. `tag:webserver`, `tag:monitoring`) and are typically used for infrastructure service nodes. Registration happens through: 1. **Web auth** — user visits a URL to authenticate with an OIDC/OAuth provider 2. **Pre-authenticated key (auth key)** — a one-time or reusable key generated by the admin that embeds tags, user, and expiry 3. **CLI / API** — direct node registration via `headscale` commands or REST API calls ## Pre-authenticated Keys Auth keys streamline automated node registration. Key attributes: | Parameter | Description | |-------------|------------------------------------------------------------------| | Expiration | Default: 1 hour. Use `0` for no expiry (not recommended). | | Reusable | Single-use by default. Mark reusable for fleet provisioning. | | Ephemeral | Ephemeral nodes are removed from the tailnet when they disconnect. Perfect for CI runners and ephemeral workloads. | | Tags | Assign tags to create a tagged node automatically. | ## Node Registration - **Personal nodes**: Create an auth key for a user → run `tailscale up --auth-key=<key>` on the device → approve in Headscale if registration is open. - **Tagged nodes**: Create an auth key with `--tags` → run `tailscale up --auth-key=<key>` → the node is auto-approved and tagged. ## Node Listing List nodes filtered by user, tag, or online status. Output includes: - Node ID - Name (hostname) - Tailscale IP address(es) - Assigned tags - User/owner - Online/offline status - Last seen timestamp - Operating system - Tailscale client version ## Node Tagging Tags can be added or replaced on existing nodes. Tags always carry the `tag:` prefix in Headscale. When adding tags, existing tags are preserved unless `--replace` is specified. ## Node Deletion To decommission a node permanently: ``` headscale nodes delete -i <node-id> ``` Or via the REST API: `DELETE /api/v1/node/<node-id>` Decommissioning is irreversible. For ephemeral nodes, disconnection alone is sufficient — the server removes them automatically. ## Environment | Variable | Description | |---------------------|-------------------------------| | `HEADSCALE_URL` | Base URL of the Headscale server (e.g. `https://headscale.example.com`) | | `HEADSCALE_API_KEY` | API key from `headscale apikeys create` | Both env vars are required for API-based operations when the `headscale` CLI is not available on `PATH`. ## Gotchas - **Auth key expiration**: Default is 1 hour. If you're provisioning a device and it takes longer, the key expires and registration fails. Set a longer expiry explicitly. - **Tagged node user**: Tagged nodes always belong to the `tagged-devices` user. Do not try to assign them to a personal user. - **Connectivity testing**: Nodes must be online (`Connected: true`) to test connectivity. Offline nodes do not respond to ping/ICMP within the tailnet. - **CLI vs API**: When both are available, the CLI is preferred for interactive use. The REST API is preferred for automation scripts. - **Key reuse**: Reusable keys are convenient but less secure. Use with care, especially in production environments. - **Ephemeral nodes**: Setting `--ephemeral` means the node is fully removed on disconnection — there is no record of it in the tailnet afterward. ## Trigger Conditions - "auth key" - "preauthkey" - "register node" - "approve node" - "tag node" - "node list" - "decommission node" ## Scripts ### hs-create-authkey.sh Create pre-authenticated keys via Headscale CLI or REST API. ``` hs-create-authkey.sh --user <user> --tags <tag> --expiration <duration> --reusable --ephemeral [--json] [--dry-run] ``` ### hs-list-nodes.sh List all nodes in the tailnet with status and metadata. ``` hs-list-nodes.sh [--json] [--user <user>] [--tag <tag>] [--online-only] ``` ### hs-approve-nodes.sh Approve pending node registrations. ``` hs-approve-nodes.sh [--all] [--auth-id <id>] [--dry-run] [--json] ``` ### hs-tag-node.sh Update tags on an existing node. ``` hs-tag-node.sh --node <id/name> --tags <tag1,tag2> [--replace | --add] [--dry-run] [--json] ``` ## When not to use Do not use this skill for installing or configuring the Tailscale client (load `tailscale-client` instead) or for ACL/policy authoring (load `tailnet-policy`). It covers node registration, tagging, listing, and decommissioning only.
-
-
headscale-routing
-
evals
-
evals.json 5.5 KB
{ "schema_version": 1, "skill_name": "headscale-routing", "evals": [ { "id": "subnet-router-setup", "prompt": "Expose our 192.168.10.0/24 lab network to the tailnet through the gateway node relay-1 so other tailnet devices can reach lab printers and NAS boxes.", "expected_output": "Scenario: subnet router setup. The agent configures relay-1 to advertise the route with tailscale up --advertise-routes=192.168.10.0/24, approves the route on the headscale server (headscale routes approve), and reminds clients they must run with --accept-routes to use the advertised subnet. The output confirms the route is approved and that client route acceptance is enabled, and notes that if the gateway goes offline the route is unavailable.", "assertions": [ "relay-1 advertises 192.168.10.0/24", "The route is approved on the headscale server", "Client nodes are instructed to enable --accept-routes", "Route approval is verified via headscale routes list", "The stable-gateway requirement is stated" ] }, { "id": "exit-node-setup", "prompt": "Route the tailnet's outbound internet traffic through our home server exit-1 so we get privacy on untrusted coffee-shop Wi-Fi.", "expected_output": "Scenario: exit node setup. The agent configures exit-1 to advertise as an exit node (tailscale up --advertise-exit-node), approves the exit-node route on headscale, and instructs a client to select it with tailscale set --exit-node=exit-1. The output confirms the exit node is advertised and approved and that a client successfully selects it, distinguishing exit-node approval from subnet-router approval.", "assertions": [ "exit-1 advertises exit-node capability", "The exit-node route is approved on the headscale server", "A client selects the exit node via tailscale set --exit-node", "Exit-node approval is handled distinctly from subnet-router approval" ] }, { "id": "auto-approvers-config", "prompt": "We have many gateway nodes tagged tag:gateway that advertise routes. Stop approving each route by hand — set up auto-approval for routes and exit nodes from trusted gateway nodes.", "expected_output": "Scenario: auto-approvers in ACL policy. The agent writes an autoApprovers block in the tailnet policy so routes under e.g. 192.168.0.0/16 are auto-approved from tag:gateway nodes and exit nodes are auto-approved from tag:gateway. The output explains that nodes carrying the tag will have their advertised routes/exit-node status approved without manual intervention, and reloads the policy.", "assertions": [ "autoApprovers.routes maps the CIDR to the trusted tag", "autoApprovers.exitNode includes the trusted tag", "The policy is reloaded on the headscale server", "The no-manual-approval behavior for the trusted tag is explained" ] }, { "id": "via-filtering", "prompt": "Our monitoring nodes talk to servers, but we need that traffic to be routed through a specific subnet so our gateway firewall can inspect it. Configure this.", "expected_output": "Scenario: via filtering in grants. The agent writes a grants rule with a via field so monitoring-to-servers traffic (e.g. the prometheus app) is routed through the specified subnet (e.g. 192.168.10.0/24), enabling gateway firewall inspection. The output explains the via mechanism and confirms the rule is in the policy.", "assertions": [ "A grants rule with a via field routes monitoring-to-servers traffic through the subnet", "The app/port scope (e.g. prometheus) is preserved", "The purpose (gateway firewall inspection) is explained", "The via rule is placed in the active policy" ] }, { "id": "snat-disable", "prompt": "The downstream network behind our subnet router needs to see the original client IPs for logging and per-device firewalling. Configure the subnet router so client source IPs are not masked.", "expected_output": "Scenario: disabling SNAT on a subnet router. The agent configures the gateway to advertise the route with --snat-subnet-routes=false so traffic originating from tailnet clients keeps the original client IP instead of the gateway's IP. The output explains that SNAT is on by default and that disabling it is required when downstream logging/ACLs need the real client IP, and notes the tradeoff.", "assertions": [ "SNAT is disabled with --snat-subnet-routes=false on the gateway", "The output explains SNAT is on by default", "The reason (downstream needs original client IP) is stated", "The tradeoff of disabling SNAT is acknowledged" ] }, { "id": "route-list-approve-reject", "prompt": "Show me all routes in the tailnet and approve the ones from our trusted gateways while rejecting a suspicious one that a random node advertised.", "expected_output": "Scenario: route inventory and triage. The agent lists all routes with headscale routes list, approves the routes from the trusted gateway nodes, and rejects the suspicious route from the unknown node. The output shows the route statuses before and after, and confirms only the intended routes were approved/rejected.", "assertions": [ "All routes are listed with their status", "Routes from trusted gateways are approved", "The suspicious route is rejected", "Route statuses are verified after the approval/rejection actions" ] } ] }
-
-
scripts
-
hs-advertise-routes.sh 6.5 KB
#!/usr/bin/env bash set -euo pipefail # ============================================================================= # hs-advertise-routes.sh — Advertise subnet routes on a Tailscale node # # Wrapper around `tailscale up --advertise-routes=<cidr>` with overlap # detection and dry-run support. # # Usage: # hs-advertise-routes.sh --routes 192.168.1.0/24,10.0.0.0/16 # hs-advertise-routes.sh --routes 192.168.1.0/24 --node myserver # hs-advertise-routes.sh --routes 10.0.0.0/8 --dry-run # hs-advertise-routes.sh --routes 172.16.0.0/12 --json # # Options: # --routes <cidr1,cidr2> Comma-separated subnet CIDRs to advertise # --node <name> Node to configure (auto-detects current host if omitted) # --dry-run Print what would be done without making changes # --json Output results as JSON # --help Show this help message # ============================================================================= SCRIPT_NAME="$(basename "$0")" usage() { sed -n '/^# Usage:/,/^$/p' "$0" | sed '1d' | sed 's/^# //; s/^#$//' exit 0 } # ── Parse arguments ────────────────────────────────────────────────────────── ROUTES="" NODE="" DRY_RUN=false JSON=false while [[ $# -gt 0 ]]; do case "$1" in --routes) ROUTES="$2"; shift 2 ;; --node) NODE="$2"; shift 2 ;; --dry-run) DRY_RUN=true; shift ;; --json) JSON=true; shift ;; --help) usage ;; *) echo "Error: Unknown argument '$1'" >&2; usage ;; esac done # ── Validate inputs ────────────────────────────────────────────────────────── if [[ -z "$ROUTES" ]]; then echo "Error: --routes is required (comma-separated CIDRs)" >&2 exit 1 fi # Validate CIDR format (basic check) IFS=',' read -ra CIDR_LIST <<< "$ROUTES" for cidr in "${CIDR_LIST[@]}"; do if ! [[ "$cidr" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then echo "Error: Invalid CIDR format: '$cidr'" >&2 exit 1 fi done # ── Auto-detect node ───────────────────────────────────────────────────────── if [[ -z "$NODE" ]]; then if command -v tailscale &>/dev/null && tailscale status --json 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('Self',{}).get('DNSName','').rstrip('.'))" 2>/dev/null; then NODE="$(tailscale status --json | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('Self',{}).get('DNSName','').rstrip('.'))")" else NODE="$(hostname -s)" fi fi # ── Check for subnet overlap with existing routes ──────────────────────────── check_overlap() { local new_cidr="$1" if ! command -v tailscale &>/dev/null; then return 0 # can't check, skip fi local status_json status_json="$(tailscale status --json 2>/dev/null || echo '{}')" # Extract advertised routes from self local self_routes self_routes="$(echo "$status_json" | python3 -c " import sys, json, ipaddress try: data = json.load(sys.stdin) self = data.get('Self', {}) routes = self.get('AdvertisedRoutes', []) new = ipaddress.ip_network('$new_cidr') for r in routes: try: existing = ipaddress.ip_network(r) if new.overlaps(existing) and new != existing: print(f'OVERLAP:{r}') except: pass except: pass " 2>/dev/null || true)" if [[ -n "$self_routes" ]]; then echo "$self_routes" | while IFS=: read -r tag cidr; do echo "Warning: New route $new_cidr overlaps with existing advertised route $cidr" >&2 done return 1 fi return 0 } # ── Execute ────────────────────────────────────────────────────────────────── ADVERTISE_ROUTES="$ROUTES" if [[ "$DRY_RUN" == true ]]; then if [[ "$JSON" == true ]]; then echo '{"dry_run":true,"node":"'"$NODE"'","routes":["'"$(echo "$ROUTES" | sed 's/,/","/g')"'"],"command":"tailscale up --advertise-routes='"$ADVERTISE_ROUTES"'"}' else echo "[DRY-RUN] Node: $NODE" echo "[DRY-RUN] Routes: $ROUTES" echo "[DRY-RUN] Command: tailscale up --advertise-routes=$ADVERTISE_ROUTES" echo "" echo "Hint: Client nodes need --accept-routes to use these routes." echo " tailscale up --accept-routes" fi exit 0 fi # Check for overlaps before applying OVERLAP_FOUND=false for cidr in "${CIDR_LIST[@]}"; do if ! check_overlap "$cidr"; then OVERLAP_FOUND=true fi done if [[ "$OVERLAP_FOUND" == true ]]; then echo "Warning: Overlapping routes detected. Proceeding anyway..." >&2 fi # Execute tailscale up echo "Advertising routes '$ADVERTISE_ROUTES' on node '$NODE'..." if command -v tailscale &>/dev/null; then tailscale up --advertise-routes="$ADVERTISE_ROUTES" EXIT_CODE=$? else echo "Error: 'tailscale' command not found on this node" >&2 EXIT_CODE=1 fi # ── Output ─────────────────────────────────────────────────────────────────── if [[ "$JSON" == true ]]; then if [[ $EXIT_CODE -eq 0 ]]; then echo '{"status":"ok","node":"'"$NODE"'","routes":["'"$(echo "$ROUTES" | sed 's/,/","/g')"'"],"message":"Routes advertised. Approve them on the Headscale server."}' else echo '{"status":"error","node":"'"$NODE"'","routes":["'"$(echo "$ROUTES" | sed 's/,/","/g')"'"],"exit_code":'"$EXIT_CODE"'}' fi else if [[ $EXIT_CODE -eq 0 ]]; then echo "✓ Routes advertised successfully on '$NODE'" echo "" echo "Next steps:" echo " 1. Approve the routes on Headscale:" echo " hs-approve-routes.sh --list --pending-only" echo " hs-approve-routes.sh --approve <route-id>" echo " 2. Ensure clients have --accept-routes enabled:" echo " tailscale up --accept-routes" else echo "✗ Failed to advertise routes (exit code: $EXIT_CODE)" >&2 fi fi exit $EXIT_CODE -
hs-approve-routes.sh 9.5 KB
#!/usr/bin/env bash set -euo pipefail # ============================================================================= # hs-approve-routes.sh -- List and approve/reject routes in Headscale # # Manage subnet route and exit node approval on a Headscale server. # Supports Headscale REST API or direct CLI access. # # Usage: # hs-approve-routes.sh --list # hs-approve-routes.sh --list --pending-only # hs-approve-routes.sh --list --node myserver # hs-approve-routes.sh --approve <route-id> # hs-approve-routes.sh --approve-all # hs-approve-routes.sh --reject <route-id> # hs-approve-routes.sh --list --json # hs-approve-routes.sh --approve-all --dry-run # # Options: # --list List all routes (or filter with --node/--pending-only) # --approve <id> Approve a specific route by ID # --approve-all Approve all pending routes # --reject <id> Reject a specific route by ID # --node <id> Filter by node ID (used with --list) # --pending-only Show only unapproved/pending routes # --dry-run Print what would be done without making changes # --json Output results as JSON # --help Show this help message # # Environment: # HEADSCALE_URL Headscale server URL (e.g., https://headscale.example.com) # HEADSCALE_API_KEY API key from `headscale apikeys create` # ============================================================================= SCRIPT_NAME="$(basename "$0")" usage() { sed -n '/^# Usage:/,/^$/p' "$0" | sed '1d' | sed 's/^# //; s/^#$//' exit 0 } # -- Config ------------------------------------------------------------------ HEADSCALE_URL="${HEADSCALE_URL:-}" HEADSCALE_API_KEY="${HEADSCALE_API_KEY:-}" # -- Parse arguments --------------------------------------------------------- ACTION="" ACTION_ARG="" NODE="" PENDING_ONLY=false DRY_RUN=false JSON=false while [[ $# -gt 0 ]]; do case "$1" in --list) ACTION="list"; shift ;; --approve) ACTION="approve"; ACTION_ARG="$2"; shift 2 ;; --approve-all) ACTION="approve-all"; shift ;; --reject) ACTION="reject"; ACTION_ARG="$2"; shift 2 ;; --node) NODE="$2"; shift 2 ;; --pending-only) PENDING_ONLY=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --json) JSON=true; shift ;; --help) usage ;; *) echo "Error: Unknown argument '$1'" >&2; usage ;; esac done # -- Validate ----------------------------------------------------------------- if [[ -z "$ACTION" ]]; then echo "Error: One of --list, --approve, --approve-all, or --reject is required" >&2 usage fi if [[ "$ACTION" == "approve" && -z "$ACTION_ARG" ]]; then echo "Error: --approve requires a route ID argument" >&2 exit 1 fi if [[ "$ACTION" == "reject" && -z "$ACTION_ARG" ]]; then echo "Error: --reject requires a route ID argument" >&2 exit 1 fi # -- Helper: route list to JSON/table ---------------------------------------- format_routes() { local raw_json="$1" if [[ "$JSON" == true ]]; then echo "$raw_json" return fi python3 -c " import sys, json try: data = json.load(sys.stdin) except json.JSONDecodeError as e: print(f'Error: Invalid JSON input: {e}', file=sys.stderr) sys.exit(1) routes = data if isinstance(data, list) else data.get('routes', []) if not routes: print('No routes found.') sys.exit(0) print(f'{\"ID\":<10} {\"Node\":<20} {\"Prefix\":<22} {\"Status\":<15} {\"Last Seen\":<20}') print('-' * 87) for r in routes: rid = str(r.get('id', \'\')) node = r.get('node', {}).get('name', r.get('node', {}).get('id', '?')) prefix = r.get('prefix', r.get('prefixes', '')) if isinstance(r.get('prefix'), str) else ', '.join(r.get('prefixes', [])) status = r.get('status', 'unknown') last_seen = r.get('lastSeen', r.get('last_seen', ''))[:19] print(f'{rid:<10} {str(node):<20} {str(prefix):<22} {str(status):<15} {str(last_seen):<20}') " <<< "$raw_json" } # -- API call wrapper -------------------------------------------------------- api_call() { local method="$1" local endpoint="$2" local data="${3:-}" if [[ -z "$HEADSCALE_URL" || -z "$HEADSCALE_API_KEY" ]]; then local headscale_cmd="" if command -v headscale &>/dev/null; then headscale_cmd="headscale" elif [[ -f /usr/local/bin/headscale ]]; then headscale_cmd="/usr/local/bin/headscale" else echo "Error: HEADSCALE_URL/HEADSCALE_API_KEY not set and 'headscale' CLI not found" >&2 echo "Set HEADSCALE_URL and HEADSCALE_API_KEY, or run on the Headscale server." >&2 return 1 fi case "$endpoint" in *routes) if [[ "$method" == "GET" ]]; then $headscale_cmd routes list --output json 2>/dev/null || $headscale_cmd routes list 2>/dev/null elif [[ "$method" == "POST" ]]; then local route_id route_id="$(echo "$data" | python3 -c "import sys,json; print(json.load(sys.stdin).get('route_id',''))" 2>/dev/null || echo "")" if [[ -n "$route_id" ]]; then $headscale_cmd routes approve -r "$route_id" 2>/dev/null fi elif [[ "$method" == "DELETE" ]]; then local route_id route_id="$(echo "$data" | python3 -c "import sys,json; print(json.load(sys.stdin).get('route_id',''))" 2>/dev/null || echo "")" if [[ -n "$route_id" ]]; then $headscale_cmd routes reject -r "$route_id" 2>/dev/null fi fi ;; *) return 1 ;; esac return $? fi local api_url="${HEADSCALE_URL%/}/api/v1${endpoint}" local curl_args=(-s -X "$method" -H "Authorization: Bearer ${HEADSCALE_API_KEY}" -H "Content-Type: application/json") if [[ -n "$data" ]]; then curl_args+=(-d "$data") fi curl "${curl_args[@]}" "$api_url" } # -- List routes ------------------------------------------------------------- list_routes() { local filter_node="$1" local pending_only="$2" local raw raw="$(api_call "GET" "/routes" || echo '{"routes":[]}')" python3 -c " import sys, json try: data = json.load(sys.stdin) except json.JSONDecodeError: data = {'routes': []} routes = data if isinstance(data, list) else data.get('routes', []) filter_node = '$filter_node' if filter_node: routes = [r for r in routes if str(r.get('node', {}).get('id', '')).lower() == filter_node.lower() or str(r.get('node', {}).get('name', '')).lower() == filter_node.lower()] pending_only = '${pending_only}' if pending_only == 'true': routes = [r for r in routes if r.get('status', '').lower() in ('pending', 'advertised')] output = {'routes': routes} print(json.dumps(output, indent=2)) " <<< "$raw" } # -- Approve route ----------------------------------------------------------- approve_route() { local route_id="$1" local data='{"route_id": "'"$route_id"'"}' if [[ "$DRY_RUN" == true ]]; then if [[ "$JSON" == true ]]; then echo '{"dry_run":true,"action":"approve","route_id":"'"$route_id"'"}' else echo "[DRY-RUN] Would approve route ID: $route_id" fi return 0 fi api_call "POST" "/routes/$route_id/approve" } # -- Approve all ------------------------------------------------------------- approve_all() { local raw raw="$(list_routes "" "true")" local pending_ids pending_ids="$(echo "$raw" | python3 -c " import sys, json data = json.load(sys.stdin) routes = data.get('routes', []) ids = [str(r.get('id')) for r in routes if r.get('id')] print(' '.join(ids)) " 2>/dev/null || true)" if [[ -z "$pending_ids" ]]; then if [[ "$JSON" == true ]]; then echo '{"status":"ok","approved":0,"message":"No pending routes to approve"}' else echo "No pending routes to approve." fi return 0 fi if [[ "$DRY_RUN" == true ]]; then if [[ "$JSON" == true ]]; then echo '{"dry_run":true,"action":"approve-all","route_ids":["'"$(echo "$pending_ids" | sed 's/ /","/g')"'"]}' else echo "[DRY-RUN] Would approve routes: $pending_ids" fi return 0 fi local count=0 for rid in $pending_ids; do approve_route "$rid" >/dev/null 2>&1 || true count=$((count + 1)) done if [[ "$JSON" == true ]]; then echo '{"status":"ok","approved":'"$count"'}' else echo "Approved $count route(s)" fi } # -- Reject route ------------------------------------------------------------ reject_route() { local route_id="$1" if [[ "$DRY_RUN" == true ]]; then if [[ "$JSON" == true ]]; then echo '{"dry_run":true,"action":"reject","route_id":"'"$route_id"'"}' else echo "[DRY-RUN] Would reject route ID: $route_id" fi return 0 fi local data='{"route_id": "'"$route_id"'"}' api_call "POST" "/routes/$route_id/reject" } # -- Main -------------------------------------------------------------------- case "$ACTION" in list) RAW="$(list_routes "$NODE" "$(echo "$PENDING_ONLY" | tr '[:upper:]' '[:lower:]')")" format_routes "$RAW" ;; approve) approve_route "$ACTION_ARG" ;; approve-all) approve_all ;; reject) reject_route "$ACTION_ARG" ;; esac -
hs-list-routes.sh 5.9 KB
#!/usr/bin/env bash set -euo pipefail # ============================================================================= # hs-list-routes.sh — List all routes with detailed status in Headscale # # Query the Headscale server for all routes with detailed status information. # Supports filtering by node and status. # # Usage: # hs-list-routes.sh # hs-list-routes.sh --json # hs-list-routes.sh --node myserver # hs-list-routes.sh --pending-only # hs-list-routes.sh --node myserver --json # hs-list-routes.sh --pending-only --json # # Options: # --node <id> Filter by node ID or name # --pending-only Show only unapproved/pending routes # --json Output raw JSON # --help Show this help message # # Environment: # HEADSCALE_URL Headscale server URL (e.g., https://headscale.example.com) # HEADSCALE_API_KEY API key from `headscale apikeys create` # # Columns: ID, Node, Prefix, Status, Last Seen # Status values: pending, advertised, enabled, disabled # ============================================================================= SCRIPT_NAME="$(basename "$0")" usage() { sed -n '/^# Usage:/,/^$/p' "$0" | sed '1d' | sed 's/^# //; s/^#$//' exit 0 } # ── Parse arguments ────────────────────────────────────────────────────────── NODE="" PENDING_ONLY=false JSON=false while [[ $# -gt 0 ]]; do case "$1" in --node) NODE="$2"; shift 2 ;; --pending-only) PENDING_ONLY=true; shift ;; --json) JSON=true; shift ;; --help) usage ;; *) echo "Error: Unknown argument '$1'" >&2; usage ;; esac done # ── Helper: fetch routes ───────────────────────────────────────────────────── fetch_routes() { if [[ -n "$HEADSCALE_URL" && -n "$HEADSCALE_API_KEY" ]]; then curl -sf -X GET \ -H "Authorization: Bearer ${HEADSCALE_API_KEY}" \ -H "Content-Type: application/json" \ "${HEADSCALE_URL%/}/api/v1/routes" 2>/dev/null \ || echo '{"routes":[]}' elif command -v headscale &>/dev/null; then headscale routes list --output json 2>/dev/null \ || echo '{"routes":[]}' elif [[ -f /usr/local/bin/headscale ]]; then /usr/local/bin/headscale routes list --output json 2>/dev/null \ || echo '{"routes":[]}' else echo "Error: HEADSCALE_URL/HEADSCALE_API_KEY not set and 'headscale' CLI not found" >&2 echo "Set HEADSCALE_URL and HEADSCALE_API_KEY, or run on the Headscale server." >&2 exit 1 fi } # ── Format output ──────────────────────────────────────────────────────────── format_table() { python3 -c " import sys, json try: data = json.load(sys.stdin) except json.JSONDecodeError: data = {'routes': []} routes = data if isinstance(data, list) else data.get('routes', []) # Filters filter_node = '$NODE' pending_only = '${PENDING_ONLY}' if filter_node: fn = filter_node.lower() routes = [r for r in routes if str(r.get('node', {}).get('id', '')).lower() == fn or str(r.get('node', {}).get('name', '')).lower() == fn] if pending_only == 'true': routes = [r for r in routes if r.get('status', '').lower() in ('pending', 'advertised')] if not routes: print('No routes found.') sys.exit(0) # Header print(f'{\"ID\":<10} {\"Node\":<20} {\"Prefix\":<25} {\"Status\":<15} {\"Last Seen\":<22}') print('-' * 92) for r in routes: rid = str(r.get('id', '')) node_name = r.get('node', {}).get('name', r.get('node', {}).get('id', '?')) node_id = str(r.get('node', {}).get('id', '')) # Show prefix(es) prefixes = r.get('prefixes', []) if not prefixes and r.get('prefix'): prefixes = [r.get('prefix')] prefix_str = ', '.join(prefixes) if prefixes else '-' # Determine status: enabled > disabled > advertised > pending raw_status = r.get('status', 'unknown') enabled = r.get('enabled', False) is_primary = r.get('isPrimary', r.get('is_primary', False)) if enabled and is_primary: status = 'enabled' elif enabled: status = 'enabled' elif raw_status.lower() in ('disabled', 'rejected'): status = 'disabled' elif raw_status.lower() == 'advertised': status = 'pending' else: status = raw_status.lower() last_seen = r.get('lastSeen', r.get('last_seen', '')) if last_seen and len(last_seen) > 19: last_seen = last_seen[:19] if not last_seen: last_seen = '-' print(f'{rid:<10} {str(node_name):<20} {str(prefix_str):<25} {str(status):<15} {str(last_seen):<22}') " <<< "$1" } # ── Main ───────────────────────────────────────────────────────────────────── RAW="$(fetch_routes)" if [[ "$JSON" == true ]]; then # Apply filters but output as raw JSON python3 -c " import sys, json try: data = json.load(sys.stdin) except json.JSONDecodeError: data = {'routes': []} routes = data if isinstance(data, list) else data.get('routes', []) filter_node = '$NODE' pending_only = '${PENDING_ONLY}' if filter_node: fn = filter_node.lower() routes = [r for r in routes if str(r.get('node', {}).get('id', '')).lower() == fn or str(r.get('node', {}).get('name', '')).lower() == fn] if pending_only == 'true': routes = [r for r in routes if r.get('status', '').lower() in ('pending', 'advertised')] output = {'routes': routes, 'count': len(routes)} print(json.dumps(output, indent=2)) " <<< "$RAW" else format_table "$RAW" fi
-
-
README.md 497 B
# Headscale Routing ## Why Install This Skill Configures subnet routers and exit nodes so a tailnet can reach LAN devices or route internet traffic. ## What You Get | Content | Purpose | |---|---| | `SKILL.md` and `scripts/` | Route advertising and approval guidance | ## Quick Start Use `SKILL.md` to advertise routes, then approve them centrally. ## Triggers Use when adding LAN routes or an exit node. ## Requirements Headscale administration and a gateway node with routing enabled. -
SKILL.md 4.9 KB
--- name: headscale-routing description: Configure subnet routers and exit nodes in a Headscale tailnet to extend mesh access to non-Tailscale devices and route internet traffic. Use when setting up subnet routing for LAN devices or configuring exit nodes for privacy. metadata: category: devops --- # headscale-routing ## Overview Subnet routers extend a tailnet to non-Tailscale devices (printers, NAS boxes, IoT devices) by advertising the local LAN subnet routes through a gateway node. Exit nodes route all non-tailnet internet traffic through a home server, providing privacy on untrusted networks (coffee shop Wi-Fi, hotel networks, etc.). Headscale manages route approval centrally — routes must be advertised by the gateway node and then approved on the Headscale server before they become active. ## Subnet Router Setup 1. On the gateway node, advertise the LAN subnet: ```bash tailscale up --advertise-routes=192.168.1.0/24 ``` 2. On the Headscale server, approve the route: ```bash headscale routes approve -r <route-id> ``` 3. On client nodes that need to reach the subnet, enable route acceptance: ```bash tailscale up --accept-routes ``` ## Exit Node Setup 1. On the exit node, advertise it as an exit node: ```bash tailscale up --advertise-exit-node ``` 2. On the Headscale server, approve the exit node route. 3. On the client, select the exit node: ```bash tailscale set --exit-node=<node-name> ``` ## Auto-Approvers Configure Headscale ACL policy to auto-approve routes from trusted nodes: ```json { "autoApprovers": { "routes": { "192.168.0.0/16": ["tag:gateway"] }, "exitNode": ["tag:gateway"] } } ``` Nodes carrying `tag:gateway` will have their advertised routes or exit-node status approved automatically without manual intervention. ## Via Filtering Use `grants.via` in ACL policies to restrict cross-subnet access: ```json { "grants": [ { "src": ["tag:monitoring"], "dst": ["tag:servers"], "app": ["prometheus"], "via": ["192.168.10.0/24"] } ] } ``` This ensures traffic from monitoring nodes to servers is routed through the specified subnet, enabling firewall policies on the gateway to inspect or filter traffic. ## SNAT on Subnet Routers By default, Headscale enables Source NAT (SNAT) on subnet router traffic. This means traffic originating from tailnet clients destined for the advertised subnet appears to come from the gateway node's IP. To disable SNAT: ```bash tailscale up --advertise-routes=192.168.1.0/24 --snat-subnet-routes=false ``` Disable SNAT when the downstream network needs to see the original client IP for logging, ACLs, or per-device firewall rules. ## List / Approve / Reject Routes on Headscale ```bash # List all routes with status headscale routes list # Approve a specific route headscale routes approve -r <route-id> # Approve all routes from a node headscale routes approve --all -n <node-name> # Reject a route headscale routes reject -r <route-id> ``` ## Gotchas - **Stable gateway required**: Subnet routes need a gateway node that stays online. If the gateway goes down, the route becomes unavailable. - **`--accept-routes` on clients**: Clients will NOT use advertised subnet routes unless they themselves run with `--accept-routes` or have it in their up flags. - **IPv4-only by default**: Tailscale subnet routing is IPv4-only unless you explicitly configure dual-stack (IPv4 + IPv6). - **Overlapping subnets**: If two nodes advertise overlapping subnets, routing behavior is undefined. Use distinct, non-overlapping CIDRs. - **Headscale API required for scripting**: The `headscale routes` CLI commands above require shell access to the Headscale server. For remote management, use the Headscale REST API with `$HEADSCALE_URL` and `$HEADSCALE_API_KEY`. ## Environment | Variable | Description | |---------------------|--------------------------------------| | `HEADSCALE_URL` | Headscale server URL | | `HEADSCALE_API_KEY` | API key from `headscale apikeys create` | ## Trigger Conditions - "subnet router" - "exit node" - "advertise route" - "approve route" - "route traffic" - "subnet routing" - "exit node setup" ## Scripts Refer to individual script help (`--help`) for usage details: | Script | Description | |-------------------------|---------------------------------------------------| | `hs-advertise-routes.sh`| Advertise subnet routes on a Tailscale node | | `hs-approve-routes.sh` | List and approve/reject routes on Headscale | | `hs-list-routes.sh` | List all routes with detailed status | ## When not to use Do not use this skill for node lifecycle management (load `headscale-node-lifecycle` instead) or for writing ACL policies (load `tailnet-policy`). It covers subnet routers and exit nodes only.
-
-
tailnet-policy
-
evals
-
evals.json 5.7 KB
{ "schema_version": 1, "skill_name": "tailnet-policy", "evals": [ { "id": "segmented-environments-policy", "prompt": "We have dev and prod nodes in one tailnet. Dev nodes can talk to dev nodes, prod nodes to prod nodes, and admins can reach both. Write the policy.", "expected_output": "Scenario: segmented environment policy. The agent writes a huJSON policy with tagOwners for tag:dev and tag:prod, and grants rules: dev-to-dev, prod-to-prod, and admin-to-both, preserving default-deny for anything else. The output explains tag ownership and that the policy file lives at the path configured in headscale config.yaml, and that the policy is validated before reload.", "assertions": [ "tagOwners declares tag:dev and tag:prod owned by admins", "Grants enforce dev-to-dev and prod-to-prod isolation", "Admin access to both environments is granted", "Default-deny is preserved for unspecified traffic", "The policy is validated before being applied" ] }, { "id": "modern-grants-vs-legacy-acls", "prompt": "Our headscale policy is written with the old users/ports ACL syntax. Convert it to the modern grants syntax so we get protocol and via filtering support.", "expected_output": "Scenario: migrating legacy ACLs to grants. The agent identifies the deprecated users/ports ACL rules and rewrites them as grants with src/dst/ip (and proto where applicable), using the migrate-acls-to-grants.py script or by hand. The output explains the ACL-vs-grants differences (port filtering via ip field, proto support, via routing) and validates the converted policy before reload. The agent keeps the users/src distinction correct and does not interleave legacy ACL fields into grants.", "assertions": [ "Legacy users/ports ACL rules are converted to src/dst/ip grants", "The migration uses grants where possible with proto/via preserved", "The converted policy is validated before reload", "The difference between ACL users/ports and grant src/ip fields is respected" ] }, { "id": "tag-based-access-pattern", "prompt": "Only monitoring nodes may talk to the webserver and database nodes, and webserver nodes may talk to the database only on port 5432. Write this as a tag-based policy.", "expected_output": "Scenario: tag-based access control. The agent writes tagOwners for tag:monitoring, tag:webserver, and tag:database (owned by admins), and grants: monitoring-to-webserver-and-database, and webserver-to-database limited to tcp:5432. The output explains that tags decouple policy from user identity and verifies the rules are restrictive rather than allow-all.", "assertions": [ "tagOwners declares the three tags owned by admins", "Monitoring can reach webserver and database nodes", "Webserver-to-database is scoped to tcp:5432 only", "The policy is restrictive (no unintended allow-all)", "The tag-based identity model is applied" ] }, { "id": "auto-approvers-policy", "prompt": "Write a policy that lets admins and a specific user auto-approve subnet routes and exit nodes so trusted infrastructure doesn't need manual route approval.", "expected_output": "Scenario: auto-approvers policy. The agent writes an autoApprovers block mapping CIDR ranges to the users/groups that can auto-approve them and an exitNode list for exit-node auto-approval. The output explains the routes vs exitNode semantics and that these nodes bypass manual route approval.", "assertions": [ "autoApprovers.routes maps CIDRs to the admin group and a specific user", "autoApprovers.exitNode lists who can advertise exit nodes", "The routes-vs-exitNode semantics are explained", "The policy is valid and reload-ready" ] }, { "id": "policy-validation-with-tests", "prompt": "Before we ship an ACL change, validate it: confirm alice can reach the webserver on 443 and bob cannot reach the database on 22. Check with an automated test.", "expected_output": "Scenario: policy validation with embedded tests. The agent adds tests entries to the policy (alice to webserver tcp:443 accept, bob to database tcp:22 drop) and runs validate-policy.py --policy <file> to validate. The output reports pass/fail for each expected result and notes that tests validate at parse time, not runtime, and that a passing test does not guarantee live behavior.", "assertions": [ "Policy tests encode the expected accept and drop results", "validate-policy.py is run against the policy file", "The test results are reported as pass/fail per case", "The limitation that tests are parse-time, not runtime, is stated" ] }, { "id": "headscale-unsupported-features", "prompt": "We copied a Tailscale-cloud policy into our self-hosted headscale and it uses devicePosture, ipSets, and OIDC group names. Will these work? Fix the policy for headscale.", "expected_output": "Scenario: adapting Tailscale-only features to headscale. The agent identifies that headscale does not support devicePosture/device:managed, ipSets/ipprotocol, or OIDC groups in ACLs. It removes or rewrites those constructs, replacing OIDC groups with autogroup:admin/autogroup:member and validating the resulting policy. The output explains each unsupported feature and the substitution used.", "assertions": [ "devicePosture and ipSets constructs are identified as unsupported and removed", "OIDC groups are replaced with autogroup:admin/autogroup:member", "Each unsupported-feature substitution is explained", "The corrected policy is validated before use" ] } ] }
-
-
scripts
-
migrate-acls-to-grants.py 14 KB
#!/usr/bin/env python3 """ migrate-acls-to-grants.py — Convert legacy ACL syntax to modern Grants syntax. Parses a huJSON policy file containing legacy ACLs and converts each ACL rule to its Grant equivalent. Preserves tagOwners, autoApprovers, SSH sections, and tests that are already in valid format. ACL -> Grant Mapping: Legacy: {"action": "accept", "users": [...], "ports": ["*:*"]} Grant: {"src": <users>, "dst": <ports-destinations>, "ip": ["*:*"]} The conversion extracts destination hosts from port expressions like "tag:svr:80" or "100.64.0.1:443" by splitting on the last colon (port separator). Usage: migrate-acls-to-grants.py --input policy.hujson --output policy-grants.hujson migrate-acls-to-grants.py --input policy.hujson --dry-run migrate-acls-to-grants.py --input policy.hujson --json migrate-acls-to-grants.py --help """ import argparse import json import os import re import sys from typing import Any, Dict, List, Optional, Tuple def parse_hujson(text: str) -> Tuple[Optional[Dict[str, Any]], List[str]]: """ Parse huJSON text, handling trailing commas and // comments. Returns (parsed_dict, errors_list). """ errors: List[str] = [] cleaned = text # Protect strings strings: List[str] = [] string_holders: List[str] = [] def _protect_strings(s: str) -> str: result = [] i = 0 while i < len(s): if s[i] == '"': j = i + 1 while j < len(s): if s[j] == '\\': j += 2 continue if s[j] == '"': j += 1 break j += 1 holder = '__STR_{}__'.format(len(strings)) strings.append(s[i:j]) string_holders.append(holder) result.append(holder) i = j else: result.append(s[i]) i += 1 return ''.join(result) def _restore_strings(s: str) -> str: for holder, orig in zip(string_holders, strings): s = s.replace(holder, orig) return s protected = _protect_strings(cleaned) # Remove // comments lines = protected.split('\n') cleaned_lines = [] for line in lines: comment_pos = line.find('//') if comment_pos >= 0: line = line[:comment_pos] cleaned_lines.append(line) cleaned = '\n'.join(cleaned_lines) cleaned = _restore_strings(cleaned) # Remove trailing commas cleaned = re.sub(r',\s*([}\]])', r'\1', cleaned) cleaned = re.sub(r',\s*\n\s*([}\]])', r'\n\1', cleaned) try: parsed = json.loads(cleaned) return parsed, [] except json.JSONDecodeError as e: errors.append("JSON parse error: {}".format(e)) return None, errors def _serialize_value(val: Any, level: int, indent: int) -> str: """Serialize a value to huJSON-style string.""" prefix = ' ' * (level * indent) inner_prefix = ' ' * ((level + 1) * indent) if val is None: return 'null' elif isinstance(val, bool): return 'true' if val else 'false' elif isinstance(val, (int, float)): return str(val) elif isinstance(val, str): return json.dumps(val) elif isinstance(val, list): if not val: return '[]' items = [] for item in val: items.append(inner_prefix + _serialize_value(item, level + 1, indent)) return '[\n' + ',\n'.join(items) + '\n' + prefix + ']' elif isinstance(val, dict): if not val: return '{}' items = [] for key, value in val.items(): k = json.dumps(key) v = _serialize_value(value, level + 1, indent) items.append(inner_prefix + k + ': ' + v) return '{\n' + ',\n'.join(items) + '\n' + prefix + '}' else: return str(val) def serialize_hujson(obj: Any, indent: int = 2) -> str: """Serialize to huJSON-style output (with trailing commas for readability).""" result = _serialize_value(obj, 0, indent) # Add trailing commas before ] and } for huJSON style lines = result.split('\n') output_lines = [] for i, line in enumerate(lines): stripped = line.rstrip() if i < len(lines) - 1: next_line = lines[i + 1].strip() if i + 1 < len(lines) else '' if stripped.endswith('}') and next_line in (']', '},'): stripped += ',' elif stripped.endswith(']') and next_line == ']': stripped += ',' output_lines.append(stripped) return '\n'.join(output_lines) def parse_port_expression(port_expr: str) -> Tuple[Optional[str], Optional[str]]: """ Parse a port expression like "tag:webserver:80" or "100.64.0.1:443" or "*:*". Returns (destination, port_or_none). Handles: "tag:webserver:80" -> ("tag:webserver", "80") "100.64.0.1:443" -> ("100.64.0.1", "443") "*:*" -> ("*", "*") "tag:ci-runner:*" -> ("tag:ci-runner", "*") """ if port_expr == '*:*': return '*', '*' if port_expr.startswith('tag:'): # Split on last colon to isolate port # tag:name:port — need to be careful # Remove the leading 'tag:' then find the last colon after_tag = port_expr[4:] # e.g., "webserver:80" last_colon = after_tag.rfind(':') if last_colon >= 0: tag_name = after_tag[:last_colon] port = after_tag[last_colon + 1:] return 'tag:' + tag_name, port return port_expr, None if ':' in port_expr: parts = port_expr.rsplit(':', 1) return parts[0], parts[1] return port_expr, None def convert_acl_to_grants(acls: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[str]]: """ Convert legacy ACL entries to modern Grant entries. Returns (grants, warnings). """ grants: List[Dict[str, Any]] = [] warnings: List[str] = [] for i, acl in enumerate(acls): if not isinstance(acl, dict): warnings.append("acls[{}]: skipped non-object entry".format(i)) continue action = acl.get('action', 'accept') users = acl.get('users', []) ports = acl.get('ports', []) if not isinstance(users, list): warnings.append("acls[{}]: 'users' must be an array, skipping".format(i)) continue if action == 'drop': warnings.append("acls[{}]: drop rules cannot be directly converted to Grants " "(Grants are accept-only; deny-by-default must be relied upon)".format(i)) continue # For each port entry, create a grant if not isinstance(ports, list): warnings.append("acls[{}]: 'ports' must be an array, skipping".format(i)) continue for port_expr in ports: if not isinstance(port_expr, str): warnings.append("acls[{}].ports: skipped non-string entry: {}".format(i, port_expr)) continue dst, port = parse_port_expression(port_expr) if dst is None: warnings.append("acls[{}].ports: could not parse '{}'".format(i, port_expr)) continue # Build ip filter ip_filter = port or '*' # If port is a number or has proto:port format, use as-is # If it's just a number, prefix with *: or tcp: if ip_filter.isdigit(): ip_filter = '*:' + ip_filter elif ip_filter == '*': ip_filter = '*:*' # Determine if we need to merge with existing grants grant = { 'src': list(users) if isinstance(users, list) else [users], 'dst': [dst], 'ip': [ip_filter], } grants.append(grant) return grants, warnings def convert_legacy_tests(tests: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[str]]: """Convert legacy test entries if they use users/ports format.""" warnings: List[str] = [] converted: List[Dict[str, Any]] = [] for i, test in enumerate(tests): if not isinstance(test, dict): warnings.append("tests[{}]: skipped non-object entry".format(i)) continue # Check if it uses legacy format if 'users' in test or 'ports' in test: users = test.get('users', []) ports = test.get('ports', []) action = test.get('action', 'accept') for port_expr in (ports if isinstance(ports, list) else [ports]): dst, port_str = parse_port_expression(str(port_expr) if port_expr else '*:*') ip_filter = '*:' + port_str if port_str and port_str.isdigit() else (port_str or '*:*') new_test: Dict[str, Any] = { 'src': users if isinstance(users, list) else [users], 'dst': dst or '*', 'action': action, } # src might be a string (legacy tests accept strings) if isinstance(users, str): new_test['src'] = users if dst and port_str: new_test['ip'] = [ip_filter] converted.append(new_test) warnings.append("tests[{}]: converted from legacy format to grant-style test".format(i)) else: # Already in modern format converted.append(test) return converted, warnings def main() -> None: parser = argparse.ArgumentParser( description="Convert legacy ACL syntax to modern Grants syntax", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s --input policy.hujson --output policy-grants.hujson %(prog)s --input policy.hujson --dry-run %(prog)s --input policy.hujson --json """ ) parser.add_argument('--input', '-i', required=True, help='Path to input huJSON policy file') parser.add_argument('--output', '-o', default=None, help='Path to output file (default: stdout)') parser.add_argument('--dry-run', action='store_true', help='Preview changes without writing') parser.add_argument('--json', '-j', action='store_true', help='Output as JSON') args = parser.parse_args() input_path = os.path.expanduser(args.input) if not os.path.exists(input_path): error_msg = "Input file not found: " + input_path if args.json: print(json.dumps({"converted": False, "errors": [error_msg]}, indent=2)) else: print("ERROR: " + error_msg, file=sys.stderr) sys.exit(1) with open(input_path, 'r') as f: content = f.read() parsed, parse_errors = parse_hujson(content) if parsed is None: if args.json: print(json.dumps({"converted": False, "errors": parse_errors}, indent=2)) else: for err in parse_errors: print("ERROR: " + err, file=sys.stderr) sys.exit(1) if not isinstance(parsed, dict): msg = "Policy root must be a JSON object" if args.json: print(json.dumps({"converted": False, "errors": [msg]}, indent=2)) else: print("ERROR: " + msg, file=sys.stderr) sys.exit(1) # Extract sections existing_grants = parsed.get('grants', []) existing_acls = parsed.get('acls', []) tag_owners = parsed.get('tagOwners', {}) auto_approvers = parsed.get('autoApprovers', {}) ssh = parsed.get('ssh', []) existing_tests = parsed.get('tests', []) all_warnings: List[str] = [] # Convert ACLs to grants if existing_acls: converted_grants, acl_warnings = convert_acl_to_grants(existing_acls) all_warnings.extend(acl_warnings) else: converted_grants = [] # Combine existing grants with converted ones combined_grants = list(existing_grants) + converted_grants # Convert legacy tests converted_tests, test_warnings = convert_legacy_tests(existing_tests) all_warnings.extend(test_warnings) # Build output policy output_policy: Dict[str, Any] = {} if tag_owners: output_policy['tagOwners'] = tag_owners if combined_grants: output_policy['grants'] = combined_grants if auto_approvers: output_policy['autoApprovers'] = auto_approvers if ssh: output_policy['ssh'] = ssh if converted_tests: output_policy['tests'] = converted_tests # Stats stats = { 'acls_converted': len(existing_acls), 'grants_from_acls': len(converted_grants), 'existing_grants_preserved': len(existing_grants), 'total_grants': len(combined_grants), 'warnings': len(all_warnings), } # Output if args.json: output = { "converted": True, "stats": stats, "warnings": all_warnings, "policy": output_policy, } print(json.dumps(output, indent=2)) else: if all_warnings: print("Warnings:", file=sys.stderr) for w in all_warnings: print(" * " + w, file=sys.stderr) print("Converted {} ACL rule(s) -> {} grant(s)".format(stats['acls_converted'], stats['grants_from_acls'])) print("Preserved {} existing grant(s)".format(stats['existing_grants_preserved'])) print("Total: {} grant(s) in output".format(stats['total_grants'])) if args.dry_run: print("\n[DRY RUN] Output would be written to: " + (args.output or 'stdout')) print("\n" + "-" * 40) print(serialize_hujson(output_policy)) elif args.output: output_path = os.path.expanduser(args.output) serialized = serialize_hujson(output_policy) with open(output_path, 'w') as f: f.write(serialized) print("\nWritten to: " + output_path) else: print("\n" + "-" * 40) print(serialize_hujson(output_policy)) if all_warnings: sys.exit(0) # Non-fatal warnings if __name__ == '__main__': main() -
reload-headscale-policy.sh 5.3 KB
#!/usr/bin/env bash # # reload-headscale-policy.sh — Reload Headscale policy via SIGHUP # # Sends SIGHUP to the headscale process to trigger a policy reload, # then verifies the reload was successful via logs or health check. # # Usage: # reload-headscale-policy.sh # reload-headscale-policy.sh --dry-run # reload-headscale-policy.sh --json # reload-headscale-policy.sh --help # # Options: # --dry-run Show what would be done without actually sending SIGHUP # --json Output machine-readable JSON # --help Show this help message set -euo pipefail SCRIPT_NAME="$(basename "$0")" PROCESS_NAME="headscale" RELOAD_TIMEOUT=10 # seconds to wait for successful reload # ---- Help ---- show_help() { sed -ne '/^#/!q;s/^#$//;s/^# //p' "$0" exit 0 } # ---- Logging ---- log_info() { echo "[INFO] $*"; } log_error() { echo "[ERROR] $*" >&2; } log_debug() { :; } # noop unless DEBUG is set # ---- JSON output helpers ---- JSON_MODE=false json_output() { local status="$1" shift local reloaded="$1" shift # Remaining args are error messages as separate args local errors=("$@") if $JSON_MODE; then # Build JSON safely local json_errors="[]" if [ ${#errors[@]} -gt 0 ]; then json_errors="[" local first=true for err in "${errors[@]}"; do if $first; then first=false else json_errors+=", " fi # Escape for JSON local escaped escaped=$(printf '%s' "$err" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || echo "\"$err\"") json_errors+="$escaped" done json_errors+="]" fi printf '{"reloaded":%s,"errors":%s}\n' "$reloaded" "$json_errors" fi } print_and_exit() { local exit_code="$1" shift local reloaded_val="$1" shift local errors=("$@") if $JSON_MODE; then json_output "done" "$reloaded_val" "${errors[@]}" fi exit "$exit_code" } # ---- Parse arguments ---- DRY_RUN=false while [[ $# -gt 0 ]]; do case "$1" in --help|-h) show_help ;; --dry-run) DRY_RUN=true shift ;; --json) JSON_MODE=true shift ;; *) log_error "Unknown option: $1" log_error "Usage: $SCRIPT_NAME [--dry-run] [--json] [--help]" exit 1 ;; esac done # ---- Find headscale PID ---- HEADSCALE_PID="" declare -a ERROR_MESSAGES=() if DRY_RUN=false; then :; fi # suppress shellcheck SC2034 # Try pgrep first if command -v pgrep &>/dev/null; then HEADSCALE_PID=$(pgrep -x "$PROCESS_NAME" 2>/dev/null || true) fi # Fallback: check common paths if [ -z "$HEADSCALE_PID" ]; then if [ -f /var/run/headscale/headscale.pid ]; then HEADSCALE_PID=$(cat /var/run/headscale/headscale.pid 2>/dev/null || true) elif [ -f /run/headscale.pid ]; then HEADSCALE_PID=$(cat /run/headscale.pid 2>/dev/null || true) fi fi # Fallback: ps aux if [ -z "$HEADSCALE_PID" ]; then HEADSCALE_PID=$(ps aux 2>/dev/null | grep -E '[h]eadscale' | awk '{print $2}' | head -1 || true) fi if [ -z "$HEADSCALE_PID" ]; then ERROR_MESSAGES+=("headscale process not found") if $JSON_MODE; then print_and_exit 1 false "${ERROR_MESSAGES[@]}" else log_error "headscale process not found (is Headscale running?)" exit 1 fi fi # ---- Dry run ---- if $DRY_RUN; then if $JSON_MODE; then print_and_exit 0 false "Dry run — no SIGHUP sent" else log_info "Dry run — would send SIGHUP to headscale (PID: $HEADSCALE_PID)" exit 0 fi fi # ---- Send SIGHUP ---- if kill -HUP "$HEADSCALE_PID" 2>/dev/null; then # Check if process is still alive (died after signal?) sleep 0.5 if kill -0 "$HEADSCALE_PID" 2>/dev/null; then : else ERROR_MESSAGES+=("headscale process (PID: $HEADSCALE_PID) exited after SIGHUP") fi else ERROR_MESSAGES+=("Failed to send SIGHUP to PID $HEADSCALE_PID (permission denied?)") fi # ---- Verify reload ---- RELOADED=false if [ ${#ERROR_MESSAGES[@]} -eq 0 ]; then # Wait briefly and check process health sleep 1 # Check process is still alive if kill -0 "$HEADSCALE_PID" 2>/dev/null; then RELOADED=true else ERROR_MESSAGES+=("headscale process died after reload (PID: $HEADSCALE_PID)") fi # Try to verify via logs (journalctl if available) if $RELOADED && command -v journalctl &>/dev/null; then RELOAD_LOG=$(journalctl -u headscale --since "10 seconds ago" 2>/dev/null | grep -i "policy.*reload\|reload.*policy\|ACL\|grant" | tail -3 || true) if [ -n "$RELOAD_LOG" ]; then log_debug "Reload log entries: $RELOAD_LOG" fi fi fi # ---- Output ---- if [ ${#ERROR_MESSAGES[@]} -gt 0 ]; then if $JSON_MODE; then print_and_exit 1 false "${ERROR_MESSAGES[@]}" else log_error "Policy reload FAILED" for err in "${ERROR_MESSAGES[@]}"; do log_error " • $err" done exit 1 fi fi if $JSON_MODE; then print_and_exit 0 true else log_info "Policy reloaded successfully (PID: $HEADSCALE_PID)" exit 0 fi -
validate-policy.py 18.5 KB
#!/usr/bin/env python3 """ validate-policy.py — Validate huJSON policy files for Headscale/Tailscale. Parses huJSON (handles trailing commas, comments), validates structure: - tagOwners references are consistent - grants have valid src/dst/ip fields - acls (legacy) have valid users/ports fields - autoApprovers have valid routes/exitNode - ssh rules have valid action/src/dst/users - tests have valid src/dst/ip/action Usage: validate-policy.py --policy policy.hujson validate-policy.py --policy policy.hujson --json validate-policy.py --policy policy.hujson --fix validate-policy.py --help """ import argparse import json import os import re import sys from typing import Any, Dict, List, Optional, Tuple def parse_hujson(text: str) -> Tuple[Optional[Dict[str, Any]], List[str]]: """ Parse huJSON text, handling trailing commas and single-line // comments. Returns (parsed_dict, errors_list). """ errors: List[str] = [] cleaned = text # Remove single-line // comments (but not http:// style) # Handle strings: temporarily replace strings so we don't mangle URLs in them strings: List[str] = [] string_holders: List[str] = [] def _protect_strings(s: str) -> str: """Replace JSON strings with placeholders to avoid comment removal inside them.""" result = [] i = 0 while i < len(s): if s[i] == '"': # Find matching closing quote (skip escaped quotes) j = i + 1 while j < len(s): if s[j] == '\\': j += 2 continue if s[j] == '"': j += 1 break j += 1 holder = f'__STR_{len(strings)}__' strings.append(s[i:j]) string_holders.append(holder) result.append(holder) i = j else: result.append(s[i]) i += 1 return ''.join(result) def _restore_strings(s: str) -> str: """Restore string placeholders back to original strings.""" for holder, orig in zip(string_holders, strings): s = s.replace(holder, orig) return s # Protect strings protected = _protect_strings(cleaned) # Remove // comments (outside strings) lines = protected.split('\n') cleaned_lines = [] for line in lines: # Find // that's not inside a string (strings are already placeholders) comment_pos = line.find('//') if comment_pos >= 0: line = line[:comment_pos] cleaned_lines.append(line) cleaned = '\n'.join(cleaned_lines) # Restore strings cleaned = _restore_strings(cleaned) # Remove trailing commas before ] or } cleaned = re.sub(r',\s*([}\]])', r'\1', cleaned) # Also handle trailing commas in arrays/objects with whitespace/newlines cleaned = re.sub(r',\s*\n\s*([}\]])', r'\n\1', cleaned) try: parsed = json.loads(cleaned) return parsed, [] except json.JSONDecodeError as e: errors.append(f"JSON parse error: {e}") return None, errors def validate_tag_name(tag: str) -> Optional[str]: """Validate a tag name, return error message or None.""" if not tag.startswith('tag:'): return f"Invalid tag format: '{tag}' — must start with 'tag:'" name = tag[4:] if not re.match(r'^[a-z0-9][a-z0-9-]*$', name): return f"Invalid tag name: '{tag}' — must be lowercase alphanumeric with hyphens" return None def validate_autogroup(group: str) -> Optional[str]: """Validate an autogroup reference.""" valid = {'autogroup:member', 'autogroup:admin', 'autogroup:tagged', 'autogroup:internet'} if group not in valid: return f"Unknown autogroup: '{group}' — valid options: {', '.join(sorted(valid))}" return None def validate_grants(grants: List[Dict[str, Any]]) -> List[str]: """Validate grants array entries.""" errors: List[str] = [] for i, grant in enumerate(grants): prefix = f"grants[{i}]" if not isinstance(grant, dict): errors.append(f"{prefix}: expected object, got {type(grant).__name__}") continue # Check required fields if 'src' not in grant: errors.append(f"{prefix}: missing required field 'src'") if 'dst' not in grant: errors.append(f"{prefix}: missing required field 'dst'") # Validate src is an array src = grant.get('src', []) if not isinstance(src, list): errors.append(f"{prefix}.src: must be an array, got {type(src).__name__}") else: for j, s in enumerate(src): if not isinstance(s, str): errors.append(f"{prefix}.src[{j}]: must be a string") elif s.startswith('tag:'): err = validate_tag_name(s) if err: errors.append(f"{prefix}.src[{j}]: {err}") elif s.startswith('autogroup:'): err = validate_autogroup(s) if err: errors.append(f"{prefix}.src[{j}]: {err}") # Validate dst is an array dst = grant.get('dst', []) if not isinstance(dst, list): errors.append(f"{prefix}.dst: must be an array, got {type(dst).__name__}") else: for j, d in enumerate(dst): if not isinstance(d, str): errors.append(f"{prefix}.dst[{j}]: must be a string") elif d.startswith('tag:'): err = validate_tag_name(d) if err: errors.append(f"{prefix}.dst[{j}]: {err}") # Validate ip if present ip = grant.get('ip', []) if ip is not None and not isinstance(ip, list): errors.append(f"{prefix}.ip: must be an array or omitted") elif ip: for j, p in enumerate(ip): if not isinstance(p, str): errors.append(f"{prefix}.ip[{j}]: must be a string") # Validate proto if present proto = grant.get('proto') if proto is not None and proto not in ('tcp', 'udp', 'icmp'): errors.append(f"{prefix}.proto: must be 'tcp', 'udp', or 'icmp', got '{proto}'") # Validate via if present via = grant.get('via') if via is not None: if isinstance(via, list): for j, v in enumerate(via): if not isinstance(v, str): errors.append(f"{prefix}.via[{j}]: must be a string") elif not isinstance(via, str): errors.append(f"{prefix}.via: must be a string or array") return errors def validate_acls(acls: List[Dict[str, Any]]) -> List[str]: """Validate legacy ACL syntax entries.""" errors: List[str] = [] for i, acl in enumerate(acls): prefix = f"acls[{i}]" if not isinstance(acl, dict): errors.append(f"{prefix}: expected object, got {type(acl).__name__}") continue if 'action' not in acl: errors.append(f"{prefix}: missing required field 'action'") elif acl['action'] not in ('accept', 'drop'): errors.append(f"{prefix}.action: must be 'accept' or 'drop', got '{acl['action']}'") if 'users' not in acl: errors.append(f"{prefix}: missing required field 'users'") elif not isinstance(acl['users'], list): errors.append(f"{prefix}.users: must be an array") if 'ports' not in acl: errors.append(f"{prefix}: missing required field 'ports'") elif not isinstance(acl['ports'], list): errors.append(f"{prefix}.ports: must be an array") return errors def validate_tag_owners(tag_owners: Dict[str, Any], grants: List[Dict], acls: List[Dict]) -> List[str]: """Validate that all tags referenced in grants/acls have corresponding tagOwners.""" errors: List[str] = [] referenced_tags: set = set() if grants: for grant in grants: for field in ('src', 'dst'): items = grant.get(field, []) if isinstance(items, list): for item in items: if isinstance(item, str) and item.startswith('tag:'): referenced_tags.add(item) if acls: for acl in acls: for field in ('users', 'ports'): items = acl.get(field, []) if isinstance(items, list): for item in items: if isinstance(item, str) and item.startswith('tag:'): referenced_tags.add(item) if tag_owners and isinstance(tag_owners, dict): defined_tags = set(tag_owners.keys()) else: defined_tags = set() if not tag_owners and referenced_tags: errors.append("Tags used in grants/acls but 'tagOwners' section is missing or empty") for tag in sorted(referenced_tags): if tag not in defined_tags: errors.append(f"Tag '{tag}' used in grants/acls but not defined in 'tagOwners'") # Check for unused tag owners if tag_owners and isinstance(tag_owners, dict): for tag in sorted(defined_tags): if tag not in referenced_tags: errors.append(f"Tag '{tag}' defined in 'tagOwners' but never referenced in grants/acls") # Validate tag name err = validate_tag_name(tag) if err: errors.append(err) # Validate owners owners = tag_owners[tag] if not isinstance(owners, list): errors.append(f"tagOwners.{tag}: must be an array") else: for j, owner in enumerate(owners): if not isinstance(owner, str): errors.append(f"tagOwners.{tag}[{j}]: must be a string") return errors def validate_auto_approvers(auto_approvers: Dict[str, Any]) -> List[str]: """Validate autoApprovers section.""" errors: List[str] = [] if not isinstance(auto_approvers, dict): return [f"autoApprovers: expected object, got {type(auto_approvers).__name__}"] routes = auto_approvers.get('routes') if routes is not None: if not isinstance(routes, dict): errors.append("autoApprovers.routes: must be an object (cidr -> [users])") else: for cidr, users in routes.items(): if not isinstance(cidr, str): errors.append(f"autoApprovers.routes: key must be a string (CIDR)") if not isinstance(users, list): errors.append(f"autoApprovers.routes['{cidr}']: must be an array of users") exit_node = auto_approvers.get('exitNode') if exit_node is not None: if not isinstance(exit_node, list): errors.append("autoApprovers.exitNode: must be an array of users") return errors def validate_ssh(ssh_rules: List[Dict[str, Any]]) -> List[str]: """Validate Tailscale SSH rules.""" errors: List[str] = [] if not isinstance(ssh_rules, list): return [f"ssh: expected array, got {type(ssh_rules).__name__}"] for i, rule in enumerate(ssh_rules): prefix = f"ssh[{i}]" if not isinstance(rule, dict): errors.append(f"{prefix}: expected object") continue if 'action' not in rule: errors.append(f"{prefix}: missing 'action'") elif rule['action'] not in ('accept', 'check'): errors.append(f"{prefix}.action: must be 'accept' or 'check'") if 'src' not in rule: errors.append(f"{prefix}: missing 'src'") elif not isinstance(rule['src'], list): errors.append(f"{prefix}.src: must be an array") if 'dst' not in rule: errors.append(f"{prefix}: missing 'dst'") elif not isinstance(rule['dst'], list): errors.append(f"{prefix}.dst: must be an array") if 'users' not in rule: errors.append(f"{prefix}: missing 'users'") elif not isinstance(rule['users'], list): errors.append(f"{prefix}.users: must be an array") return errors def validate_tests(tests: List[Dict[str, Any]]) -> List[str]: """Validate test definitions.""" errors: List[str] = [] if not isinstance(tests, list): return [f"tests: expected array, got {type(tests).__name__}"] for i, test in enumerate(tests): prefix = f"tests[{i}]" if not isinstance(test, dict): errors.append(f"{prefix}: expected object") continue if 'action' in test and test['action'] not in ('accept', 'drop'): errors.append(f"{prefix}.action: must be 'accept' or 'drop'") return errors def auto_fix(content: str, errors: List[str]) -> Tuple[str, List[str]]: """ Attempt auto-fixes for common issues. Returns (fixed_content, remaining_errors). """ fixed = content remaining: List[str] = [] # Fix 1: Missing closing brace open_braces = fixed.count('{') close_braces = fixed.count('}') if open_braces > close_braces: fixed += '\n}' * (open_braces - close_braces) remaining.append("Added missing closing braces") # Fix 2: Missing closing bracket open_brackets = fixed.count('[') close_brackets = fixed.count(']') if open_brackets > close_brackets: fixed += '\n]' * (open_brackets - close_brackets) remaining.append("Added missing closing brackets") # Fix 3: Trailing commas (already handled by parser, but fix in source) fixed = re.sub(r',\s*([}\]])', r'\1', fixed) # Fix 4: Remove BOM if fixed.startswith('\ufeff'): fixed = fixed[1:] remaining.append("Removed UTF-8 BOM") # Fix 5: Fix common tag naming errors (uppercase -> lowercase) fixed = re.sub(r'tag:([A-Z])', lambda m: f'tag:{m.group(1).lower()}', fixed) for err in errors: # Check if error was auto-fixable if "JSON parse error" in err: continue # Might be fixed now, will re-validate remaining.append(err) return fixed, remaining def main() -> None: parser = argparse.ArgumentParser( description="Validate huJSON policy files for Headscale/Tailscale", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s --policy policy.hujson %(prog)s --policy policy.hujson --json %(prog)s --policy policy.hujson --fix """ ) parser.add_argument('--policy', '-p', required=True, help='Path to huJSON policy file') parser.add_argument('--json', '-j', action='store_true', help='Output as JSON') parser.add_argument('--fix', '-f', action='store_true', help='Attempt auto-fix of common issues') parser.add_argument('--dry-run', action='store_true', help='Show what fixes would be applied without writing') args = parser.parse_args() policy_path = os.path.expanduser(args.policy) if not os.path.exists(policy_path): error_msg = f"Policy file not found: {policy_path}" if args.json: print(json.dumps({"valid": False, "errors": [error_msg]}, indent=2)) else: print(f"ERROR: {error_msg}", file=sys.stderr) sys.exit(1) with open(policy_path, 'r') as f: content = f.read() parsed, parse_errors = parse_hujson(content) all_errors: List[str] = list(parse_errors) if parsed is not None and isinstance(parsed, dict): # Validate sections grants = parsed.get('grants') if grants is not None: all_errors.extend(validate_grants(grants)) acls = parsed.get('acls') if acls is not None: all_errors.extend(validate_acls(acls)) tag_owners = parsed.get('tagOwners', {}) all_errors.extend(validate_tag_owners(tag_owners, grants or [], acls or [])) auto_approvers = parsed.get('autoApprovers', {}) if auto_approvers: all_errors.extend(validate_auto_approvers(auto_approvers)) ssh = parsed.get('ssh') if ssh is not None: all_errors.extend(validate_ssh(ssh)) tests = parsed.get('tests') if tests is not None: all_errors.extend(validate_tests(tests)) elif parsed is not None and not isinstance(parsed, dict): all_errors.append(f"Policy root must be a JSON object, got {type(parsed).__name__}") # Auto-fix handling fix_notes: List[str] = [] if args.fix and all_errors: fixed_content, remaining_errors = auto_fix(content, all_errors) fix_applied = fixed_content != content if fix_applied and not args.dry_run: with open(policy_path, 'w') as f: f.write(fixed_content) fix_notes.append(f"Applied fixes to {policy_path}") # Re-validate content = fixed_content all_errors = remaining_errors parsed, parse_errors = parse_hujson(content) if parsed and isinstance(parsed, dict): all_errors = list(parse_errors) grants = parsed.get('grants') if grants: all_errors.extend(validate_grants(grants)) acls = parsed.get('acls') if acls: all_errors.extend(validate_acls(acls)) tag_owners = parsed.get('tagOwners', {}) all_errors.extend(validate_tag_owners(tag_owners, grants or [], acls or [])) auto_approvers = parsed.get('autoApprovers', {}) if auto_approvers: all_errors.extend(validate_auto_approvers(auto_approvers)) ssh = parsed.get('ssh') if ssh: all_errors.extend(validate_ssh(ssh)) elif fix_applied and args.dry_run: fix_notes.append("Would apply fixes (use --fix without --dry-run to apply)") is_valid = len(all_errors) == 0 if args.json: output = { "valid": is_valid, "policy": os.path.basename(policy_path), "errors": all_errors, } if fix_notes: output["fixNotes"] = fix_notes print(json.dumps(output, indent=2)) else: if fix_notes: for note in fix_notes: print(note) if is_valid: print(f"✓ Policy '{policy_path}' is valid") else: print(f"✗ Policy '{policy_path}' has {len(all_errors)} issue(s):", file=sys.stderr) for err in all_errors: print(f" • {err}", file=sys.stderr) sys.exit(1) if __name__ == '__main__': main()
-
-
README.md 475 B
# Tailnet Policy ## Why Install This Skill Helps author and validate Headscale-compatible huJSON access policies. ## What You Get | Content | Purpose | |---|---| | `SKILL.md` and `scripts/` | Policy authoring, validation, and deployment guidance | ## Quick Start Use `SKILL.md` to validate a policy before deploying it. ## Triggers Use when changing ACLs, grants, tags, or Tailscale SSH rules. ## Requirements Headscale policy access and huJSON-compatible tooling. -
SKILL.md 7.7 KB
--- name: tailnet-policy description: Author, test, and deploy Tailscale-compatible huJSON policy files for Headscale tailnets — ACLs, Grants, Tags, Auto Approvers, Tailscale SSH rules. Use when configuring access control, writing policy files, or troubleshooting connectivity issues caused by ACLs. metadata: category: devops --- # tailnet-policy ## Overview Headscale uses Tailscale-compatible policy files written in **huJSON** (Human JSON — standard JSON with trailing commas and `//` comments). Policy files control: - **ACLs** (deprecated legacy syntax) — `{action, users, ports}` rules - **Grants** (modern syntax) — `{src, dst, ip, proto, via}` rules - **Tags** — Node identity tags (`tag:dev`, `tag:prod`) - **TagOwners** — Which users/groups can apply which tags - **AutoApprovers** — Auto-approval for subnet routers and exit nodes - **Tailscale SSH** — SSH access rules via `ssh.users` and `ssh.action` ### ACLs vs Grants | Feature | ACLs (legacy) | Grants (modern) | |---------|--------------|-----------------| | Format | `{action: "accept", users: [...], ports: [...]}` | `{src: [...], dst: [...], ip: [...], proto: "tcp"}` | | Port filtering | Embedded in `ports: ["*:*"]` | Separate `ip` field for ports | | Protocol filtering | Not supported | `proto` field (`tcp`, `udp`, `icmp`) | | Destination routing | Not supported | `via` field for relay/exit nodes | | Status | Deprecated by Tailscale | Current recommended syntax | Use Grants wherever possible. The `migrate-acls-to-grants.py` script can convert legacy ACL files automatically. ## Policy Location The policy file path is configured in Headscale's `config.yaml`: ```yaml policy: path: /etc/headscale/policy.hujson ``` After modifying the policy file, reload it on the Headscale server: ```bash # Reload via SIGHUP kill -HUP $(pgrep headscale) # Or use the convenience script ./skills/tailnet-policy/reload-headscale-policy.sh ``` ## Writing Policy ### Allow-All (default-open) ```hujson { // Grants that allow all traffic "grants": [ { "src": ["autogroup:member"], "dst": ["autogroup:member"], "ip": ["*:*"] } ], // Tag ownership "tagOwners": { "tag:dev": ["autogroup:admin"], "tag:prod": ["autogroup:admin"] } } ``` ### Deny-All (default-closed) ```hujson { "grants": [ // Only allow ICMP (ping) between all members { "src": ["autogroup:member"], "dst": ["autogroup:member"], "ip": ["*"], "proto": "icmp" } ], // Specific grants added per-service "tagOwners": { "tag:monitor": ["autogroup:admin"] } } ``` ### Segmented (environments) ```hujson { "grants": [ // Dev can reach dev { "src": ["tag:dev"], "dst": ["tag:dev"], "ip": ["*:*"] }, // Prod can reach prod { "src": ["tag:prod"], "dst": ["tag:prod"], "ip": ["*:*"] }, // Admin access to all { "src": ["autogroup:admin"], "dst": ["tag:dev", "tag:prod"], "ip": ["*:*"] } ], "tagOwners": { "tag:dev": ["autogroup:admin"], "tag:prod": ["autogroup:admin"] } } ``` ### Tag-Based Patterns Tags are node-level identifiers set via `tailscale up --advertise-tags=tag:dev`. They decouple policy from user identity. ```hujson { "tagOwners": { "tag:ci-runner": ["autogroup:admin"], "tag:database": ["autogroup:admin"], "tag:webserver": ["autogroup:admin"], "tag:monitoring": ["autogroup:admin"] }, "grants": [ { "src": ["tag:monitoring"], "dst": ["tag:webserver", "tag:database"], "ip": ["*:*"] }, { "src": ["tag:webserver"], "dst": ["tag:database"], "ip": ["tcp:5432"] } ] } ``` ## Grants Syntax Grants are the modern policy primitive: ```hujson { "grants": [ { "src": ["tag:source", "user@example.com"], "dst": ["tag:destination", "100.64.0.1"], "ip": ["*:*"], // proto:port — "*:*" means all "proto": "tcp", // optional protocol filter "via": ["tag:exit-node"] // optional via/routing } ] } ``` Fields: - **`src`** — Source entities (tags, users, autogroups, IPs) - **`dst`** — Destination entities - **`ip`** — Protocol and port filter (e.g. `tcp:80`, `udp:53`, `*:*`, `*`) - **`proto`** — Protocol constraint (`tcp`, `udp`, `icmp`) - **`via`** — Route through a specific exit node or relay ## Auto Approvers Auto-approvers let specific users approve subnet routes and exit nodes without manual intervention: ```hujson { "autoApprovers": { "routes": { "10.0.0.0/8": ["autogroup:admin"], "172.16.0.0/12": ["alice@example.com"] }, "exitNode": ["autogroup:admin"] } } ``` - `routes`: Maps CIDR ranges to lists of users who can auto-approve those routes - `exitNode`: Lists users who can advertise exit nodes ## Autogroups Autogroups are dynamic groups resolved by Headscale/Tailscale at runtime: | Autogroup | Description | |-----------|-------------| | `autogroup:member` | All tailnet members | | `autogroup:admin` | Tailnet admins | | `autogroup:tagged` | All tagged nodes (any node with at least one tag) | | `autogroup:internet` | The public internet (used for exit node routing) | ## Tailscale SSH Configuration Tailscale SSH rules are configured via the `ssh` section: ```hujson { "ssh": [ { "action": "accept", // "accept" or "check" "src": ["autogroup:admin"], "dst": ["tag:webserver"], "users": ["root", "ubuntu"] }, { "action": "check", // "check" requires node-level SSH authorization "src": ["autogroup:member"], "dst": ["tag:dev"], "users": ["*"] } ] } ``` - `action`: `"accept"` (allow directly) or `"check"` (require node-level auth) - `src`: Source users/groups - `dst`: Destination tags/users - `users`: Which OS users can be SSH'd into ## Testing Policies Policy files include test definitions that are validated when loaded: ```hujson { "grants": [...], "tests": [ { "src": "alice@example.com", "dst": "tag:webserver", "ip": ["tcp:443"], "action": "accept" // expected result }, { "src": "bob@example.com", "dst": "tag:database", "ip": ["tcp:22"], "action": "drop" // expected result } ] } ``` Validate tests with: ```bash ./skills/tailnet-policy/validate-policy.py --policy policy.hujson ``` ## Gotchas - **Headscale does NOT support device posture** — rules like `devicePosture` or `device:managed` are Tailscale-only - **Headscale does NOT support IP sets** — `ipSets` and `ipprotocol` are not supported - **Headscale does NOT support OIDC groups in ACLs** — groups from OIDC claims cannot be used in policy; use `autogroup:admin` and `autogroup:member` instead - **Tag names must start with `tag:`** and contain only lowercase letters, numbers, and hyphens - **Policy reload via SIGHUP does not return errors on failure** — always validate before reloading - **ACL `users` field and Grant `src` field are NOT interchangeable** — grants use `src`/`dst`, legacy ACLs use `users`/`ports` - **Tests are not enforced at runtime** — they only validate during parsing; a passing test does not guarantee runtime behavior ## Trigger Conditions This skill is automatically loaded when the user's message contains any of these keywords: - tailnet policy - headscale acl - hujson policy - tailscale grant - tagowners - autoapprovers - tailscale ssh policy - policy validation - migrate acls - /etc/headscale/policy - policy.hujson ## When not to use Do not use this skill for deploying the Headscale server (load `headscale-deploy` instead) or for client connectivity issues unrelated to access control (load `tailscale-client`). It covers huJSON policy authoring and testing only.
-
-
tailscale-client
-
evals
-
evals.json 5.3 KB
{ "schema_version": 1, "skill_name": "tailscale-client", "evals": [ { "id": "client-install-platform", "prompt": "Install the Tailscale client on a new Debian/Ubuntu workstation so we can join it to our self-hosted headscale tailnet.", "expected_output": "Scenario: client installation. The agent loads tailscale-client and installs the official Tailscale client for Debian/Ubuntu (adding the apt repository and installing the tailscale package, or using ts-install.sh for automated detection). The output confirms the client and tailscaled daemon are installed and that the daemon is started so CLI commands work.", "assertions": [ "The tailscale-client sub-skill is loaded", "The client is installed via the correct platform method", "The tailscaled daemon is confirmed running", "The installed client version is reported" ] }, { "id": "connect-to-headscale", "prompt": "Connect this laptop to our self-hosted headscale server at https://headscale.example.com non-interactively using a pre-auth key, tagged as a laptop.", "expected_output": "Scenario: client connection to a self-hosted control server. The agent runs tailscale up with --login-server=https://headscale.example.com and an auth key, optionally with --advertise-tags, so the device authenticates to headscale rather than Tailscale's SaaS. The output confirms the device registered and appears in the tailnet (or notes the pending-approval path). The output uses the headscale login-server flag, not the default Tailscale control plane.", "assertions": [ "tailscale up uses --login-server pointing at the headscale URL", "A pre-auth key is used for non-interactive registration", "Tags are advertised where applicable", "Registration with headscale is confirmed" ] }, { "id": "diagnostics-interpretation", "prompt": "A user says they can reach some peers but not others and it's slow. Run diagnostics on their machine and tell me what's wrong from the output.", "expected_output": "Scenario: diagnostic collection and interpretation. The agent runs ts-diagnostics.sh (collecting tailscale status --json, ping, netcheck, version) and interprets the results with ts-connectivity-report.py, translating raw JSON into a readable verdict. It distinguishes direct peer connections from DERP relay paths (looking for relay vs direct tx/rx), reports NAT/port-mapping findings, and gives a concrete remediation.", "assertions": [ "ts-diagnostics.sh collects status, ping, netcheck, and version output", "The connectivity report is interpreted into a readable verdict", "Direct vs DERP relay paths are distinguished in the output", "A concrete remediation is given for the diagnosed problem" ] }, { "id": "derp-fallback-troubleshoot", "prompt": "Our peer-to-peer connections keep falling back to a DERP relay and everything is slow. How can I confirm that's happening and what can we do about it?", "expected_output": "Scenario: diagnosing DERP-only fallback. The agent checks tailscale status --json and ping --verbose to determine whether peers are connected via a relay instead of a direct path, explaining that when NAT traversal fails peers connect only through DERP with added latency. The output suggests checking netcheck for UDP/STUN/NAT issues and remediation (fix NAT traversal, or accept relay latency if traversal can't be fixed).", "assertions": [ "The relay-vs-direct path is confirmed in tailscale status/ping output", "The cause (NAT traversal failure) is investigated with netcheck", "The latency cost of DERP fallback is explained", "A remediation or acceptance decision is given" ] }, { "id": "magicdns-setup", "prompt": "We want to reach tailnet nodes by hostname instead of raw 100.x IPs. Configure our client to use MagicDNS.", "expected_output": "Scenario: MagicDNS configuration. The agent enables --accept-dns on tailscale up (or re-auths with the flag) so nodes resolve by hostname under the base domain. The output explains that without --accept-dns, nodes are only reachable by Tailscale IP, and verifies name resolution works.", "assertions": [ "--accept-dns is enabled for the client", "The output explains MagicDNS hostname vs raw-IP resolution", "Hostname resolution of a tailnet node is verified" ] }, { "id": "serve-local-service", "prompt": "Expose a local dev web service running on port 8080 of this machine to other tailnet devices using Tailscale Serve.", "expected_output": "Scenario: exposing a local service via Tailscale Serve. The agent runs tailscale serve to expose the local 8080 service on the tailnet, checks for port conflicts (lsof -i :8080) since Serve often uses 8080/8443, and confirms other tailnet devices can reach the exposed URL. The output reports the served URL and notes Serve is supported with headscale (unlike Funnel).", "assertions": [ "tailscale serve is configured to expose the local 8080 service", "Port conflicts are checked and resolved", "The exposed tailnet URL is reported", "The headscale compatibility of Serve is acknowledged" ] } ] }
-
-
scripts
-
ts-connectivity-report.py 15.8 KB
#!/usr/bin/env python3 """ ts-connectivity-report.py — Interpret Tailscale diagnostics into a structured report. Reads the JSON output from ts-diagnostics.sh and produces a structured report analyzing peer connectivity, path types (direct vs DERP relay), latency tiers, exit node status, and MagicDNS health. Usage: ./ts-connectivity-report.py --diagnostics <diagnostics.json> ./ts-connectivity-report.py --diagnostics diagnostics.json --json Options: --diagnostics <path> Path to JSON diagnostics file from ts-diagnostics.sh --json Output results in JSON format --help Show this help message and exit """ import json import sys import os import re from datetime import datetime def usage(): print(__doc__.strip()) sys.exit(0) def parse_args(): args = { "diagnostics": None, "json_output": False, } i = 1 while i < len(sys.argv): if sys.argv[i] == "--diagnostics" and i + 1 < len(sys.argv): args["diagnostics"] = sys.argv[i + 1] i += 2 elif sys.argv[i] == "--json": args["json_output"] = True i += 1 elif sys.argv[i] == "--help": usage() else: print(f"Unknown option: {sys.argv[i]}", file=sys.stderr) usage() return args def load_diagnostics(path): """Load and validate the diagnostics JSON file.""" if not os.path.exists(path): print(f"Error: diagnostics file not found: {path}", file=sys.stderr) sys.exit(1) try: with open(path, "r") as f: data = json.load(f) except json.JSONDecodeError as e: print(f"Error: invalid JSON in diagnostics file: {e}", file=sys.stderr) sys.exit(1) return data def analyze_peers(diagnostics): """Analyze each peer for connectivity type and health.""" peer_analysis = diagnostics.get("peer_analysis", []) status = diagnostics.get("status", {}) peers_raw = status.get("Peer", {}) results = [] for peer in peer_analysis: hostname = peer.get("hostname", "unknown") dns_name = peer.get("dns_name", "") ips = peer.get("tailscale_ips", []) online = peer.get("online", False) primary_path = peer.get("primary_path", "unknown") relay_server = peer.get("relay_server", "") tx = peer.get("tx_bytes", 0) rx = peer.get("rx_bytes", 0) entry = { "hostname": hostname, "dns_name": dns_name, "tailscale_ips": ips, "online": online, "path_type": primary_path, "relay_server": relay_server, "traffic_bytes": {"tx": tx, "rx": rx}, "latency_ms": None, "health": "unknown", } # Determine health if not online: entry["health"] = "offline" elif primary_path == "direct": entry["health"] = "healthy" elif primary_path == "relay": entry["health"] = "derp_relay" else: entry["health"] = "unknown" # Check for ping results matching this peer ping_results = diagnostics.get("ping_results", []) for ping in ping_results: target = ping.get("target", "") if ( target in ips or target == hostname or target == hostname + "." + dns_name ): entry["latency_ms"] = ping.get("latency_ms") if ping.get("path_type") == "direct": entry["path_type"] = "direct" if entry["health"] == "derp_relay": entry["health"] = "healthy" # real-time ping confirms direct # Latency tier lat = entry["latency_ms"] if lat is not None: if lat < 10: entry["latency_tier"] = "green" elif lat < 50: entry["latency_tier"] = "yellow" else: entry["latency_tier"] = "red" else: entry["latency_tier"] = "unknown" results.append(entry) return results def analyze_netcheck(diagnostics): """Analyze netcheck results.""" netcheck = diagnostics.get("netcheck", {}) if isinstance(netcheck, str): return {"raw_output": netcheck} report = { "udp_enabled": netcheck.get("UDP", True), "ipv4": netcheck.get("IPv4", False), "ipv6": netcheck.get("IPv6", False), "mapping_varies_by_dest": netcheck.get("MappingVariesByDest", None), "hair_pinning": netcheck.get("HairPinning", None), "captive_portal": netcheck.get("CaptivePortal", False), "global_derp_latency": {}, "preferred_derp": netcheck.get("PreferredDERP", None), } # Parse DERP region latencies derp_map = netcheck.get("DERPMap", {}) regions = derp_map.get("Regions", []) if isinstance(derp_map, dict) else [] if isinstance(derp_map, dict): regions_obj = derp_map.get("Regions", {}) if isinstance(regions_obj, dict): for region_id, region in regions_obj.items(): if isinstance(region, dict): report["global_derp_latency"][region.get("RegionName", f"Region{region_id}")] = { "region_id": region_id, "latency_ms": region.get("Latency", {}), } # Also check top-level latency keys latency = netcheck.get("Latency", {}) if isinstance(latency, dict): for region_name, lat_data in latency.items(): if region_name not in report["global_derp_latency"]: report["global_derp_latency"][region_name] = lat_data return report def analyze_exit_nodes(peer_analysis): """Check if any peers are exit nodes.""" # This is a best-effort analysis from diagnostics data exit_nodes = [p for p in peer_analysis if "exit" in p.get("hostname", "").lower()] return { "exit_nodes_found": len(exit_nodes), "exit_nodes": exit_nodes, } def analyze_magicdns(diagnostics): """Check MagicDNS health based on DNS names.""" peer_analysis = diagnostics.get("peer_analysis", []) self_info = diagnostics.get("self", {}) dns_names = [] for peer in peer_analysis: dns = peer.get("dns_name", "") if dns: dns_names.append(dns) self_dns = self_info.get("dns_name", "") # MagicDNS is considered healthy if we have DNS names with a tailnet suffix has_dns_names = len(dns_names) > 0 or bool(self_dns) return { "magicdns_enabled": has_dns_names, "peers_with_dns": len(dns_names), "self_dns_name": self_dns, "sample_dns_names": dns_names[:5] if dns_names else [], "status": "healthy" if has_dns_names else "not_configured", } def analyze_version(diagnostics): """Analyze version consistency.""" version = diagnostics.get("version", {}) client = version.get("client", "") daemon = version.get("daemon", "") version_match = client == daemon or not daemon return { "client_version": client, "daemon_version": daemon, "versions_match": version_match, "status": "consistent" if version_match else "mismatch", } def generate_summary(peer_analysis, netcheck_report, magicdns, version_report): """Generate a summary with key findings.""" total_peers = len(peer_analysis) online_peers = sum(1 for p in peer_analysis if p.get("online")) offline_peers = sum(1 for p in peer_analysis if not p.get("online")) direct_peers = sum(1 for p in peer_analysis if p.get("path_type") == "direct") relay_peers = sum(1 for p in peer_analysis if p.get("path_type") == "relay") green_peers = sum(1 for p in peer_analysis if p.get("latency_tier") == "green") yellow_peers = sum(1 for p in peer_analysis if p.get("latency_tier") == "yellow") red_peers = sum(1 for p in peer_analysis if p.get("latency_tier") == "red") issues = [] if offline_peers > 0: offline_names = [ p["hostname"] for p in peer_analysis if not p.get("online") ] issues.append( f"Offline peers ({offline_peers}): {', '.join(offline_names)}" ) if relay_peers > 0: relay_names = [ p["hostname"] for p in peer_analysis if p.get("path_type") == "relay" ] issues.append( f"Peers on DERP relay ({relay_peers}): {', '.join(relay_names)}. " "NAT traversal may need investigation." ) if red_peers > 0: red_names = [ p["hostname"] for p in peer_analysis if p.get("latency_tier") == "red" ] issues.append( f"High-latency peers ({red_peers}): {', '.join(red_names)}" ) if not netcheck_report.get("udp_enabled", True): issues.append("UDP is blocked or disabled — DERP relay may be the only option") if not magicdns.get("magicdns_enabled"): issues.append( "MagicDNS not configured or no DNS names found. " "Run tailscale up with --accept-dns to enable." ) if not version_report.get("versions_match"): issues.append( f"Version mismatch: client={version_report['client_version']}, " f"daemon={version_report['daemon_version']}" ) summary = { "timestamp": datetime.utcnow().isoformat() + "Z", "total_peers": total_peers, "online_peers": online_peers, "offline_peers": offline_peers, "direct_connections": direct_peers, "relay_connections": relay_peers, "latency_tiers": { "green": green_peers, "yellow": yellow_peers, "red": red_peers, }, "issues": issues, "health": "healthy" if len(issues) == 0 else "degraded", } return summary def format_human_report( summary, peer_analysis, netcheck_report, magicdns, version_report, exit_node_report ): """Format a human-readable report.""" lines = [] lines.append("=" * 60) lines.append(" Tailscale Connectivity Report") lines.append(f" Generated: {summary['timestamp']}") lines.append("=" * 60) lines.append("") # Summary lines.append("--- Summary ---") health_color = ( "\033[32mhealthy\033[0m" if summary["health"] == "healthy" else "\033[31mdegraded\033[0m" ) lines.append(f" Overall Health: {health_color}") lines.append( f" Peers: {summary['online_peers']}/{summary['total_peers']} online " f"({summary['offline_peers']} offline)" ) lines.append( f" Connections: {summary['direct_connections']} direct, " f"{summary['relay_connections']} via DERP relay" ) lines.append(" Latency Tiers:") lines.append(f" \033[32m<10ms (green):\033[0m {summary['latency_tiers']['green']}") lines.append(f" \033[33m10-50ms (yellow):\033[0m {summary['latency_tiers']['yellow']}") lines.append(f" \033[31m>50ms (red):\033[0m {summary['latency_tiers']['red']}") lines.append("") # Issues if summary["issues"]: lines.append("\033[31m--- Issues Found ---\033[0m") for issue in summary["issues"]: lines.append(f" ⚠ {issue}") lines.append("") else: lines.append("\033[32mNo issues found.\033[0m") lines.append("") # Peer Details lines.append("--- Peer Details ---") tier_color_map = { "green": "\033[32m", "yellow": "\033[33m", "red": "\033[31m", "unknown": "\033[90m", } for peer in peer_analysis: hostname = peer.get("hostname", "?") ips = ", ".join(peer.get("tailscale_ips", [])) online = "\033[32mONLINE\033[0m" if peer.get("online") else "\033[31mOFFLINE\033[0m" path = peer.get("path_type", "?") if path == "direct": path_str = "\033[32mdirect\033[0m" elif path == "relay": relay = peer.get("relay_server", "") path_str = f"\033[33mrelay ({relay})\033[0m" else: path_str = f"\033[90m{path}\033[0m" lat = peer.get("latency_ms") tier = peer.get("latency_tier", "unknown") lat_color = tier_color_map.get(tier, "\033[90m") lat_str = f"{lat_color}{lat}ms\033[0m" if lat is not None else "\033[90m?\033[0m" lines.append(f" {hostname:25} {ips:20} {online:8} Path: {path_str:20} Latency: {lat_str}") lines.append("") # Netcheck lines.append("--- Netcheck ---") lines.append(f" UDP enabled: {netcheck_report.get('udp_enabled', '?')}") lines.append(f" IPv4: {netcheck_report.get('ipv4', '?')}") lines.append(f" IPv6: {netcheck_report.get('ipv6', '?')}") lines.append( f" Captive portal: {netcheck_report.get('captive_portal', '?')}" ) pref_derp = netcheck_report.get("preferred_derp") lines.append(f" Preferred DERP: {pref_derp if pref_derp else 'none'}") if netcheck_report.get("global_derp_latency"): lines.append(" DERP Latencies:") for region, lat_data in netcheck_report["global_derp_latency"].items(): if isinstance(lat_data, dict): lat_val = lat_data.get("latency_ms", "?") elif isinstance(lat_data, (int, float)): lat_val = lat_data else: lat_val = "?" lines.append(f" {region}: {lat_val}ms") lines.append("") # MagicDNS lines.append("--- MagicDNS ---") mdns_status = magicdns.get("status", "unknown") mdns_color = "\033[32m" if mdns_status == "healthy" else "\033[33m" lines.append(f" Status: {mdns_color}{mdns_status}\033[0m") lines.append(f" Peers w/ DNS: {magicdns.get('peers_with_dns', 0)}") lines.append(f" Self DNS: {magicdns.get('self_dns_name', 'none')}") lines.append("") # Version lines.append("--- Version ---") ver_match = version_report.get("status", "unknown") ver_color = "\033[32m" if ver_match == "consistent" else "\033[31m" lines.append(f" Client: {version_report.get('client_version', '?')}") lines.append(f" Daemon: {version_report.get('daemon_version', '?')}") lines.append(f" Match: {ver_color}{ver_match}\033[0m") lines.append("") # Exit Nodes lines.append("--- Exit Nodes ---") if exit_node_report.get("exit_nodes_found", 0) > 0: for en in exit_node_report.get("exit_nodes", []): lines.append(f" {en.get('hostname', '?')} — {', '.join(en.get('tailscale_ips', []))}") else: lines.append(" No exit nodes detected from available data.") lines.append("") lines.append("=" * 60) return "\n".join(lines) def main(): args = parse_args() if not args["diagnostics"]: print("Error: --diagnostics <path> is required", file=sys.stderr) usage() diagnostics = load_diagnostics(args["diagnostics"]) peer_analysis = analyze_peers(diagnostics) netcheck_report = analyze_netcheck(diagnostics) exit_node_report = analyze_exit_nodes(peer_analysis) magicdns_report = analyze_magicdns(diagnostics) version_report = analyze_version(diagnostics) summary = generate_summary( peer_analysis, netcheck_report, magicdns_report, version_report ) if args["json_output"]: output = { "summary": summary, "peers": peer_analysis, "netcheck": netcheck_report, "magicdns": magicdns_report, "version": version_report, "exit_nodes": exit_node_report, } print(json.dumps(output, indent=2)) else: print( format_human_report( summary, peer_analysis, netcheck_report, magicdns_report, version_report, exit_node_report, ) ) if __name__ == "__main__": main() -
ts-diagnostics.sh 9.7 KB
#!/usr/bin/env bash # ============================================================================= # ts-diagnostics.sh — Comprehensive Tailscale Connectivity Diagnostics # ============================================================================= # Description: # Runs a comprehensive diagnostic bundle: tailscale status, ping, netcheck, # and version. Produces structured JSON or human-readable output with path # analysis (direct vs. DERP relay). # # Usage: # ./ts-diagnostics.sh [options] # # Options: # --peer <IP|hostname> Specific peer to test (ping target) # --json Output results in JSON format # --help Show this help message and exit # # Examples: # ./ts-diagnostics.sh # ./ts-diagnostics.sh --peer 100.64.0.2 # ./ts-diagnostics.sh --peer my-server --json > diagnostics.json # ============================================================================= set -euo pipefail # ---- Colors ---- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' info() { echo -e "${BLUE}[INFO]${NC} $*"; } success() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } usage() { grep "^# " "$0" | sed 's/^# //' | sed 's/^#//' exit 0 } # ---- Defaults ---- PEER="" JSON_OUTPUT=false # ---- Parse Arguments ---- while [[ $# -gt 0 ]]; do case "$1" in --peer) PEER="$2"; shift 2 ;; --json) JSON_OUTPUT=true; shift ;; --help) usage ;; *) error "Unknown option: $1"; usage ;; esac done # ---- Verify prerequisites ---- if ! command -v tailscale &>/dev/null; then error "tailscale command not found" exit 1 fi # ---- Collect Diagnostics ---- info "Collecting Tailscale diagnostics..." # 1. tailscale status --json STATUS_JSON="" STATUS_TEXT="" if STATUS_RAW=$(tailscale status --json 2>/dev/null); then STATUS_JSON="$STATUS_RAW" STATUS_TEXT=$(echo "$STATUS_RAW" | python3 -m json.tool 2>/dev/null || echo "$STATUS_RAW") else STATUS_JSON='{"error":"tailscale status failed"}' STATUS_TEXT="tailscale status: FAILED" fi info " ✓ Status collected" # 2. tailscale version VERSION_RAW=$(tailscale version 2>/dev/null || echo "unknown") VERSION_CLIENT=$(echo "$VERSION_RAW" | sed -n '1p') VERSION_DAEMON=$(echo "$VERSION_RAW" | sed -n '2p') VERSION_COMMIT=$(echo "$VERSION_RAW" | sed -n '3p') info " ✓ Version collected" # 3. tailscale netcheck NETCHECK_JSON="" if NETCHECK_RAW=$(tailscale netcheck 2>/dev/null); then NETCHECK_JSON=$(echo "$NETCHECK_RAW" | head -50) else NETCHECK_JSON='{"error":"tailscale netcheck failed"}' fi info " ✓ Netcheck collected" # 4. tailscale ping to peer(s) PING_RESULTS="[]" if [[ -n "$PEER" ]]; then if PING_RAW=$(tailscale ping --verbose -c 3 "$PEER" 2>&1); then PING_PARSED=$(echo "$PING_RAW" | python3 -c " import sys, json lines = sys.stdin.read().strip().split('\n') results = [] for line in lines: parts = line.split() entry = {'raw': line} if 'via' in line: entry['type'] = 'relay' if 'DERP' in line else 'direct' if 'pong' in line: entry['type'] = 'pong' for i, p in enumerate(parts): if 'from' in p and i+1 < len(parts): entry['target'] = parts[i+1] if 'latency' in line: import re m = re.search(r'[\\d.]+ms', line) if m: entry['latency_ms'] = m.group() results.append(entry) print(json.dumps(results)) " 2>/dev/null || echo "[{\"raw\": \"${PING_RAW}\"}]") PING_RESULTS=$(echo "$PING_RAW" | python3 -c " import sys, json lines = sys.stdin.read().strip().split('\n') results = [] for line in lines: parts = line.split() entry = {'raw': line} # Detect path type if 'via' in line.lower() or 'relay' in line.lower() or 'derp' in line.lower(): entry['path_type'] = 'relay' for i, p in enumerate(parts): if p.lower() == 'via' and i+1 < len(parts): entry['via'] = parts[i+1] if p.lower() == 'derp' and i+1 < len(parts): entry['derp_server'] = parts[i+1] elif 'pong' in line.lower(): entry['path_type'] = 'direct' if 'pong' in line.lower(): entry['type'] = 'pong' for i, p in enumerate(parts): if p == 'from' and i+1 < len(parts): entry['target'] = parts[i+1].rstrip(':') import re m = re.search(r'([\\d.]+)ms', line) if m: entry['latency_ms'] = float(m.group(1)) results.append(entry) print(json.dumps(results, indent=2)) " 2>/dev/null || echo "[{\"raw\": \"ping command failed\"}]") fi info \" ✓ Ping to ${PEER} collected\" fi # ---- Analyze peers for direct vs DERP relay ---- PEER_ANALYSIS="[]" if [[ -n "$STATUS_JSON" ]]; then PEER_ANALYSIS=$(echo "$STATUS_JSON" | python3 -c " import sys, json data = json.load(sys.stdin) peers = data.get('Peer', {}) status_map = data.get('Status', '').lower() results = [] for node_id, node_info in peers.items(): entry = { 'id': node_id, 'hostname': node_info.get('HostName', ''), 'dns_name': node_info.get('DNSName', ''), 'tailscale_ips': node_info.get('TailscaleIPs', []), 'os': node_info.get('OS', ''), 'online': node_info.get('Online', False), 'relay': node_info.get('Relay', ''), 'tx_bytes': node_info.get('TxBytes', 0), 'rx_bytes': node_info.get('RxBytes', 0), 'cur_connection': node_info.get('CurConnection', ''), 'keep_alive': node_info.get('KeepAlive', False) } # Determine primary path if node_info.get('Relay'): entry['primary_path'] = 'relay' entry['relay_server'] = node_info['Relay'] elif node_info.get('CurConnection') == 'direct': entry['primary_path'] = 'direct' elif node_info.get('TxBytes', 0) > 0 or node_info.get('RxBytes', 0) > 0: # Has traffic but no relay — likely direct entry['primary_path'] = 'direct' else: entry['primary_path'] = 'unknown' results.append(entry) print(json.dumps(results, indent=2)) " 2>/dev/null || echo "$PEER_ANALYSIS") fi # ---- Self info ---- SELF_INFO="{}" if [[ -n "$STATUS_JSON" ]]; then SELF_INFO=$(echo "$STATUS_JSON" | python3 -c " import sys, json data = json.load(sys.stdin) self_info = data.get('Self', {}) self_info = { 'hostname': self_info.get('HostName', ''), 'dns_name': self_info.get('DNSName', ''), 'tailscale_ips': self_info.get('TailscaleIPs', []), 'online': self_info.get('Online', False), 'os': self_info.get('OS', ''), 'relay': self_info.get('Relay', ''), 'cur_connection': self_info.get('CurConnection', ''), 'id': list(data.get('Self', {}).keys())[0] if len(data.get('Self', {})) <= 1 else None } if 'Self' in data: s = data['Self'] if isinstance(s, dict) and 'ID' in s: self_info['id'] = s['ID'] print(json.dumps(self_info, indent=2)) " 2>/dev/null || echo '{}') fi # ---- Output ---- if [[ "$JSON_OUTPUT" == "true" ]]; then cat <<EOF { "version": { "client": "$(echo "$VERSION_CLIENT" | sed 's/"/\\"/g')", "daemon": "$(echo "$VERSION_DAEMON" | sed 's/"/\\"/g')", "commit": "$(echo "$VERSION_COMMIT" | sed 's/"/\\"/g')" }, "self": $SELF_INFO, "status": $STATUS_JSON, "peer_analysis": $PEER_ANALYSIS, "ping_results": $PING_RESULTS, "netcheck": $(echo "$NETCHECK_JSON" | python3 -c " import sys, json try: d = json.loads(sys.stdin.read().strip() if sys.stdin.read().strip() else '{}') except: d = {'raw_output': sys.stdin.read().strip() if sys.stdin.read().strip() else 'unavailable'} print(json.dumps(d, indent=2)) " 2>/dev/null || echo '"unavailable"') } EOF else echo "" echo "============================================" echo " Tailscale Diagnostics Report" echo "============================================" echo "" echo "--- Version ---" echo " Client: ${VERSION_CLIENT:-unknown}" echo " Daemon: ${VERSION_DAEMON:-unknown}" if [[ -n "$VERSION_COMMIT" ]]; then echo " Commit: ${VERSION_COMMIT}" fi echo "" echo "--- Self ---" echo "$SELF_INFO" | python3 -c " import sys, json d = json.load(sys.stdin) for k, v in d.items(): if isinstance(v, list): print(f' {k}: {', '.join(str(x) for x in v)}') else: print(f' {k}: {v}') " 2>/dev/null || echo " (unavailable)" echo "" echo "--- Peer Analysis ---" echo "$PEER_ANALYSIS" | python3 -c " import sys, json peers = json.load(sys.stdin) for p in peers: path_str = 'DIRECT' if p.get('primary_path') == 'direct' else ('RELAY (' + p.get('relay_server', '?') + ')' if p.get('primary_path') == 'relay' else 'UNKNOWN') online_str = 'ONLINE' if p.get('online') else 'OFFLINE' ips = ', '.join(p.get('tailscale_ips', [])) print(f' {p[\"hostname\"] + \".\" + p.get(\"dns_name\", \"\"):30} {ips:20} {online_str:8} Path: {path_str}') " 2>/dev/null || echo " (no peers or unavailable)" echo "" if [[ -n "$PEER" ]]; then echo "--- Ping: ${PEER} ---" echo "$PING_RESULTS" | python3 -c " import sys, json results = json.load(sys.stdin) for r in results: path = r.get('path_type', 'unknown') lat = r.get('latency_ms', '?') via = r.get('via', '') if path == 'direct': print(f' DIRECT latency={lat}ms') elif path == 'relay': print(f' RELAY via={via} latency={lat}ms') else: print(f' {r.get(\"raw\", \"?\")}') " 2>/dev/null || echo " (ping output unavailable)" echo "" fi echo "--- Netcheck ---" echo "$NETCHECK_JSON" | head -15 echo "" echo "--- Status (summary) ---" tailscale status 2>/dev/null | head -30 || echo " (unavailable)" echo "" echo "============================================" fi -
ts-install.sh 9.3 KB
#!/usr/bin/env bash # ============================================================================= # ts-install.sh — Tailscale Client Installation Script # ============================================================================= # Description: # Auto-detect the operating system and install the official Tailscale client. # Supports Debian/Ubuntu (apt), macOS (brew), Windows (choco), # Fedora/RHEL (yum/dnf), and Alpine (apk). # # Usage: # ./ts-install.sh [options] # # Options: # --login-server <URL> Headscale server URL to configure after install # --authkey <key> Pre-authentication key for non-interactive auth # --dry-run Preview actions without executing them # --json Output results in JSON format # --help Show this help message and exit # # Environment: # HEADSCALE_URL Default --login-server value # TAILSCALE_AUTHKEY Default --authkey value # # Examples: # ./ts-install.sh --login-server https://headscale.example.com # ./ts-install.sh --login-server https://headscale.example.com --authkey tskey-auth-xxxxx # ./ts-install.sh --dry-run --json # HEADSCALE_URL=https://headscale.example.com ./ts-install.sh # ============================================================================= set -euo pipefail # ---- Colors / Formatting ---- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # ---- Defaults ---- LOGIN_SERVER="" AUTHKEY="" DRY_RUN=false JSON_OUTPUT=false # ---- Functions ---- info() { echo -e "${BLUE}[INFO]${NC} $*"; } success() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } usage() { grep "^# " "$0" | sed 's/^# //' | sed 's/^#//' exit 0 } json_escape() { echo "$1" | sed 's/"/\\"/g' } json_output() { local status="$1" local message="$2" local platform="$3" local version="$4" cat <<EOF { "status": "$(json_escape "$status")", "message": "$(json_escape "$message")", "platform": "$(json_escape "$platform")", "version": "$(json_escape "$version")", "login_server": "$(json_escape "${LOGIN_SERVER:-not-configured}")" } EOF } # ---- Parse Arguments ---- while [[ $# -gt 0 ]]; do case "$1" in --login-server) LOGIN_SERVER="$2" shift 2 ;; --authkey) AUTHKEY="$2" shift 2 ;; --dry-run) DRY_RUN=true shift ;; --json) JSON_OUTPUT=true shift ;; --help) usage ;; *) error "Unknown option: $1" usage ;; esac done # Apply env var defaults LOGIN_SERVER="${LOGIN_SERVER:-${HEADSCALE_URL:-}}" AUTHKEY="${AUTHKEY:-${TAILSCALE_AUTHKEY:-}}" # ---- Detect Platform ---- detect_platform() { if command -v apt-get &>/dev/null || [[ -f /etc/debian_version ]]; then echo "debian" elif command -v brew &>/dev/null && [[ "$(uname)" == "Darwin" ]]; then echo "macos" elif command -v choco &>/dev/null && [[ "$(uname -s)" == MINGW* || "$(uname -s)" == CYGWIN* ]]; then echo "windows" elif command -v dnf &>/dev/null; then echo "fedora" elif command -v yum &>/dev/null; then echo "rhel" elif command -v apk &>/dev/null; then echo "alpine" elif command -v brew &>/dev/null && [[ "$(uname)" == "Linux" ]]; then echo "linuxbrew" else echo "unknown" fi } PLATFORM=$(detect_platform) # ---- Check if already installed ---- check_installed_version() { if command -v tailscale &>/dev/null; then tailscale version 2>/dev/null | head -1 else echo "" fi } EXISTING_VERSION=$(check_installed_version) # ---- Dry-Run Mode ---- if [[ "$DRY_RUN" == "true" ]]; then INSTALL_CMD="" case "$PLATFORM" in debian) INSTALL_CMD="curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | sudo tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null && curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | sudo tee /etc/apt/sources.list.d/tailscale.list && sudo apt-get update && sudo apt-get install -y tailscale" ;; macos) INSTALL_CMD="brew install tailscale" ;; windows) INSTALL_CMD="choco install tailscale" ;; fedora) INSTALL_CMD="sudo dnf install -y tailscale" ;; rhel) INSTALL_CMD="sudo yum install -y tailscale" ;; alpine) INSTALL_CMD="apk add tailscale" ;; *) INSTALL_CMD="UNKNOWN_PLATFORM" ;; esac if [[ "$JSON_OUTPUT" == "true" ]]; then json_output "dry-run" "Platform detected: ${PLATFORM}. Would install tailscale. Existing version: ${EXISTING_VERSION:-none}" "${PLATFORM}" "${EXISTING_VERSION:-}" else info "Dry-run mode — no changes will be made" info "Detected platform: ${PLATFORM}" info "Tailscale already installed: ${EXISTING_VERSION:-no}" info "Install command: ${INSTALL_CMD}" if [[ -n "$LOGIN_SERVER" ]]; then info "Would configure --login-server: ${LOGIN_SERVER}" fi if [[ -n "$AUTHKEY" ]]; then info "Would authenticate with --authkey: ${AUTHKEY:0:16}..." fi fi exit 0 fi # ---- Proceed with Installation ---- INSTALL_NEEDED=false if [[ -n "$EXISTING_VERSION" ]]; then success "Tailscale already installed: ${EXISTING_VERSION}" else INSTALL_NEEDED=true info "Installing tailscale on ${PLATFORM}..." fi if [[ "$INSTALL_NEEDED" == "true" ]]; then case "$PLATFORM" in debian) info "Detected Debian/Ubuntu — installing via apt" curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | sudo tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | sudo tee /etc/apt/sources.list.d/tailscale.list >/dev/null sudo apt-get update sudo apt-get install -y tailscale ;; macos) info "Detected macOS — installing via Homebrew" brew install tailscale ;; windows) info "Detected Windows — installing via Chocolatey" choco install tailscale -y ;; fedora) info "Detected Fedora — installing via dnf" sudo dnf install -y dnf-plugins-core sudo dnf config-manager --add-repo https://pkgs.tailscale.com/stable/fedora/tailscale.repo sudo dnf install -y tailscale ;; rhel) info "Detected RHEL/CentOS — installing via yum" sudo yum install -y yum-utils sudo yum-config-manager --add-repo https://pkgs.tailscale.com/stable/rhel/tailscale.repo sudo yum install -y tailscale ;; alpine) info "Detected Alpine — installing via apk" apk add tailscale ;; linuxbrew) info "Detected Linux + Homebrew — installing via brew" brew install tailscale ;; *) error "Unknown platform. Please install tailscale manually from https://tailscale.com/download" if [[ "$JSON_OUTPUT" == "true" ]]; then json_output "error" "Unknown platform. Manual installation required." "unknown" "" fi exit 1 ;; esac # Verify installation NEW_VERSION=$(check_installed_version) if [[ -n "$NEW_VERSION" ]]; then success "Tailscale ${NEW_VERSION} installed successfully" else error "Installation completed but tailscale command not found" if [[ "$JSON_OUTPUT" == "true" ]]; then json_output "error" "Installation completed but command not found" "${PLATFORM}" "" fi exit 1 fi fi # ---- Start the daemon (if applicable) ---- if [[ "$PLATFORM" != "macos" && "$PLATFORM" != "windows" ]]; then # Check if tailscaled is running if systemctl is-active tailscaled &>/dev/null 2>&1; then success "tailscaled already running" else info "Starting tailscaled..." sudo systemctl enable --now tailscaled 2>/dev/null || { warn "Could not start tailscaled via systemd. Try: sudo systemctl start tailscaled" } fi fi # ---- Configure login server / authenticate ---- if [[ -n "$LOGIN_SERVER" ]]; then if [[ -n "$AUTHKEY" ]]; then info "Authenticating with Headserver: ${LOGIN_SERVER}" sudo tailscale up --login-server="${LOGIN_SERVER}" --authkey="${AUTHKEY}" 2>&1 || { error "Failed to authenticate with Headscale" if [[ "$JSON_OUTPUT" == "true" ]]; then json_output "error" "Authentication failed" "${PLATFORM}" "$(check_installed_version)" fi exit 1 } success "Authenticated with ${LOGIN_SERVER}" else warn "No --authkey provided. Run the following to authenticate interactively:" echo " sudo tailscale up --login-server=${LOGIN_SERVER}" fi fi # ---- Final Result ---- FINAL_VERSION=$(check_installed_version) if [[ "$JSON_OUTPUT" == "true" ]]; then json_output "success" "Tailscale installed and configured" "${PLATFORM}" "${FINAL_VERSION}" else success "Tailscale ${FINAL_VERSION} ready on ${PLATFORM}" fi -
ts-up.sh 6.4 KB
#!/usr/bin/env bash # ============================================================================= # ts-up.sh — Wrapper for `tailscale up` with Headscale # ============================================================================= # Description: # Wrapper around `tailscale up` configured for Headscale. Applies env-var # defaults and exposes common flags in a consistent interface. # # Usage: # ./ts-up.sh [options] # # Options: # --login-server <URL> Headscale control server URL (default: $HEADSCALE_URL) # --authkey <key> Pre-authentication key (default: $TAILSCALE_AUTHKEY) # --advertise-tags <tags> Comma-separated tags (e.g. tag:ci-runner,tag:monitoring) # --advertise-routes <cidr> CIDR ranges to advertise (e.g. 10.0.0.0/16) # --accept-routes Accept routes from other nodes # --accept-dns Accept MagicDNS configuration # --exit-node <IP> Use a specific exit node # --ssh Enable Tailscale SSH # --dry-run Preview the tailscale up command without running it # --json Output results in JSON format # --help Show this help message and exit # # Environment: # HEADSCALE_URL Default --login-server value # TAILSCALE_AUTHKEY Default --authkey value # # Examples: # ./ts-up.sh --login-server https://headscale.example.com --authkey tskey-auth-xxxxx # ./ts-up.sh --accept-routes --accept-dns # HEADSCALE_URL=https://headscale.example.com ./ts-up.sh --dry-run --json # ./ts-up.sh --advertise-tags tag:ci-runner --ssh # ============================================================================= set -euo pipefail # ---- Colors ---- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' info() { echo -e "${BLUE}[INFO]${NC} $*"; } success() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } usage() { grep "^# " "$0" | sed 's/^# //' | sed 's/^#//' exit 0 } # ---- Defaults ---- LOGIN_SERVER="" AUTHKEY="" ADVERTISE_TAGS="" ADVERTISE_ROUTES="" ACCEPT_ROUTES=false ACCEPT_DNS=false EXIT_NODE="" SSH=false DRY_RUN=false JSON_OUTPUT=false # ---- Parse Arguments ---- while [[ $# -gt 0 ]]; do case "$1" in --login-server) LOGIN_SERVER="$2"; shift 2 ;; --authkey) AUTHKEY="$2"; shift 2 ;; --advertise-tags) ADVERTISE_TAGS="$2"; shift 2 ;; --advertise-routes) ADVERTISE_ROUTES="$2"; shift 2 ;; --accept-routes) ACCEPT_ROUTES=true; shift ;; --accept-dns) ACCEPT_DNS=true; shift ;; --exit-node) EXIT_NODE="$2"; shift 2 ;; --ssh) SSH=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --json) JSON_OUTPUT=true; shift ;; --help) usage ;; *) error "Unknown option: $1"; usage ;; esac done # Apply env var defaults LOGIN_SERVER="${LOGIN_SERVER:-${HEADSCALE_URL:-}}" AUTHKEY="${AUTHKEY:-${TAILSCALE_AUTHKEY:-}}" # ---- Verify prerequisites ---- if ! command -v tailscale &>/dev/null; then error "tailscale command not found. Install tailscale first (see ts-install.sh)." exit 1 fi # ---- Build the tailscale up flags ---- TAILSCALE_UP_FLAGS=() if [[ -n "$LOGIN_SERVER" ]]; then TAILSCALE_UP_FLAGS+=(--login-server="$LOGIN_SERVER") fi if [[ -n "$AUTHKEY" ]]; then TAILSCALE_UP_FLAGS+=(--authkey="$AUTHKEY") fi if [[ -n "$ADVERTISE_TAGS" ]]; then IFS=',' read -ra TAGS <<< "$ADVERTISE_TAGS" for tag in "${TAGS[@]}"; do TAILSCALE_UP_FLAGS+=(--advertise-tags="$tag") done fi if [[ -n "$ADVERTISE_ROUTES" ]]; then TAILSCALE_UP_FLAGS+=(--advertise-routes="$ADVERTISE_ROUTES") fi if [[ "$ACCEPT_ROUTES" == "true" ]]; then TAILSCALE_UP_FLAGS+=(--accept-routes) fi if [[ "$ACCEPT_DNS" == "true" ]]; then TAILSCALE_UP_FLAGS+=(--accept-dns) fi if [[ -n "$EXIT_NODE" ]]; then TAILSCALE_UP_FLAGS+=(--exit-node="$EXIT_NODE") fi if [[ "$SSH" == "true" ]]; then TAILSCALE_UP_FLAGS+=(--ssh) fi # ---- Dry-Run / Execute ---- if [[ "$DRY_RUN" == "true" ]]; then if [[ "$JSON_OUTPUT" == "true" ]]; then cat <<EOF { "status": "dry-run", "command": "sudo tailscale up ${TAILSCALE_UP_FLAGS[*]}", "login_server": "${LOGIN_SERVER:-not-set}", "authkey": "$(if [[ -n "$AUTHKEY" ]]; then echo "provided (${#AUTHKEY} chars)"; else echo "not-set"; fi)", "advertise_tags": "${ADVERTISE_TAGS:-none}", "advertise_routes": "${ADVERTISE_ROUTES:-none}", "accept_routes": ${ACCEPT_ROUTES}, "accept_dns": ${ACCEPT_DNS}, "exit_node": "${EXIT_NODE:-none}", "ssh": ${SSH} } EOF else info "Dry-run mode — would execute:" echo "" echo " sudo tailscale up ${TAILSCALE_UP_FLAGS[*]}" echo "" info "Current tailscale status:" tailscale status --json 2>/dev/null | python3 -m json.tool 2>/dev/null || tailscale status 2>/dev/null || echo " (not connected)" fi exit 0 fi # ---- Execute tailscale up ---- info "Running: sudo tailscale up ${TAILSCALE_UP_FLAGS[*]}" echo "" UP_OUTPUT=$(sudo tailscale up "${TAILSCALE_UP_FLAGS[@]}" 2>&1) || { EXIT_CODE=$? error "tailscale up failed (exit code ${EXIT_CODE})" error "${UP_OUTPUT}" if [[ "$JSON_OUTPUT" == "true" ]]; then cat <<EOF { "status": "error", "exit_code": ${EXIT_CODE}, "error": "$(echo "${UP_OUTPUT}" | head -5 | sed 's/"/\\"/g')" } EOF fi exit ${EXIT_CODE} } if [[ -n "$UP_OUTPUT" ]]; then echo "${UP_OUTPUT}" fi # ---- Verify connectivity ---- echo "" info "Verifying connectivity..." CONNECTIVITY="" if tailscale status --json 2>/dev/null | python3 -c " import sys, json d = json.load(sys.stdin) self = d.get('Self', {}) online = self.get('Online', False) print('online' if online else 'offline') " 2>/dev/null; then CONNECTIVITY=$(tailscale status --json 2>/dev/null | python3 -c " import sys, json d = json.load(sys.stdin) self = d.get('Self', {}) print('connected' if self.get('Online', False) else 'online-false') ") fi if [[ "$JSON_OUTPUT" == "true" ]]; then # Get full status for JSON output STATUS_JSON=$(tailscale status --json 2>/dev/null || echo '{}') cat <<EOF { "status": "success", "login_server": "${LOGIN_SERVER:-}", "authkey_provided": $(if [[ -n "$AUTHKEY" ]]; then echo "true"; else echo "false"; fi), "connectivity": $(echo "$STATUS_JSON") } EOF else success "tailscale up completed successfully" tailscale status 2>/dev/null | head -20 fi
-
-
README.md 527 B
# Tailscale Client for Headscale ## Why Install This Skill Connects and troubleshoots official Tailscale clients against a self-hosted Headscale server. ## What You Get | Content | Purpose | |---|---| | `SKILL.md` and `scripts/` | Client setup and diagnostics | ## Quick Start Follow `SKILL.md` to connect with the appropriate `--login-server` value. ## Triggers Use when enrolling a device or diagnosing tailnet connectivity. ## Requirements The official Tailscale client and access to the Headscale control server. -
SKILL.md 6.6 KB
--- name: tailscale-client description: Install, configure, and troubleshoot the official Tailscale client when connected to a Headscale self-hosted control server. Use when connecting a new device, diagnosing connectivity, checking peer status, or troubleshooting DERP relay issues. metadata: category: devops --- # tailscale-client ## Overview Tailscale is a WireGuard-based mesh VPN that connects devices into a secure tailnet. When used with [Headscale](https://github.com/juanfont/headscale), a self-hosted open-source control server, the official Tailscale client connects via the `--login-server` flag instead of Tailscale's SaaS control plane. This skill covers client-side installation, authentication, diagnostics, and troubleshooting. ### Architecture ``` [Client A] ───── WireGuard ───── [Client B] │ │ └──────── Headscale URL ────────┘ (control/ coordination) ``` The Tailscale client (the `tailscaled` daemon) registers with the Headscale control server, exchanges WireGuard keys, and establishes direct peer-to-peer encrypted connections. When direct NAT traversal fails, traffic falls back through DERP (Detour Encrypted Relay Protocol) relays. ## Installation Install the official Tailscale client on the target device: | Platform | Command | |----------|---------| | **Debian/Ubuntu** | `curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | sudo tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null && curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | sudo tee /etc/apt/sources.list.d/tailscale.list && sudo apt-get update && sudo apt-get install tailscale` | | **macOS** | `brew install tailscale` | | **Windows** | `choco install tailscale` | | **Fedora/RHEL** | `sudo dnf install dnf-plugins-core && sudo dnf config-manager --add-repo https://pkgs.tailscale.com/stable/fedora/tailscale.repo && sudo dnf install tailscale` | | **Alpine** | `apk add tailscale` | See `ts-install.sh` for automated detection and installation. ## Connection After installation, authenticate with your Headscale server: ```bash sudo tailscale up --login-server=https://headscale.example.com ``` This opens a browser for web-based authentication OR prints an auth URL at the terminal. For non-interactive (scripted) setups, use a pre-authentication key: ```bash sudo tailscale up \ --login-server=https://headscale.example.com \ --authkey=tskey-auth-xxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` See `ts-up.sh` for a wrapper with env-var defaults. ## Authentication ### Web Auth The default `tailscale up` flow prints a URL (e.g. `https://headscale.example.com/register/nodekey:xxxxx`). Visit this URL in a browser (or pass it to the Headscale admin to approve). ### Pre-Auth Keys Generate on the Headscale server: ```bash headscale preauthkeys create --user myuser ``` Use the key with `--authkey` as shown above. Keys can be tagged for service/auth nodes that don't belong to a specific user: ```bash headscale preauthkeys create --user myuser --tags tag:ci-runner,tag:monitoring ``` Then on the client: ```bash sudo tailscale up \ --login-server=https://headscale.example.com \ --authkey=tskey-auth-xxxxx \ --advertise-tags=tag:ci-runner ``` ## Diagnostics | Command | Purpose | |---------|---------| | `tailscale status --json` | List all peers and their connection state | | `tailscale ping --verbose -c 3 <peer>` | Test direct vs. relay path to a peer | | `tailscale netcheck` | Check NAT type and DERP relay connectivity | | `tailscale version` | Client and daemon version info | | `tailscale debug` | Low-level debugging (derp-map, metrics, goroutines) | Run `ts-diagnostics.sh` for a comprehensive connectivity bundle that collects all of the above into a structured JSON output. Use `ts-connectivity-report.py` to interpret the diagnostics and produce a human-readable or structured report. ## Features | Feature | Headscale Support | Notes | |---------|-------------------|-------| | **MagicDNS** | ✅ Supported | `--accept-dns` must be passed to `tailscale up` | | **Taildrop / Taildrive** | ✅ Supported | File sharing between peers | | **Tailscale SSH** | ✅ Supported | `--ssh` flag on `tailscale up` | | **Serve** | ✅ Supported | Expose local services via tailnet | | **Funnel** | ❌ Not supported | Funnel requires Tailscale's SaaS control plane | | **Exit Nodes** | ✅ Supported | Advertise with `--advertise-exit-node`, use with `--exit-node` | ## Environment Variables | Variable | Purpose | |----------|---------| | `HEADSCALE_URL` | Default `--login-server` URL for `ts-up.sh` | | `TAILSCALE_AUTHKEY` | Default `--authkey` for `ts-up.sh` | ## Gotchas - **Port conflicts (8080, 8443)**: Tailscale Serve often uses 8080 or 8443. Check for conflicts with `lsof -i :8080`. - **Subnet overlap**: If the tailnet subnets overlap with local networks, routes may not work. Review advertised routes carefully. - **DERP-only fallback**: When NAT traversal fails, peers connect via DERP relays only. Latency increases significantly. Check with `ts-diagnostics.sh` or `tailscale status --json` and look for `"relay":"..."` instead of `"txBytes"/"rxBytes"` on the direct path. - **tailscaled not running**: The daemon must be started before `tailscale` CLI commands work. On systemd systems: `sudo systemctl start tailscaled`. On macOS: open the Tailscale GUI app or run `sudo tailscaled`. - **DNS resolution**: MagicDNS requires `--accept-dns` on `tailscale up`. Without it, nodes are only reachable by their Tailscale IP (100.x.x.x). - **Key expiry**: Node keys expire by default. Use `--force-reauth` or re-run `tailscale up` to re-authenticate. Pre-auth keys can be created with `--expiry=false` for non-expiring (long-lived) nodes. ## Trigger Conditions This skill should be loaded when the user mentions any of the following: - Installing or setting up Tailscale client on any platform - Connecting a device to a Headscale server - Tailscale authentication issues (auth key, web auth, node approval) - Checking tailscale status, ping, or connectivity - DERP relay problems or NAT traversal failures - Tailscale SSH, Serve, MagicDNS, or Taildrop configuration - Troubleshooting "tailscaled not running" or "no connection" - Interpreting `tailscale status`, `tailscale ping`, or `tailscale netcheck` output ## When not to use Do not use this skill for server-side Headscale deployment (load `headscale-deploy` instead) or for ACL/policy authoring (load `tailnet-policy`). It covers client installation, authentication, and diagnostics only.
-
-
-
templates
-
derp-map.json 1 KB
{ "Regions": { "999": { "RegionID": 999, "RegionCode": "headscale", "RegionName": "Headscale Embedded DERP", "Nodes": [ { "Name": "headscale-derp", "RegionID": 999, "HostName": "headscale.example.com", "DERPPort": 443, "STUNPort": 3478, "STUNOnly": false } ] }, "1": { "RegionID": 1, "RegionCode": "us-east", "RegionName": "US East", "Nodes": [ { "Name": "derp-us-east-1", "RegionID": 1, "HostName": "derp-us-east.example.com", "DERPPort": 443, "STUNPort": 3478, "STUNOnly": false } ] }, "2": { "RegionID": 2, "RegionCode": "eu-west", "RegionName": "EU West", "Nodes": [ { "Name": "derp-eu-west-1", "RegionID": 2, "HostName": "derp-eu-west.example.com", "DERPPort": 443, "STUNPort": 3478, "STUNOnly": false } ] } } } -
docker-compose-headscale.yaml 1.2 KB
version: "3.9" services: headscale: image: headscale/headscale:latest container_name: headscale restart: unless-stopped ports: - "3478:3478/udp" - "8080:8080" volumes: - ./config:/etc/headscale - ./data:/var/lib/headscale command: headscale serve environment: - HEADSCALE_CONFIG=/etc/headscale/config.yaml labels: - "traefik.enable=true" - "traefik.http.routers.headscale.rule=Host(`headscale.example.com`)" - "traefik.http.routers.headscale.entrypoints=websecure" - "traefik.http.routers.headscale.tls.certresolver=letsencrypt" traefik: image: traefik:v3.0 container_name: traefik restart: unless-stopped command: - "--providers.docker=true" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" - "--certificatesresolvers.letsencrypt.acme.email=admin@example.com" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./letsencrypt:/letsencrypt -
headscale-config.yaml 2.2 KB
# Headscale Configuration Template # Reference: https://headscale.net/stable/ref/configuration/ # Server URL — must be the public URL clients connect to server_url: https://headscale.example.com:443 # Address to listen for client connections listen_addr: 0.0.0.0:8080 # Address to expose metrics (Prometheus) metrics_listen_addr: 0.0.0.0:9090 # gRPC address for remote CLI grpc_listen_addr: 0.0.0.0:50443 # Enable gRPC for remote headscale CLI grpc_allow_insecure: false # Private key for inter-node encryption private_key_path: /var/lib/headscale/private.key # Database configuration database: type: sqlite3 # path: /var/lib/headscale/db.sqlite # For PostgreSQL: # type: postgres # host: localhost # port: 5432 # name: headscale # user: headscale # pass: "" # TLS — set if not using a reverse proxy tls_letsencrypt_hostname: "" tls_letsencrypt_listen: ":http" tls_letsencrypt_challenge_type: HTTP-01 tls_cert_path: "" tls_key_path: "" # DNS configuration dns_config: # Enable MagicDNS — assign .ts.net domain to nodes magic_dns: true # Base domain for MagicDNS base_domain: example.com # Global nameservers (all DNS queries) nameservers: - 1.1.1.1 - 8.8.8.8 # Split DNS — specific domains to specific resolvers restricted_nameservers: my-internal.local: - 10.0.0.53 # Search domains for DNS resolution search_domains: [] # Extra DNS records (Headscale-only feature) extra_records: - name: "monitoring.example.com" type: "A" value: "10.0.0.50" # DERP relay configuration derp: server: # Enable embedded DERP server enabled: true region_id: 999 region_code: "headscale" region_name: "Headscale Embedded DERP" stun_listen_addr: "0.0.0.0:3478" private_key_path: /var/lib/headscale/derp_server.key # URLs for custom DERP maps urls: [] # Local DERP map files paths: [] # Auto-update DERP map from URLs auto_update: true # Policy (ACL) file policy: path: /etc/headscale/policy.json # Mode: "file" (default) or "database" mode: file # OpenID Connect (optional) oidc: only_start_if_oidc_is_available: false issuer: "" client_id: "" client_secret: "" # Logging log: level: info format: text -
policy-allow-all.json 3 B
{} -
policy-deny-all.json 107 B
{ "grants": [], "acls": [], "tagOwners": {}, "autoApprovers": {}, "ssh": [], "nodeAttrs": [] } -
policy-tagged-segmented.json 510 B
{ "tagOwners": { "tag:server": ["alice@"], "tag:ci": ["alice@"], "tag:client": ["alice@", "bob@"], "tag:monitoring": ["alice@"] }, "grants": [ { "src": ["autogroup:member"], "dst": ["tag:server:*"], "ip": ["*"] }, { "src": ["tag:server"], "dst": ["tag:monitoring:80,443"], "ip": ["*"] }, { "src": ["tag:monitoring"], "dst": ["*:*"], "ip": ["*"] } ], "autoApprovers": { "exitNode": ["tag:server"] } }
-
-
AGENTS.md 1.1 KB
# Tailscale/Headscale Bundle — Agent Instructions ## Auto-Load Protocol When the user's message matches trigger keywords (see umbrella SKILL.md), load the corresponding sub-skill with `skill_view(name='tailscale/<sub-skill-name>')`. For general "tailscale"/"headscale"/"tailnet" mentions, load this umbrella SKILL.md first for navigation, then the relevant sub-skill. ## Shared Scripts All scripts in the bundle root `scripts/` are available regardless of which sub-skill is loaded. Reference them by relative path from the bundle root: ``` scripts/headscale-health-check.sh --json scripts/headscale-backup.sh --dry-run ``` ## Sub-Skill Scripts Sub-skill scripts are in `skills/<sub-skill>/scripts/` and are documented in their respective SKILL.md files. ## Environment Set these env vars for non-interactive operation: - `HEADSCALE_URL` — Headscale server URL - `HEADSCALE_API_KEY` — API key from `headscale apikeys create` - `TAILSCALE_AUTHKEY` — Pre-authenticated key for client setup All scripts check these at runtime and show a helpful error if missing. -
manifest.yaml 2.9 KB
# Bundle manifest (bundle-manifest-v1) — see schemas/bundle-manifest-v1.schema.json # and docs/bundle-manifest-design.md. Paths are relative to this bundle dir. schema_version: 1 bundle_name: tailscale purpose: >- Operate a self-hosted Tailscale/Headscale ecosystem end to end: deploy and manage a Headscale control server, configure Tailscale clients, define ACL policy, manage node lifecycle, advertise subnet routes and exit nodes, operate DERP relays, and back up, restore, or migrate the control plane. audience: >- Platform and homelab operators running self-hosted Tailscale/Headscale infrastructure; agents that need to know which sub-skill to load for deploy, policy, client, node, routing, DERP, or backup tasks in a WireGuard mesh. stages: - name: Deploy control server skills: - skills/headscale-deploy/SKILL.md - name: Policy skills: - skills/tailnet-policy/SKILL.md - name: Client connectivity skills: - skills/tailscale-client/SKILL.md - name: Node lifecycle skills: - skills/headscale-node-lifecycle/SKILL.md - name: Routing skills: - skills/headscale-routing/SKILL.md - name: DERP relays skills: - skills/headscale-derp/SKILL.md - name: Backup and restore skills: - skills/headscale-backup/SKILL.md included_skills: - skills/headscale-deploy/SKILL.md - skills/tailnet-policy/SKILL.md - skills/tailscale-client/SKILL.md - skills/headscale-node-lifecycle/SKILL.md - skills/headscale-routing/SKILL.md - skills/headscale-derp/SKILL.md - skills/headscale-backup/SKILL.md prerequisites: - artifact: A Linux host and install target for the Headscale control server skill: skills/headscale-deploy/SKILL.md - artifact: HEADSCALE_URL and HEADSCALE_API_KEY credentials skill: skills/headscale-deploy/SKILL.md - artifact: A running Headscale instance with the headscale CLI available skill: skills/headscale-deploy/SKILL.md outputs: - headscale-server - tailnet-policy - registered-nodes - routed-networks - derp-map - backup-archive - restored-instance handoffs: - to: tailnet-policy artifact: headscale-server note: >- A running control server is required before ACL policy is applied and before the tailnet opens to other users. - to: headscale-node-lifecycle artifact: headscale-server note: >- Nodes are registered, approved, tagged, and decommissioned against the running control server. - to: headscale-backup artifact: headscale-server note: >- Backups (sqlite + config + policy + certs) run regularly against the production control server; restore re-creates the instance from the archive. - to: user artifact: backup-archive note: >- The backup archive is the disaster-recovery artifact for the tailnet; keep it off the control server host. conflicts: [] eval_suite: - evals/evals.json -
README.md 2.1 KB
# Tailscale / Headscale — Self-Hosted Mesh VPN Bundle A comprehensive bundle of 7 sub-skills covering the entire self-hosted Tailscale ecosystem using Headscale as the open-source control server. Deploy, configure, and maintain your own WireGuard-based mesh VPN. ## Why Install This Bundle When your agent loads this bundle, it becomes a **Tailscale/Headscale infrastructure engineer** who can handle the full lifecycle: - **Deploy Headscale** — install and configure the control server - **Author tailnet policies** — ACL rules, tag-based access control, user groups - **Manage node lifecycle** — auth keys, registration, tagging, decommissioning - **Configure clients** — install and connect Tailscale to your Headscale server - **Set up routing** — subnet routers and exit nodes - **Deploy DERP relays** — reliable peer-to-peer connectivity across NATs - **Backup and migrate** — regular backup and restoration of the control server ## What You Get | Directory | Purpose | |-----------|---------| | `SKILL.md` | Bundle umbrella — trigger-based auto-loading for 7 sub-skills | | `skills/` | 7 sub-skills: headscale-deploy, tailnet-policy, headscale-node-lifecycle, tailscale-client, headscale-routing, headscale-derp, headscale-backup | | `scripts/` | 23 shared scripts with `--json` and `--dry-run` support | | `references/` | 8 reference documents | | `templates/` | 6 templates for policy files and configs | ## Quick Start 1. **Deploy Headscale** first — install the control server 2. **Configure tailnet policy** — set up ACLs before opening to users 3. **Manage nodes** — register and tag machines on your tailnet 4. **Install clients** — connect machines to your headscale server ## Triggers Load this when you hear "Tailscale," "Headscale," "tailnet," "mesh VPN," "WireGuard mesh," or "self-hosted VPN infrastructure." ## Requirements Bash, Python 3.8+, jq, curl. Access to a Headscale server or the `headscale` CLI. Tailscale client on target machines. ## Why Install This Skill This skill packages practical, reusable guidance for this domain so you can move from a real task to a dependable result without rebuilding the workflow each time. -
SKILL.md 7 KB
--- name: tailscale description: >- Deploy and manage the self-hosted Tailscale/Headscale ecosystem: a Headscale control server, tailscale clients, ACL policies, node lifecycle, subnet routing, DERP relays, and backup/migration. Use when the user mentions Tailscale, Headscale, tailnet, mesh VPN, WireGuard mesh, or self-hosted VPN infrastructure. Do not use this skill for unrelated requests; route to the nearest named specialist. license: MIT compatibility: Requires bash, Python 3.8+, jq, curl, and access to a Headscale server or the `headscale` CLI. Tailscale client (`tailscale`) must be installed on target machines. metadata: tags: tailscale, headscale, vpn, wireguard, mesh, networking, homelab spec-version: '1.0' --- # Tailscale + Headscale Skill Bundle This umbrella skill covers the self-hosted Tailscale ecosystem using [Headscale](https://headscale.net) as the open-source control server. It provides 7 sub-skills that are auto-loaded by context. ## Auto-Loading by Context When the user's message matches a trigger keyword, the corresponding sub-skill's SKILL.md is loaded. Multiple sub-skills can load together when triggers overlap. | Trigger Keywords | Sub-Skill(s) Loaded | |---|---| | "deploy headscale", "install headscale", "setup headscale server", "headscale config" | `headscale-deploy` | | "ACL", "policy file", "tailnet policy", "access control", "grant", "tag owners" | `tailnet-policy` | | "install tailscale", "connect to headscale", "tailscale client", "tailscale up", "tailscale status", "diagnose tailscale", "connectivity" | `tailscale-client` | | "auth key", "preauthkey", "register node", "approve node", "tag node", "node list", "decommission node" | `headscale-node-lifecycle` | | "subnet router", "exit node", "advertise route", "approve route" | `headscale-routing` | | "DERP", "relay", "peer relay", "STUN" | `headscale-derp` | | "backup headscale", "restore headscale", "migrate headscale", "headscale backup" | `headscale-backup` | | "Tailscale", "Headscale", "tailnet", "mesh VPN", "WireGuard mesh", "self-hosted VPN" | Loads this umbrella SKILL.md for navigation | ## Sub-Skill Ordering & Dependencies ``` headscale-deploy ─────┬──> tailnet-policy ───> headscale-routing │ ├──> headscale-node-lifecycle │ ├──> tailscale-client │ ├──> headscale-derp │ └──> headscale-backup (prerequisite: a running headscale instance) ``` - **headscale-deploy** must be completed first — the others require a running Headscale server - **tailnet-policy** (configures ACLs) is recommended before opening the tailnet to other users - **headscale-derp** is optional but recommended for reliability across NATs - **headscale-backup** should be run regularly on any production deployment ## Root Scripts (Shared Utilities) These live in `scripts/` at the bundle root and are available to all sub-skills. ## Available Scripts | Script | Purpose | Invocation | |---|---|---| | `scripts/headscale-health-check.sh` | Probe Headscale server health: version, node count, and DB integrity. Run it after any control-server change and as the first diagnostic when nodes or clients misbehave. | `scripts/headscale-health-check.sh --json` | | `scripts/headscale-backup.sh` | Full backup of the Headscale server (sqlite + config + policy + certs) to a restorable archive. Run it on a schedule for any production deployment and before upgrades or migrations; `--dry-run` previews without writing. | `scripts/headscale-backup.sh --dry-run` | | `scripts/headscale-restore.sh` | Restore a Headscale server from a backup archive. Run it during disaster recovery or migration onto a fresh host; always verify node list and policy afterwards. | `scripts/headscale-restore.sh --backup headscale-backup-2026-01-01.tar.gz` | | `scripts/tailscale-status-json.sh` | Structured wrapper around `tailscale status --json` with peer diagnostics. Run it from any client to check connectivity, peers, and relay/direct paths in machine-readable form. | `scripts/tailscale-status-json.sh` | | `scripts/test-all.sh` | Smoke test across all bundle scripts (`--help`, syntax, executability) without requiring a running Headscale. Run it after modifying any bundled script; CI runs it via `scripts/check-skill-tests.py`. | `bash scripts/test-all.sh` | ## Templates Templates live in `templates/` and cover common deployment patterns: - `templates/docker-compose-headscale.yaml` — Headscale + embedded DERP + Traefik TLS - `templates/headscale-config.yaml` — Annotated full headscale configuration - `templates/policy-allow-all.json` — Minimal allow-all policy - `templates/policy-deny-all.json` — Locked-down deny-all policy - `templates/policy-tagged-segmented.json` — Tag-based access model - `templates/derp-map.json` — Custom DERP relay map ## Environment Variables | Variable | Used By | Purpose | |---|---|---| | `HEADSCALE_URL` | All | Headscale server URL (e.g. `https://headscale.example.com`) | | `HEADSCALE_API_KEY` | All | Headscale API key (created via `headscale apikeys create`) | | `TAILSCALE_AUTHKEY` | tailscale-client | Pre-authenticated key for non-interactive client setup | ## Use the CLI tools All scripts use `--json`, `--dry-run`, and have informative `--help` output. Scripts relative to bundle root: `scripts/<tool>` or `skills/<sub-skill>/scripts/<tool>`. See the individual sub-skill SKILL.md for detailed usage. ## Prerequisites - bash, Python 3.8+, `jq`, and `curl` on the host running the scripts (per `compatibility`). - A running Headscale server with `HEADSCALE_URL` and `HEADSCALE_API_KEY` set for server-side operations (API key created via `headscale apikeys create`); `TAILSCALE_AUTHKEY` for non-interactive client enrollment. - The `tailscale` client installed on target machines for status and routing sub-skills; the `headscale` CLI (or API access) for control-server administration. - For headscale-backup/restore: filesystem access to the server's sqlite DB, config, policy, and cert paths, plus storage for archives off the control-server host. ## Limitations - This bundle assumes a self-hosted Headscale control plane — it does not manage Tailscale's hosted SaaS (see When not to use). - Scripts check environment variables at runtime and error helpfully when missing; they do not create credentials themselves. - Backup/restore operates on the files present on the control-server host; it cannot recover data that was never backed up, and a restore should always be followed by health verification. - Sub-skill scripts live under `skills/<sub-skill>/scripts/` and are documented in their own SKILL.md files, not here. ## When not to use Do not load this umbrella when a task maps to a single sub-skill — load the matching sub-skill directly (e.g. `headscale-deploy`, `tailnet-policy`, `tailscale-client`). It assumes a self-hosted Headscale control server; for Tailscale's hosted SaaS control plane, or for non-Tailscale VPN tooling, use the appropriate network skill instead.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.