Install in seconds
Install this skill
Copy the command and run it in your terminal. You can review the source before installing.
terminal
git clone https://github.com/magnus919/agent-skills

Works with Git. The repository opens in your current directory.

🛡️
DevOpsPython

Tailnet Policy

by magnus919

Author, test, and deploy Tailscale-compatible huJSON policy files for Headscale control. Manages ACLs, grants, tags, SSH rules, and auto-approvers with validation tests.

10 stars0 forksAdded 2026/07/18
agent-skillsagentskillsai-agentsautomationllm

Documentation

README

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:

policy:
  path: /etc/headscale/policy.hujson

After modifying the policy file, reload it on the Headscale server:

# 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)

{
  // 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)

{
  "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)

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

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

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

More from magnus919

Other Claude Code skills by this author in the directory.