From df4f712a93a4daf97cf32a491852622af50d09ee Mon Sep 17 00:00:00 2001 From: counterweight Date: Sat, 19 Sep 2026 17:49:54 +0200 Subject: [PATCH] headscale: the ACL was `{}` in code and real on the box. Put it in git `Create ACL policies file` wrote this, verbatim, to /etc/headscale/acl.json: content: | {} An empty policy. The real rules - who may reach which host on which port - were maintained by hand on spacey and existed nowhere else. So the repo and the box disagreed about the single file that governs every machine's access to every other machine, and the repo's version was "nothing is allowed". It had already fired once. /etc/headscale/acl.json.937464.2026-09-12@14:03:08~ is Ansible's own backup of a 1596-byte policy it replaced with {} on 12 Sep; the live file is dated 17 Sep, so somebody rebuilt it by hand afterwards without the playbook ever being corrected. Every subsequent `site.yml` run was one command away from flattening the whole tailnet's access rules again. This commit changes nothing on spacey. The policy now lives in templates/acl.json.j2, captured verbatim from the live file, and the task is a `template:` instead of an inline `{}`. Rendering it produces sha256 1ed14e7247d957db5e9f8d4f4c53846dc646131ad900054c868483a9a84ed8fd, byte for byte the file that was already there - verified before applying, and again after. Three deliberate choices: - A template file, not the inline `content:` used elsewhere in this playbook. The whole failure was three invisible characters passing review; a 2KB policy needs to show up as a diff. - `validate: python3 -m json.tool %s`. headscale refuses to load a malformed policy, and a policy it will not load is the same outage as an empty one. - `"counter-net@"` is now `"{{ headscale_namespace }}@"`, which is already a fact in group_vars/all/main.yml rather than a fourteenth hand-typed copy. The header comment records why a node with no ACL rule is INVISIBLE rather than merely unreachable - it never appears in `tailscale status`, MagicDNS does not resolve it, and the symptom is a downstream "no such host" that looks like a DNS problem. That cost an hour today. Co-Authored-By: Claude Opus 5 (1M context) --- .../headscale/deploy_headscale_playbook.yml | 17 ++- .../services/headscale/templates/acl.json.j2 | 130 ++++++++++++++++++ 2 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 ansible/services/headscale/templates/acl.json.j2 diff --git a/ansible/services/headscale/deploy_headscale_playbook.yml b/ansible/services/headscale/deploy_headscale_playbook.yml index e181f24..fe3a225 100644 --- a/ansible/services/headscale/deploy_headscale_playbook.yml +++ b/ansible/services/headscale/deploy_headscale_playbook.yml @@ -79,15 +79,24 @@ groups: headscale append: yes - - name: Create ACL policies file + # This used to write `{}` - an empty policy - over the real ACL, which was + # maintained by hand on the box. It had already flattened it once + # (/etc/headscale/acl.json.937464.2026-09-12@14:03:08~ is Ansible's own + # backup of the 1596-byte policy it replaced), and every subsequent + # site.yml run would have taken the whole tailnet's access rules with it. + # + # The policy now lives in templates/acl.json.j2, captured from the live + # file on 2026-09-19. validate first: a malformed policy is one headscale + # refuses to load, which is the same outage as an empty one. + - name: Deploy the Headscale ACL policy become: yes - copy: + ansible.builtin.template: + src: acl.json.j2 dest: /etc/headscale/acl.json - content: | - {} owner: headscale group: headscale mode: '0640' + validate: "python3 -m json.tool %s" notify: Restart headscale - name: Deploy headscale configuration file diff --git a/ansible/services/headscale/templates/acl.json.j2 b/ansible/services/headscale/templates/acl.json.j2 new file mode 100644 index 0000000..98ada80 --- /dev/null +++ b/ansible/services/headscale/templates/acl.json.j2 @@ -0,0 +1,130 @@ +{# + Headscale ACL policy. + + CAPTURED FROM THE LIVE FILE on spacey, 2026-09-19. Until then this playbook + wrote `{}` here - an empty policy - while the real rules were maintained by + hand on the box. Ansible had already flattened them once: + /etc/headscale/acl.json.937464.2026-09-12@14:03:08~ is its own backup of a + 1596-byte policy it replaced with {}. The next full site.yml run would have + done it again, taking the whole tailnet's access rules with it. + + So this is the source of truth now. It is a template and not inline `content:` + (the style used elsewhere in this playbook) precisely because it needs to be + diffable in review - an empty policy is the kind of change that must never + again slip through as three invisible characters. + + ADDING A HOST: it needs a tagOwners entry, and an acls rule naming the exact + ports its peers may reach. A node with no rule is INVISIBLE to the others - + it will not appear in `tailscale status` and MagicDNS will not resolve it, + which surfaces as a Caddy 502 with "no such host" rather than as a refused + connection. +#} +{ + "acls": [ + { + "action": "accept", + "dst": [ + "*:22", + "tag:arbret-staging-box:4117", + "tag:prd-arbret:4117", + "tag:nodito:8006" + ], + "src": [ + "tag:framie" + ] + }, + { + "action": "accept", + "dst": [ + "tag:knots-box:8333", + "tag:knots-box:7152", + "tag:knots-box:23334", + "tag:fulcrum-box:50002", + "tag:mempool-box:8080", + "tag:memos-box:5230", + "tag:arbret-staging-box:80", + "tag:stalwart-box:25", + "tag:stalwart-box:465", + "tag:stalwart-box:993", + "tag:stalwart-box:4190", + "tag:stalwart-box:8080" + ], + "src": [ + "tag:prd-vipy" + ] + }, + { + "action": "accept", + "dst": [ + "tag:knots-box:8332", + "tag:knots-box:28332-28335" + ], + "src": [ + "tag:fulcrum-box" + ] + }, + { + "action": "accept", + "dst": [ + "tag:knots-box:8332", + "tag:fulcrum-box:50001" + ], + "src": [ + "tag:mempool-box" + ] + }, + { + "action": "accept", + "dst": [ + "tag:prd-arbret:22", + "tag:prd-vipy:22", + "tag:memos-box:22", + "tag:stalwart-box:22" + ], + "src": [ + "tag:small-backups-box" + ] + } + ], + "tagOwners": { + "tag:arbret-staging-box": [ + "{{ headscale_namespace }}@" + ], + "tag:forgejo-runner-box": [ + "{{ headscale_namespace }}@" + ], + "tag:framie": [ + "{{ headscale_namespace }}@" + ], + "tag:fulcrum-box": [ + "{{ headscale_namespace }}@" + ], + "tag:knots-box": [ + "{{ headscale_namespace }}@" + ], + "tag:memos-box": [ + "{{ headscale_namespace }}@" + ], + "tag:mempool-box": [ + "{{ headscale_namespace }}@" + ], + "tag:nodito": [ + "{{ headscale_namespace }}@" + ], + "tag:prd-arbret": [ + "{{ headscale_namespace }}@" + ], + "tag:prd-monitoring": [ + "{{ headscale_namespace }}@" + ], + "tag:prd-vipy": [ + "{{ headscale_namespace }}@" + ], + "tag:small-backups-box": [ + "{{ headscale_namespace }}@" + ], + "tag:stalwart-box": [ + "{{ headscale_namespace }}@" + ] + } +}