headscale-pf is a command-line tool and Docker container that automates the generation and management of Headscale Access Control List (ACL) policy files. It synchronizes user group memberships from external identity providers into Headscale policy definitions, eliminating the need to manually maintain user lists in policy files.
The tool reads a policy template file (in HJSON format), queries one or more identity providers for group membership information, merges that data into the template, and outputs a complete policy file (in JSON format) that can be applied to a Headscale server.
- For Headscale < 0.26, use
headscale-pfversion 0.0.3. - From 1.0.0 onward,
headscale-pfuses a new user definition format compatible with Headscale ≥ 0.26.
IMPORTANT: This utility was developed for our own use and exclusively for use with Jumpcloud. Other sources were added at the request of the community.
If you have a problem, please create an issue.
- Jumpcloud
- Authentik
- LDAP / OpenLDAP / Active Directory** (tested with Jumpcloud LDAP)
- Keycloak
Planned:
- Auth0
- CSV
- JSON
- REMOTE JSON
- ...
go install github.com/YouSysAdmin/headscale-pf/cmd/headscale-pf@latestcurl -L https://raw.githubusercontent.com/YouSysAdmin/headscale-pf/master/scripts/install.sh | bashOptions:
-b→ install directory (default:~/.bin)-d→ enable debug logging[tag]→ optional release tag (latest if omitted)
brew install yousysadmin/apps/headscale-pf
Install the git hooks after cloning (lefthook required):
make hooksOn every commit this runs gofmt, go vet, go build, and go test (see lefthook.yml).
Bypass in a pinch with git commit --no-verify. Other useful targets: make build, make test, make lint.
headscale-pf [command] [flags]prepare– fetch group membership and generate a Headscale policycompletion– generate autocomplete script for your shellhelp– show help for any command
jc,jumpcloud- Jumpcloudak,authentik- Authentikldap,ldaps- LDAPkk,keycloak- Keycloak
| Flag / Option | Description | Env var | Default |
|---|---|---|---|
--source string |
Source type (jc, ak, ldap, kk) |
PF_SOURCE |
– |
--endpoint string |
Source endpoint | PF_ENDPOINT |
– |
--token string |
API token | PF_TOKEN |
– |
--input-policy string |
Input policy template | – | ./policy.hjson |
--output-policy string |
Output policy file | – | ./current.hjson |
--output-format string |
Output format: auto, hjson, or json |
– | auto |
--ldap-base-dn string |
LDAP base DN | PF_LDAP_BASE_DN |
– |
--ldap-bind-dn string |
LDAP bind DN | PF_LDAP_BIND_DN |
– |
--ldap-bind-password string |
LDAP password | PF_LDAP_BIND_PASSWORD |
– |
--ldap-default-email-domain |
LDAP default email domain | PF_LDAP_DEFAULT_USER_EMAIL_DOMAIN |
– |
--keycloak-realm string |
Keycloak Realm | PF_KEYCLOAK_REALM |
– |
--no-color |
Disable colored output | – | – |
-v, --version |
Show version | – | – |
--output-format controls how the prepared policy is written:
auto(default) — detect from the input template: if the template is strict JSON (no comments or trailing commas) the output isjson; otherwise it ishjson.hjson— the output mirrors the HuJSON template one-to-one: comments, key order, and formatting are preserved byte-for-byte; only group members are filled in.json— plain RFC-8259 JSON, pretty-printed with a 2-space indent (comments and trailing commas removed, key order preserved).
Headscale reads either format. The flag only changes serialization — it does not rename
the output file, so pair an explicit format with a matching --output-policy
(e.g. --output-format json --output-policy ./current.json).
headscale-pf prepare \
--source=jc \
--token=$JC_TOKEN \
--input-policy=policy.hjson \
--output-policy=out.json
headscale policy set -f out.jsonheadscale-pf prepare \
--source=ak \
--endpoint="https://auth.example.com" \
--token=$AK_TOKEN \
--input-policy=policy.hjson \
--output-policy=out.json
headscale policy set -f out.jsonheadscale-pf prepare \
--source=ldap \
--endpoint=ldap.example.com:636 \
--ldap-base-dn="ou=Users,dc=example,dc=com" \
--ldap-bind-dn="cn=service,ou=Users,dc=example,dc=com" \
--ldap-bind-password=$LDAP_PASS \
--ldap-default-email-domain="example.com" \
--input-policy=policy.hjson \
--output-policy=out.json
headscale policy set -f out.json# Get API Token
# Replace the url/username/password with your own.
KK_TOKEN=$(curl -X POST \
--url http://auth.example.com/realms/master/protocol/openid-connect/token \
--header 'content-type: application/x-www-form-urlencoded' \
--data client_id=admin-cli \
--data grant_type=password \
--data username=admin \
--data password=admin | jq -r '.access_token')
# Prepare policy
headscale-pf prepare \
--source=kk \
--endpoint="https://auth.example.com" \
--token=$KK_TOKEN \
--keycloak-realm="master" \
--input-policy=policy.hjson \
--output-policy=out.json
# Apply policy
headscale policy set -f out.json- Create a new file under
internal/sources/. - Implement the interface:
GetGroupByName(groupName string) (*models.Group, error)GetGroupMembers(groupID string) ([]models.User, error)GetUserInfo(userID string) (models.User, error)
- Register it in
internal/sources/sources.go.
Use the Headscale-PF Docker image inside your CI (the Docker image contains the Headscale CLI)
PF_TOKEN - Jumpcloud/etc. API token
HEADSCALE_CLI_ADDRESS - Headscale GRPC Endpoint
HEADSCALE_CLI_API_KEY - Headscale GRPC Token
# .github/workflows/policy.yaml
name: Headscale Policy
on:
workflow_dispatch:
inputs:
apply_policy:
description: "Apply policy after generation?"
type: boolean
required: true
default: true
save_artifact:
description: "Save generated policy.json as artifact?"
type: boolean
required: true
default: true
permissions:
contents: read
jobs:
generate-policy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run policy prepare
env:
IMAGE: ghcr.io/yousysadmin/headscale-pf:latest
PF_TOKEN: ${{ secrets.PF_TOKEN }}
HEADSCALE_CLI_ADDRESS: ${{ secrets.HEADSCALE_CLI_ADDRESS }}
HEADSCALE_CLI_API_KEY: ${{ secrets.HEADSCALE_CLI_API_KEY }}
APPLY_INPUT: ${{ github.event.inputs.apply_policy }}
run: |
set -euxo pipefail
docker pull "$IMAGE"
APPLY_FLAG=""
if [ "${APPLY_INPUT}" = "true" ]; then
APPLY_FLAG="-e APPLY_POLICY=1"
fi
# fix: headscale config file should be present anyway
touch config.yaml
docker run --rm \
--user "$(id -u)":"$(id -g)" \
-e PF_TOKEN="${PF_TOKEN}" \
${APPLY_FLAG} \
-e HEADSCALE_CLI_ADDRESS="${HEADSCALE_CLI_ADDRESS}" \
-e HEADSCALE_CLI_API_KEY="${HEADSCALE_CLI_API_KEY}" \
-e INPUT_POLICY="/work/policy.hjson" \
-e OUTPUT_POLICY="/work/current.hjson" \
-e SOURCE="jc" \
-e RETRIES="5" \
-e RETRY_DELAY_SEC="6" \
-v "$GITHUB_WORKSPACE:/work" \
"$IMAGE"
- name: Upload current.json artifact
if: ${{ github.event.inputs.save_artifact == 'true' }}
uses: actions/upload-artifact@v4
with:
name: current.hjson
path: ${{ github.workspace }}/current.hjson