fix(headscale): restrict admin ports to an admin group and tagged nodes; client PCs reach web:443 only #246

Merged
jfraeys merged 2 commits from jfraeys/fix/headscale-acl-admin-group into main 2026-10-16 19:09:27 -04:00
Owner

Why: eight ACL rules used src '*'. Any user who can pass Authelia (the Headscale OIDC client's authorization_policy is one_factor) could enroll a device and reach SSH on both hosts, the LLDAP admin page, Alertmanager's UI (no login of its own), Forgejo git-SSH and web port, and the CI cache. docs/runbooks/onboard-client-user.md said non-infra nodes get 443 only. This matters now because the coaching site's multi-instance admin panel brings client and client-admin users onto the tailnet.

Change

  • roles/headscale/templates/acl.hujson.j2: new group:admin from headscale_admin_users; the seven admin-port rules take group:admin + tag:web + tag:services instead of '*'. Tagged nodes are kept because they can only be created by the infra user and carry the CI flows (runners reaching Forgejo 3000/2222 and the cache 8089, the provisioner reaching runners); staging nodes join the same Headscale, so CI staging does not exercise this ACL. web:443 stays open to everyone (the admin panel, which has its own Authelia gate).
  • headscale_admin_users: role default [jfraeys]; production lists the Headscale user name and the OIDC email, because Headscale may identify a user with an email by that email in policy files. An entry that matches nobody is skipped, not an error (the policy checker accepts a nonexistent user, which I confirmed).
  • Runbook: control-server URL for enrolling a device, corrected access description, how to make a device an admin device.

Checked: test_headscale_acl.py (5 cases; two fail if a wildcard is put back); 'headscale policy check' on the live server says valid; Headscale users on the live server: jfraeys (id 2, oidc, one device = the operator's Mac) and infra.
Not checked: that the policy gives the operator's Mac the admin ports after deploy. That needs the deploy; see below.

Deploy and verify (headscale tag restarts the container briefly; existing tunnels keep running):

  1. ./infra deploy --tags headscale --limit services
  2. From the Mac, over the tailnet: nc -z 100.64.0.3 17170, 9093, 2222, 3000 must all succeed; ssh to the tailnet address works.
  3. If the Mac lost access: the other form of the name is the fix, or roll back with 'headscale policy set' on the previous file / redeploy the previous commit. Ansible and public SSH are not affected (they use public addresses), so there is always a way in.

Follow-up PR: limit the Headscale OIDC login itself to the groups that should have devices.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ

Why: eight ACL rules used src '*'. Any user who can pass Authelia (the Headscale OIDC client's authorization_policy is one_factor) could enroll a device and reach SSH on both hosts, the LLDAP admin page, Alertmanager's UI (no login of its own), Forgejo git-SSH and web port, and the CI cache. docs/runbooks/onboard-client-user.md said non-infra nodes get 443 only. This matters now because the coaching site's multi-instance admin panel brings client and client-admin users onto the tailnet. Change - roles/headscale/templates/acl.hujson.j2: new group:admin from headscale_admin_users; the seven admin-port rules take group:admin + tag:web + tag:services instead of '*'. Tagged nodes are kept because they can only be created by the infra user and carry the CI flows (runners reaching Forgejo 3000/2222 and the cache 8089, the provisioner reaching runners); staging nodes join the same Headscale, so CI staging does not exercise this ACL. web:443 stays open to everyone (the admin panel, which has its own Authelia gate). - headscale_admin_users: role default [jfraeys]; production lists the Headscale user name and the OIDC email, because Headscale may identify a user with an email by that email in policy files. An entry that matches nobody is skipped, not an error (the policy checker accepts a nonexistent user, which I confirmed). - Runbook: control-server URL for enrolling a device, corrected access description, how to make a device an admin device. Checked: test_headscale_acl.py (5 cases; two fail if a wildcard is put back); 'headscale policy check' on the live server says valid; Headscale users on the live server: jfraeys (id 2, oidc, one device = the operator's Mac) and infra. Not checked: that the policy gives the operator's Mac the admin ports after deploy. That needs the deploy; see below. Deploy and verify (headscale tag restarts the container briefly; existing tunnels keep running): 1. ./infra deploy --tags headscale --limit services 2. From the Mac, over the tailnet: nc -z 100.64.0.3 17170, 9093, 2222, 3000 must all succeed; ssh to the tailnet address works. 3. If the Mac lost access: the other form of the name is the fix, or roll back with 'headscale policy set' on the previous file / redeploy the previous commit. Ansible and public SSH are not affected (they use public addresses), so there is always a way in. Follow-up PR: limit the Headscale OIDC login itself to the groups that should have devices. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ
fix(headscale): only admin devices and tagged nodes reach SSH/LLDAP/Alertmanager/Forgejo; a client's PC reaches web:443
Some checks failed
Lint / lint (pull_request) Failing after 2m1s
Secret scan / scan (pull_request) Successful in 14s
5295aa2cfb
Eight ACL rules used src '*', so any device a user enrolled (any LLDAP user passes the Headscale OIDC login) could reach SSH on both hosts, the
LLDAP admin page, Alertmanager's UI (no login), Forgejo's git-SSH and web port and the CI cache. The onboarding runbook claimed 443 only.
Seven rules now take group:admin (headscale_admin_users) plus tag:web and tag:services (servers, ephemeral CI runners and staging nodes, which only
the infra user can create); web:443 stays open to everyone. The admin list accepts a user name or an email, because Headscale may identify an OIDC
user with an email by that email; unmatched entries are skipped, not errors.
Test: test_headscale_acl.py (5 cases; fails if a wildcard is put back). Policy checked with 'headscale policy check' on the live server (valid; it does not
validate membership). Runbook: control-server URL and the corrected access description.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ
fix(headscale): drop the Alertmanager ACL rule (UI is behind Authelia, #250); fix yamllint blank line
All checks were successful
Lint / lint (pull_request) Successful in 2m30s
Secret scan / scan (pull_request) Successful in 15s
dee4cacc4c
Alertmanager's UI moves to alertmanager.<domain> behind Traefik + Authelia and no tailnet port is published, so the services:9093 rule is not needed. A test asserts
no rule targets it. The CI failure on this PR was yamllint: a blank line at the end of roles/headscale/defaults/main.yml.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jfraeys/infra!246
No description provided.