Compare commits

...

6 commits

Author SHA1 Message Date
4933186d31 docs(friction): task-3 integration-gate findings (dnsmasq, nftables, hostname)
Documents three blockers found while developing the askari_inputonly
integration-test profile:

1. inet filter default-deny silently blocks libvirt dnsmasq DHCP: nftables
   multi-table independence means ip filter LIBVIRT_INP accept does NOT
   prevent inet filter drop. Diagnosed via strace; fixed with a drop-in.

2. libvirt leaseshelper PID-file: virPidFileReleasePath unlinks the file after
   every call; nobody cannot recreate in /run/. Fix: suid root C wrapper.

3. cloud-init rejects underscores in local-hostname → skips network-config
   → no DHCP. Fix: sanitize with replace("_", "-") in meta-data hostname.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 19:16:45 +02:00
9f0626040b docs(todo): add note on ubongo↔cluster network topology question
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 19:15:18 +02:00
8ca42c389c fix(integration): fix VM boot: hostname, netplan, known_hosts handling
Three fixes found during askari_inputonly integration-test development:

1. Hostname sanitization: cloud-init rejects underscores in local-hostname
   (silently skips network-config → VM never gets DHCP). Sanitize with
   name.replace("_", "-") for the meta-data hostname; paths/domain names
   keep the original (underscore is valid there).

2. Netplan explicit interface: match.name: en* with a named key produces a
   .network file that networkd never DHCPs. Use explicit enp1s0 (all virtio
   NICs in these KVM VMs) + renderer: networkd to bypass the bug.

3. ansible_ssh_common_args in the generated hosts.yml: integration VMs
   reuse IPs (different VMs at same 192.168.150.x lease). StrictHostKey
   accept-new from ansible.cfg blocks changed keys. Add StrictHostKeyChecking=no
   + UserKnownHostsFile=/dev/null per-host to the generated inventory so
   stale known_hosts entries never block the apply step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 19:15:07 +02:00
1042f161b6 test(integration): askari_inputonly — INPUT-only default-deny reboot gate
Adds the ADR-025 integration-test profile that proves the askari
mesh-hardening REDESIGN (INPUT-only default-deny, forward ACCEPT for Docker)
is reboot-safe on a throwaway KVM VM before the live cut-over.

Profile applies base (firewall + sshd) and offsite (docker_host +
reverse_proxy). Post-reboot verify checks: input policy drop, forward
policy accept, admin-addr break-glass SSH (192.168.150.1), Docker up,
and a published port answered from the controller. GREEN on 2026-06-19.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 19:14:55 +02:00
d9b8676fce feat(inventory): askari INPUT-only firewall + WAN break-glass + manage over wt0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:18:58 +02:00
ab328a2f79 feat(netbird_coordinator): disable geolocation so no-egress startup can't FATAL the control plane
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:15:33 +02:00
12 changed files with 176 additions and 19 deletions

View file

