Claude Skill

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

LLM Mart · 0 points · 6 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download magnus919-agent-skills-tailscale-d0edebb.zip · 129 KB
Part of magnus919/agent-skills — 145 skills

Install

skills CLI npx skills add https://github.com/magnus919/agent-skills/tree/main/tailscale
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
Git 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

  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 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 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.

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.

No comments yet.

Reviews (0)

No reviews yet.

Related