personal_infra/ansible/services/headscale/templates/acl.json.j2

135 lines
3.3 KiB
Text
Raw Normal View History

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) <noreply@anthropic.com>
2026-09-19 17:49:54 +02:00
{#
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",
monero: a full node on its own box, reachable from Feather over the WAN monerod v0.18.5.1 on monerod-box (VMID 2400), Debian 13, systemd, no Docker. Syncing at 24% as of this commit, 0 restarts, 0 OOM kills. ── The build-from-source requirement could not be met ────────────────────── The intent was to build it ourselves and check the result against the published checksums. That is not achievable for this release, and the role's README says so at length so nobody re-derives it: contrib/guix - the reproducible build that yields a byte-identical tarball - exists ONLY on monero master. At tag v0.18.5.1 contrib/ contains `gitian`, whose README wants an Ubuntu 18.04 host (EOL 2023) and python-cheetah. A plain `make release-static` is not reproducible at all, so it produces a binary whose hash matches nothing - failing both halves of the ask. So we install the published binary and prove its provenance instead: 1. binaryFate's key is VENDORED at files/binaryfate.asc. A trust root fetched over the network at the moment you need it is not a trust root. Its fingerprint is asserted after import regardless. 2. hashes.txt is verified, and the assertion is on gpg's `VALIDSIG <fpr>` status line - NOT on the string "Good signature", which is what bitcoin_knots greps for and which passes for any key in the keyring. 3. The hash is looked up in the output of `gpg --decrypt`, not in the raw clearsigned file, so nothing unsigned is ever searched. 4. The lookup requires hash AND filename on one line. Tested with a positive and a negative control before deploying: the real tarball matches, a corrupted hash correctly fails. monero_sha256 in defaults/ is a second in-git assertion so a version bump cannot happen without the checksum moving in the same diff. When v0.19 ships with guix on the release branch this gets easier, and the README records why: the published monero-linux-x64 tarball unpacks to monero-x86_64-linux-gnu-v0.18.5.1/, i.e. it IS the guix DISTNAME renamed, so sha256(guix output) will equal the hashes.txt entry exactly. ── The 500 GB disk is provisioned in code ────────────────────────────────── knots-box mounts /mnt/knots_data (~875 GB of chain) entirely by hand; that mount appears nowhere in Ansible and a rebuild of that host would come up with the blockchain silently missing. tasks/storage.yml does not repeat it. Whole device, no partition table. Everything after mkfs goes by UUID, because /dev/sdX is assigned by the kernel at boot and this estate has been bitten eight times by an identifier written down once whose thing then moved. monero_data_device is used ONLY to create the filesystem. Guards refuse to touch the root disk or to overwrite an unrecognised filesystem, and a final assert confirms the data directory is not still on /. fstab gets `nofail` so a missing disk cannot drop a headless VM into an emergency shell - and the unit gets `RequiresMountsFor=`, without which monerod would then start, find an empty directory, and write a 300 GB chain onto a 10 GB root disk. With it the unit refuses to start and infra/401 says so within five minutes. The mount-point task and the ownership task deliberately share owner and mode. Writing the obvious root:root 0755 on the first made them disagree and every run flipped the directory back and forth; caught by re-running --check. ── Exposure: one HTTPS vhost, zero new open ports ────────────────────────── Two RPC servers, not one: unrestricted on 127.0.0.1:18081 for administration and the health check, restricted on :18089 for everything else. That is `rpc-restricted-bind-port` and deliberately NOT `restricted-rpc=1`, which would restrict the loopback server too. Verified live: the restricted port reports restricted=true and blanks version and start_time. --rpc-login is HTTP Digest (contrib/epee/src/http_auth.cpp) and core_rpc_server::init hands the same login to both servers. Caddy on vipy does TLS and routing only - no caddy_site_basic_auth, because a Basic challenge would collide with the Digest challenge Feather answers. Verified end to end from the WAN: 401 unauthenticated, get_block_count over /json_rpc when authenticated, 401 again with a wrong password. No UFW rule, and that is correct: tailnet traffic bypasses UFW entirely because `-A INPUT -j ts-input` sits ahead of every ufw-* chain. Confirmed on mempool-box before relying on it here. P2P is outbound only. Nothing forwards 18080, so the node dials out, syncs and serves us without relaying for strangers; no-igd follows from that. ── Monitoring: infra, systemd, public. No backups ────────────────────────── Six Gatus endpoints, all green: liveness, disk, services/monerod-box-local/ monerod, probe/monerod, dns/monero.contrapeso.xyz, public/monero. The probe does NOT assert `synchronized`. A full sync takes days and a check requiring it would report DOWN for every one of them - an alarm with the batteries taken out. Height and sync state go in the message instead. The timer carries no `Requires=monerod.service`. Fulcrum's did, and sat active+enabled for seven months without firing once. public/monero asserts 401, not 200, so it goes red if rpc-login ever stops being enforced. No backups, deliberately, following the phoenixd precedent: the chain is re-syncable public data and there is no wallet on this node. ── Known constraint ──────────────────────────────────────────────────────── The VM has 2 GB and no swap; monerod's RSS is ~1.3 GB mid-sync. It is holding (0 OOM kills, ~1 GB available, kernel reclaiming cache) but 8 GB is the recommendation once the initial sync is done. out-peers/in-peers are held at 16/8 partly for this reason. Bumping it is a Proxmox resize plus memory_mb here, since the vm resource sets ignore_changes = all. The 500 GB data disk is likewise invisible to tofu - it was attached in the UI and the vm resource ignores it - so vms.auto.tfvars now carries a comment saying where it is and what mounts it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 17:50:46 +02:00
"tag:monerod-box:18089",
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) <noreply@anthropic.com>
2026-09-19 17:49:54 +02:00
"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 }}@"
],
monero: a full node on its own box, reachable from Feather over the WAN monerod v0.18.5.1 on monerod-box (VMID 2400), Debian 13, systemd, no Docker. Syncing at 24% as of this commit, 0 restarts, 0 OOM kills. ── The build-from-source requirement could not be met ────────────────────── The intent was to build it ourselves and check the result against the published checksums. That is not achievable for this release, and the role's README says so at length so nobody re-derives it: contrib/guix - the reproducible build that yields a byte-identical tarball - exists ONLY on monero master. At tag v0.18.5.1 contrib/ contains `gitian`, whose README wants an Ubuntu 18.04 host (EOL 2023) and python-cheetah. A plain `make release-static` is not reproducible at all, so it produces a binary whose hash matches nothing - failing both halves of the ask. So we install the published binary and prove its provenance instead: 1. binaryFate's key is VENDORED at files/binaryfate.asc. A trust root fetched over the network at the moment you need it is not a trust root. Its fingerprint is asserted after import regardless. 2. hashes.txt is verified, and the assertion is on gpg's `VALIDSIG <fpr>` status line - NOT on the string "Good signature", which is what bitcoin_knots greps for and which passes for any key in the keyring. 3. The hash is looked up in the output of `gpg --decrypt`, not in the raw clearsigned file, so nothing unsigned is ever searched. 4. The lookup requires hash AND filename on one line. Tested with a positive and a negative control before deploying: the real tarball matches, a corrupted hash correctly fails. monero_sha256 in defaults/ is a second in-git assertion so a version bump cannot happen without the checksum moving in the same diff. When v0.19 ships with guix on the release branch this gets easier, and the README records why: the published monero-linux-x64 tarball unpacks to monero-x86_64-linux-gnu-v0.18.5.1/, i.e. it IS the guix DISTNAME renamed, so sha256(guix output) will equal the hashes.txt entry exactly. ── The 500 GB disk is provisioned in code ────────────────────────────────── knots-box mounts /mnt/knots_data (~875 GB of chain) entirely by hand; that mount appears nowhere in Ansible and a rebuild of that host would come up with the blockchain silently missing. tasks/storage.yml does not repeat it. Whole device, no partition table. Everything after mkfs goes by UUID, because /dev/sdX is assigned by the kernel at boot and this estate has been bitten eight times by an identifier written down once whose thing then moved. monero_data_device is used ONLY to create the filesystem. Guards refuse to touch the root disk or to overwrite an unrecognised filesystem, and a final assert confirms the data directory is not still on /. fstab gets `nofail` so a missing disk cannot drop a headless VM into an emergency shell - and the unit gets `RequiresMountsFor=`, without which monerod would then start, find an empty directory, and write a 300 GB chain onto a 10 GB root disk. With it the unit refuses to start and infra/401 says so within five minutes. The mount-point task and the ownership task deliberately share owner and mode. Writing the obvious root:root 0755 on the first made them disagree and every run flipped the directory back and forth; caught by re-running --check. ── Exposure: one HTTPS vhost, zero new open ports ────────────────────────── Two RPC servers, not one: unrestricted on 127.0.0.1:18081 for administration and the health check, restricted on :18089 for everything else. That is `rpc-restricted-bind-port` and deliberately NOT `restricted-rpc=1`, which would restrict the loopback server too. Verified live: the restricted port reports restricted=true and blanks version and start_time. --rpc-login is HTTP Digest (contrib/epee/src/http_auth.cpp) and core_rpc_server::init hands the same login to both servers. Caddy on vipy does TLS and routing only - no caddy_site_basic_auth, because a Basic challenge would collide with the Digest challenge Feather answers. Verified end to end from the WAN: 401 unauthenticated, get_block_count over /json_rpc when authenticated, 401 again with a wrong password. No UFW rule, and that is correct: tailnet traffic bypasses UFW entirely because `-A INPUT -j ts-input` sits ahead of every ufw-* chain. Confirmed on mempool-box before relying on it here. P2P is outbound only. Nothing forwards 18080, so the node dials out, syncs and serves us without relaying for strangers; no-igd follows from that. ── Monitoring: infra, systemd, public. No backups ────────────────────────── Six Gatus endpoints, all green: liveness, disk, services/monerod-box-local/ monerod, probe/monerod, dns/monero.contrapeso.xyz, public/monero. The probe does NOT assert `synchronized`. A full sync takes days and a check requiring it would report DOWN for every one of them - an alarm with the batteries taken out. Height and sync state go in the message instead. The timer carries no `Requires=monerod.service`. Fulcrum's did, and sat active+enabled for seven months without firing once. public/monero asserts 401, not 200, so it goes red if rpc-login ever stops being enforced. No backups, deliberately, following the phoenixd precedent: the chain is re-syncable public data and there is no wallet on this node. ── Known constraint ──────────────────────────────────────────────────────── The VM has 2 GB and no swap; monerod's RSS is ~1.3 GB mid-sync. It is holding (0 OOM kills, ~1 GB available, kernel reclaiming cache) but 8 GB is the recommendation once the initial sync is done. out-peers/in-peers are held at 16/8 partly for this reason. Bumping it is a Proxmox resize plus memory_mb here, since the vm resource sets ignore_changes = all. The 500 GB data disk is likewise invisible to tofu - it was attached in the UI and the vm resource ignores it - so vms.auto.tfvars now carries a comment saying where it is and what mounts it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 17:50:46 +02:00
"tag:monerod-box": [
"{{ headscale_namespace }}@"
],
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) <noreply@anthropic.com>
2026-09-19 17:49:54 +02:00
"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 }}@"
]
}
}