@ -224,6 +224,46 @@ harness on ubongo and shaking it down against real KVM (spec/plan in docs/superp
`flush` is safe; (3) the firewall final-review checklist should include "does the host run `flush` is safe; (3) the firewall final-review checklist should include "does the host run
Docker/libvirt? the flush wipes their nat." Docker/libvirt? the flush wipes their nat."
<!-- From the 2026-06-19 mesh-hardening 3/3 (askari INPUT-only integration gate). -->
- `[gotcha]` **`inet filter` default-deny blocks libvirt dnsmasq DHCP — silent, hard to diagnose**
(2026-06-19, task-3 integration gate): when `base__firewall_input_only: true` is applied to
ubongo, the `table inet filter { chain input { policy drop; } }` blocks DHCP packets that arrive
via the libvirt bridge (`virbr-boma`). In nftables, multiple tables at the same hook priority all
run independently; an `accept` verdict in `table ip filter LIBVIRT_INP` does NOT prevent
`table inet filter` from seeing and dropping the same packet. VMs never got DHCP leases (dnsmasq
socket confirmed by strace to never receive POLLIN despite tcpdump seeing the packet on
`virbr-boma`). Diagnosed by temporarily changing `inet filter input` to `policy accept` → fd=3
immediately fired. Fix: `/etc/nftables.d/10-libvirt-boma.nft` drop-in adding
`iifname "virbr-boma" accept` (survives service restarts via `include "/etc/nftables.d/*.nft"`).
→ The `base` role's template needs a `base__firewall_trusted_bridges` variable so this is
encoded at the Ansible level, not in a manual host drop-in. Every host that runs Docker or
libvirt and also has `base__firewall_input_only: true` needs an analogous exception.
- `[gotcha]` **libvirt `leaseshelper` PID-file permission: `virPidFileReleasePath` unlinks
`/run/leaseshelper.pid` after EVERY call; nobody cannot recreate it** (2026-06-19, task-3
integration gate): dnsmasq runs as nobody; `libvirt_leaseshelper` is its `--dhcp-script`. The
helper acquires a PID-file mutex at `/run/leaseshelper.pid`, but `virPidFileReleasePath`
UNLINKS the file on exit. `/run/` is `root:root 755`, so nobody cannot create the file after the
first unlink → every subsequent `add` call fails with `errno=13`, dnsmasq silently drops the
DHCP grant (no log, no error to the client). Fix: suid root C wrapper at
`/usr/lib/libvirt/libvirt_leaseshelper` (original moved to `.real`) that pre-creates
`/run/leaseshelper.pid` owned by nobody, then drops privileges and execs the real helper. The
root dnsmasq fork calls the wrapper; suid gives it permission to touch `/run/`; on return to
nobody uid the PID file stays. Also: `/var/lib/libvirt/dnsmasq/` must be `nobody:nogroup 775`
so leaseshelper can update `virbr-boma.status`. This fix is host-local on ubongo and NOT in
Ansible — encode it in an `integration_test` role task (or a libvirt role) before the harness
can be safely re-deployed.
- `[gotcha]` **cloud-init rejects underscores in `local-hostname` → silently skips
network-config → VM never gets DHCP** (2026-06-19, task-3 integration gate): setting
`local-hostname: boma-it-askari_inputonly-<uuid>` caused cloud-init-local to consider the
hostname invalid and skip writing the network-config to the system. Systemd-networkd then
used the genericcloud default (no DHCP), so VMs got only IPv6 link-local. Fix in
`scripts/integration-vm.py`: `name.replace("_", "-")` in the meta-data hostname (disk paths
and virsh domain names keep the original underscore). Sanitization rule: RFC-952 hostnames
allow hyphens, not underscores.
--- ---
## Kaizen reviews — decisions ledger ## Kaizen reviews — decisions ledger

View file

@ -128,6 +128,7 @@
6. Supply-chain hygiene: enforce tiered image pinning (stateful `tag@digest`; 6. Supply-chain hygiene: enforce tiered image pinning (stateful `tag@digest`;
stateless rolling tags — ADR-011) + official/verified images via the service stateless rolling tags — ADR-011) + official/verified images via the service
checklist; revisit active scanning (Trivy/Grype) once a triage stack exists (R1). checklist; revisit active scanning (Trivy/Grype) once a triage stack exists (R1).
7. Is our network setup as it should be? I am not sure if all traffic between ubongo and notes goes via askari? what if askari breaks - will the rest work?
16. **ADR-011 (update management) — resolve open questions + accept.** Committed as 16. **ADR-011 (update management) — resolve open questions + accept.** Committed as
**Proposed**; resolve before marking Accepted: **Proposed**; resolve before marking Accepted:

View file

@ -1,17 +1,21 @@
--- ---
# Off-site hosts (askari). askari runs the NetBird coordinator AND is a mesh peer # Off-site hosts (askari). askari runs the NetBird coordinator AND is a mesh peer
# (ADR-016, M5). base__mesh_enabled stays true (M5 enrollment). # (ADR-016, M5).
# #
# Mesh-hardening 1/3 (move SSH onto wt0 + nftables default-deny) was attempted on # Mesh-hardening REDESIGN (2026-06-19): the 2026-06-17 attempt was backed out (forward
# 2026-06-17 and BACKED OUT after it took askari down: applying base's nftables # `policy drop` broke Docker on reboot; wt0-only sshd left no break-glass; ip_nonlocal_bind
# `forward policy drop` to a Docker host broke container forwarding/NAT on reboot, and the # did not beat the boot-race). The redesign mirrors the proven ubongo 2/3 pattern:
# wt0-only sshd ListenAddress left no break-glass (ip_nonlocal_bind did not beat the boot # - INPUT-only default-deny (base__firewall_input_only) — forward stays `policy accept`
# race). Until docker_host ships Docker-safe container-forward rules and the boot-race + # so Docker container forwarding/NAT survive a reboot;
# coordinator-bootstrap issues are re-designed, askari keeps: # - SSH scoped by the host firewall (iifname wt0 + admin-addr), NOT a sshd ListenAddress
# - sshd listening on all interfaces (reachable over the WAN; Hetzner Cloud Firewall is # change — base__ssh_listen_mesh_only stays false, so there is no boot-race;
# the perimeter) — base__ssh_listen_mesh_only stays false, # - WAN :22 is DELIBERATELY left open from ubongo's WAN IP (base__firewall_admin_addrs)
# - the host nftables firewall NOT applied — base__firewall_apply false. # as the permanent non-mesh break-glass — the coordinator-host exception (a host's only
# See the incident write-up / the mesh-hardening re-spec before re-enabling either. # management path must never depend on a service that host itself hosts).
# Spec: docs/superpowers/specs/2026-06-19-mesh-hardening-askari-redesign-design.md
base__mesh_enabled: true base__mesh_enabled: true
base__ssh_listen_mesh_only: false base__firewall_apply: true
base__firewall_apply: false base__firewall_input_only: true # forward stays `policy accept` → Docker-safe
base__ssh_listen_mesh_only: false # no sshd ListenAddress change → no boot-race
base__firewall_admin_addrs:
- 91.226.145.80 # ubongo's (static) WAN IP — the permanent non-mesh SSH break-glass

View file

@ -0,0 +1,7 @@
---
# Manage askari over the NetBird mesh (wt0). Overrides the TF-generated WAN `ansible_host`
# in offsite.yml (host_vars are NOT regenerated by tf_to_inventory.py). The WAN :22 path
# (Hetzner Cloud Firewall + base__firewall_admin_addrs = ubongo's WAN) stays as the
# break-glass; the Hetzner web console is the IP-independent ultimate fallback.
# Spec: docs/superpowers/specs/2026-06-19-mesh-hardening-askari-redesign-design.md
ansible_host: 100.99.226.39

View file

@ -46,6 +46,7 @@ upstream support; WS/gRPC need long timeouts (Caddy sets none by default).
| `netbird_coordinator__domain` | `netbird.askari.wingu.me` | Public hostname; feeds `exposedAddress`, the OIDC issuer, redirect URIs, and the dashboard endpoints | | `netbird_coordinator__domain` | `netbird.askari.wingu.me` | Public hostname; feeds `exposedAddress`, the OIDC issuer, redirect URIs, and the dashboard endpoints |
| `netbird_coordinator__trusted_proxies` | `["172.16.0.0/12"]` | Source ranges NetBird trusts `X-Forwarded-*` from (`server.reverseProxy.trustedHTTPProxies`). Must cover Caddy's source IP on the boma network — verify the actual bridge subnet at deploy | | `netbird_coordinator__trusted_proxies` | `["172.16.0.0/12"]` | Source ranges NetBird trusts `X-Forwarded-*` from (`server.reverseProxy.trustedHTTPProxies`). Must cover Caddy's source IP on the boma network — verify the actual bridge subnet at deploy |
| `netbird_coordinator__manage` | `true` | Set `false` in Molecule to render templates without a Docker daemon | | `netbird_coordinator__manage` | `true` | Set `false` in Molecule to render templates without a Docker daemon |
| `netbird_coordinator__disable_geolocation` | `true` | sets `NB_DISABLE_GEOLOCATION` so a no-egress startup can't FATAL the server on the GeoLite2 download (FRICTION 2026-06-17 #4) |
Production overrides live in `inventories/production/group_vars/`. Production overrides live in `inventories/production/group_vars/`.

View file

@ -6,6 +6,13 @@ netbird_coordinator__dashboard_image: "netbirdio/dashboard:v2.39.0"
netbird_coordinator__base_dir: /opt/services/netbird netbird_coordinator__base_dir: /opt/services/netbird
netbird_coordinator__domain: netbird.askari.wingu.me netbird_coordinator__domain: netbird.askari.wingu.me
# Disable NetBird's GeoLite2 geolocation (download + lookups). boma uses no geo posture
# (ACL is Allow-All), and the combined server treats a failed GeoLite2 download as FATAL —
# so a transient egress loss (NAT wiped on `nft flush`, or the boot window before Docker
# re-adds NAT) would crash-loop the whole control plane (FRICTION 2026-06-17 #4). Disabling
# removes that dependency. Revisit if a future ACL sub-project wants geo-based posture.
netbird_coordinator__disable_geolocation: true
# Source IP ranges Caddy fronts NetBird from, rendered into config.yaml # Source IP ranges Caddy fronts NetBird from, rendered into config.yaml
# server.reverseProxy.trustedHTTPProxies. NetBird trusts X-Forwarded-* only from # server.reverseProxy.trustedHTTPProxies. NetBird trusts X-Forwarded-* only from
# these. MUST cover the Caddy container's source IP on the boma Docker network — # these. MUST cover the Caddy container's source IP on the boma Docker network —

View file

@ -30,3 +30,12 @@
- "'v2.39.0' in (_compose.content | b64decode)" - "'v2.39.0' in (_compose.content | b64decode)"
fail_msg: "docker-compose.yml is missing pinned image tags" fail_msg: "docker-compose.yml is missing pinned image tags"
success_msg: "docker-compose.yml pins both image tags" success_msg: "docker-compose.yml pins both image tags"
- name: "Assert geolocation is disabled (FRICTION 2026-06-17 #4 — no geo-DB download FATAL)"
ansible.builtin.assert:
that:
- "'NB_DISABLE_GEOLOCATION: \"true\"' in (_compose.content | b64decode)"
fail_msg: >-
compose must set NB_DISABLE_GEOLOCATION=true so a no-egress startup can't FATAL
the coordinator on the GeoLite2 download
success_msg: "geolocation disabled in compose"

View file

@ -16,6 +16,10 @@ services:
container_name: netbird-server container_name: netbird-server
restart: unless-stopped restart: unless-stopped
command: ["--config", "/etc/netbird/config.yaml"] command: ["--config", "/etc/netbird/config.yaml"]
environment:
# Disable geolocation so a no-egress startup can't FATAL the control plane
# (FRICTION 2026-06-17 #4). boma uses no geo posture (ACL Allow-All).
NB_DISABLE_GEOLOCATION: "{{ netbird_coordinator__disable_geolocation | string | lower }}"
ports: ports:
- "3478:3478/udp" - "3478:3478/udp"
volumes: volumes:

View file

@ -106,6 +106,12 @@ def render_run_hosts(name, ip, ansible_user, groups):
f" {name}:", f" {name}:",
f" ansible_host: {ip}", f" ansible_host: {ip}",
f" ansible_user: {ansible_user}", f" ansible_user: {ansible_user}",
# Integration VMs reuse IPs; bypass host-key caching so stale
# known_hosts entries (from prior runs with a different VM at
# the same IP) do not block the Ansible apply step.
" ansible_ssh_common_args: >-",
" -o StrictHostKeyChecking=no",
" -o UserKnownHostsFile=/dev/null",
] ]
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
@ -188,15 +194,22 @@ def up(host, name=None, mem_mib=DEFAULT_MEM_MIB, vcpus=DEFAULT_VCPUS):
overlay = CACHE_DIR / f"{name}.qcow2" overlay = CACHE_DIR / f"{name}.qcow2"
sh(["qemu-img", "create", "-f", "qcow2", "-F", "qcow2", "-b", str(img), str(overlay)]) sh(["qemu-img", "create", "-f", "qcow2", "-F", "qcow2", "-b", str(img), str(overlay)])
(RUN_DIR / "user-data").write_text(render_user_data(_ssh_pubkey(), "ansible")) (RUN_DIR / "user-data").write_text(render_user_data(_ssh_pubkey(), "ansible"))
(RUN_DIR / "meta-data").write_text(render_meta_data(f"iid-{name}", name)) # cloud-init rejects underscores in local-hostname (causes init-local to skip
# writing the network config → VM never gets a DHCP lease). Sanitize VM name
# for use as hostname without affecting disk paths or virsh domain names.
(RUN_DIR / "meta-data").write_text(render_meta_data(f"iid-{name}", name.replace("_", "-")))
seed = CACHE_DIR / f"{name}-seed.img" seed = CACHE_DIR / f"{name}-seed.img"
# Force DHCP on the VM NIC — don't rely on the genericcloud image's network fallback. # Force DHCP on the VM NIC — don't rely on the genericcloud image's network fallback.
# Use explicit renderer + interface name to avoid a netplan 1.1.2 generation issue:
# `match.name: en*` with a named key (e.g. `primary`) produces a .network file that
# networkd loads but never DHCPs (no DHCP4 messages, just IPv6LL). Using the real
# interface name `enp1s0` (all virtio NICs in these KVM VMs are named enp1s0) and
# `renderer: networkd` bypasses the bug.
(RUN_DIR / "network-config").write_text( (RUN_DIR / "network-config").write_text(
'version: 2\n' 'version: 2\n'
'renderer: networkd\n'
'ethernets:\n' 'ethernets:\n'
' primary:\n' ' enp1s0:\n'
' match:\n'
' name: "en*"\n'
' dhcp4: true\n') ' dhcp4: true\n')
sh(["cloud-localds", "--network-config", str(RUN_DIR / "network-config"), sh(["cloud-localds", "--network-config", str(RUN_DIR / "network-config"),
str(seed), str(RUN_DIR / "user-data"), str(RUN_DIR / "meta-data")]) str(seed), str(RUN_DIR / "user-data"), str(RUN_DIR / "meta-data")])

View file

@ -0,0 +1,17 @@
---
# Integration overlay (ADR-025) — the askari mesh-hardening REDESIGN (2026-06-19).
# Validates INPUT-only default-deny on a Docker host: input policy drop, forward policy
# accept (Docker-safe), SSH via the admin-addr break-glass, reboot-survivable.
integration_profile: askari_inputonly
base__firewall_apply: true
base__firewall_input_only: true
# No sshd ListenAddress change — never wt0-only in a throwaway VM.
base__ssh_listen_mesh_only: false
# Isolated VM: never touch the real mesh.
base__mesh_enabled: false
# The non-mesh SSH break-glass = the admin-addr path the real design uses. Point it at the
# VM's libvirt-NAT gateway (where the harness connects from), by source IP so it is
# interface-independent and the default-deny + reboot don't lock out the driver. This
# mirrors askari's real base__firewall_admin_addrs (ubongo's WAN) in the test topology.
base__firewall_admin_addrs:
- 192.168.150.1

View file

@ -0,0 +1,10 @@
{
"groups": ["offsite_hosts"],
"applies": [
{"playbook": "site.yml", "tags": ["base"]},
{"playbook": "offsite.yml", "tags": ["docker_host", "reverse_proxy"]}
],
"extra_vars_files": ["overrides/askari_inputonly.yml"],
"mem_mib": 3072,
"vcpus": 2
}

View file

@ -11,8 +11,8 @@
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- integration_profile is defined - integration_profile is defined
- integration_profile in ['askari', 'ubongo'] - integration_profile in ['askari', 'askari_inputonly', 'ubongo']
fail_msg: "integration_profile must be set in the profile overlay (askari|ubongo)" fail_msg: "integration_profile must be set in the profile overlay (askari|askari_inputonly|ubongo)"
# ── askari profile — Docker host: published-port forwarding survives the reboot ── # ── askari profile — Docker host: published-port forwarding survives the reboot ──
# The load-bearing check probes the VM's published :80 FROM the controller (ubongo) — if # The load-bearing check probes the VM's published :80 FROM the controller (ubongo) — if
@ -83,3 +83,47 @@
ubongo profile: expected input policy drop, forward policy accept (input-only), ubongo profile: expected input policy drop, forward policy accept (input-only),
the ssh-from-control lifeline (192.168.150.1), and both admin-addr the ssh-from-control lifeline (192.168.150.1), and both admin-addr
(192.168.150.98/99) SSH allows in the live ruleset. (192.168.150.98/99) SSH allows in the live ruleset.
# ── askari_inputonly profile — the mesh-hardening REDESIGN (2026-06-19) ──
# INPUT-only default-deny on a Docker host: input policy drop, forward policy ACCEPT
# (Docker-safe), SSH via the admin-addr break-glass, published-port DNAT survives reboot.
- name: (askari_inputonly) Read the live nftables ruleset
when: integration_profile == 'askari_inputonly'
ansible.builtin.command: nft list ruleset
register: _nft_io
changed_when: false
- name: (askari_inputonly) INPUT default-deny, forward permissive, admin-addr break-glass
when: integration_profile == 'askari_inputonly'
ansible.builtin.assert:
that:
- "'hook input priority filter; policy drop;' in _nft_io.stdout"
- "'hook forward priority filter; policy accept;' in _nft_io.stdout"
- "'ip saddr 192.168.150.1 tcp dport 22 accept' in _nft_io.stdout"
fail_msg: >-
askari_inputonly: expected input policy drop, forward policy accept (input-only),
and the admin-addr break-glass (192.168.150.1) SSH allow in the live ruleset.
- name: (askari_inputonly) Gather service facts
when: integration_profile == 'askari_inputonly'
ansible.builtin.service_facts:
- name: (askari_inputonly) Docker daemon is active
when: integration_profile == 'askari_inputonly'
ansible.builtin.assert:
that: "ansible_facts.services['docker.service'].state == 'running'"
fail_msg: "docker.service is not running"
- name: (askari_inputonly) Published port answers from the controller (DNAT + forward alive)
when: integration_profile == 'askari_inputonly'
delegate_to: localhost
become: false
ansible.builtin.uri:
url: "http://{{ ansible_host }}/"
follow_redirects: none
status_code: [200, 301, 308, 404, 502, 503]
timeout: 10
register: _probe_io
retries: 5
delay: 6
until: _probe_io is succeeded