Compare commits

..

No commits in common. "b0511179cbffc239005ec737107b0b728a3bc638" and "dfa363cecd3e9844886ccd016f7a0531398b378c" have entirely different histories.

14 changed files with 14 additions and 130 deletions

View file

@ -2,27 +2,14 @@
# Shared firewall topology — single source of truth for the host nftables layer # Shared firewall topology — single source of truth for the host nftables layer
# (base role) and OPNsense (future). See docs/decisions/020-firewall.md. # (base role) and OPNsense (future). See docs/decisions/020-firewall.md.
# Zone → subnet (from ADR-007). `public` = the WAN (anywhere) for deliberately public # Zone → subnet (from ADR-007).
# off-site services (askari); home/cluster services use the internal zones only.
firewall_zones: firewall_zones:
mgmt: 10.10.0.0/24 mgmt: 10.10.0.0/24
srv: 10.20.0.0/24 srv: 10.20.0.0/24
lan: 10.30.0.0/24 lan: 10.30.0.0/24
iot: 10.40.0.0/24 iot: 10.40.0.0/24
guest: 10.50.0.0/24 guest: 10.50.0.0/24
public: 0.0.0.0/0
# Service catalog: <name> → placement (host | group | hosts) + ingress[]. # Service catalog: <name> → placement (host | group | hosts) + ingress[].
# askari's public surface (ADR-024 Caddy + ADR-016 NetBird STUN). NOTE: the host # Empty until services are built; hosts still get default-deny + the management plane.
# nftables template renders IPv4 source rules only; askari is reached via its A record firewall_catalog: {}
# (no AAAA), so IPv4-only public rules are sufficient (see the spec's IPv6 note).
firewall_catalog:
reverse_proxy:
host: askari
ingress:
- { from: public, port: 80, proto: tcp }
- { from: public, port: 443, proto: tcp }
netbird_stun:
host: askari
ingress:
- { from: public, port: 3478, proto: udp }

View file

@ -1,8 +1,6 @@
--- ---
# 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). Mesh-hardening 1/3 (2026-06-17): SSH is moved onto wt0 — sshd binds the # (ADR-016, M5) — enrol the agent via base's `mesh` concern. Enrollment only; the
# mesh IP only (base__ssh_listen_mesh_only) and the base nftables default-deny applies # host firewall default-deny + moving askari's SSH onto wt0 stay deferred to the
# (base__firewall_apply defaults true; SSH allowed on wt0 via base__firewall_mgmt_interface, # mesh-hardening follow-on.
# public services via the catalog). base__mesh_enabled stays true (precondition from M5).
base__mesh_enabled: true base__mesh_enabled: true
base__ssh_listen_mesh_only: true

View file

@ -1,6 +0,0 @@
---
# Manage askari over the NetBird mesh (wt0), not its WAN IP. This OVERRIDES the
# TF-generated inventories/production/offsite.yml (ansible_host = 77.42.120.136); host_vars
# outrank the generated inventory and are NOT touched by `make tf-inventory-offsite`.
# Mesh-hardening 1/3 — once SSH is wt0-only, the WAN IP is no longer reachable for SSH.
ansible_host: 100.99.226.39 # askari's wt0 address (NetBird, M5)

View file

@ -21,14 +21,6 @@ base__fail2ban_findtime: 10m
# base__ssh_authorised_keys lives in group_vars/all/vars.yml (per-person control keys). # base__ssh_authorised_keys lives in group_vars/all/vars.yml (per-person control keys).
base__ssh_authorised_keys: [] base__ssh_authorised_keys: []
# SSH listen-on-mesh (mesh-hardening 1/3, ADR-016/021). Opt-in: when true, sshd binds
# ListenAddress to this host's mesh IP only (not the WAN). The IP comes from the live wt0
# fact (ansible_facts.wt0.ipv4.address); base__ssh_listen_addr overrides it. ip_nonlocal_bind
# lets sshd bind the mesh IP before wt0 exists at boot. Fails closed: the play asserts a
# non-empty address rather than silently listening on all interfaces.
base__ssh_listen_mesh_only: false
base__ssh_listen_addr: ""
# NetBird mesh agent enrollment (ADR-016). Opt-in: default off so applying `base` to a # NetBird mesh agent enrollment (ADR-016). Opt-in: default off so applying `base` to a
# host not on the mesh is a no-op for this concern. The live actions (apt install over # host not on the mesh is a no-op for this concern. The live actions (apt install over
# the network, `netbird up` against the coordinator) are additionally gated by # the network, `netbird up` against the coordinator) are additionally gated by

View file

@ -11,13 +11,10 @@
base__mesh_enabled: true base__mesh_enabled: true
base__mesh_manage: false base__mesh_manage: false
base__mesh_setup_key: "dummy-molecule-key" base__mesh_setup_key: "dummy-molecule-key"
base__ssh_listen_mesh_only: true
base__ssh_listen_addr: "100.99.0.1" # fixture mesh IP (no wt0 in the container)
firewall_zones: firewall_zones:
lan: 10.30.0.0/24 lan: 10.30.0.0/24
srv: 10.20.0.0/24 srv: 10.20.0.0/24
mgmt: 10.10.0.0/24 mgmt: 10.10.0.0/24
public: 0.0.0.0/0
firewall_catalog: firewall_catalog:
reverse_proxy: reverse_proxy:
host: instance host: instance
@ -27,9 +24,5 @@
host: instance host: instance
ingress: ingress:
- { from: srv, port: 2342, proto: tcp } - { from: srv, port: 2342, proto: tcp }
netbird_stun:
host: instance
ingress:
- { from: public, port: 3478, proto: udp }
roles: roles:
- role: base - role: base

View file

@ -19,11 +19,6 @@ platforms:
volumes: volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw - /sys/fs/cgroup:/sys/fs/cgroup:rw
command: /lib/systemd/systemd command: /lib/systemd/systemd
# Pre-create the namespaced sysctl so ansible.posix.sysctl can set it (mesh-hardening 1/3).
# The container image lacks procps so the sysctl binary is absent; we also install it in
# prepare.yml. This entry ensures the value exists in the container's netns at startup.
sysctls:
net.ipv4.ip_nonlocal_bind: "0"
provisioner: provisioner:
name: ansible name: ansible

View file

@ -1,11 +0,0 @@
---
- name: Prepare
hosts: all
become: true
gather_facts: false
tasks:
- name: Install procps so ansible.posix.sysctl can find the sysctl binary
ansible.builtin.apt:
name: procps
state: present
update_cache: true

View file

@ -38,13 +38,6 @@
- "'tcp dport 2342 accept' in nft" - "'tcp dport 2342 accept' in nft"
fail_msg: "missing srv->2342 rule for photoprism" fail_msg: "missing srv->2342 rule for photoprism"
- name: Assert the public->stun:3478/udp ingress rule (0.0.0.0/0 source)
ansible.builtin.assert:
that:
- "'0.0.0.0/0' in nft"
- "'udp dport 3478 accept' in nft"
fail_msg: "missing public->3478/udp rule for netbird_stun"
- name: Assert the docker_host extension hook is present - name: Assert the docker_host extension hook is present
ansible.builtin.assert: ansible.builtin.assert:
that: that:
@ -65,18 +58,6 @@
ansible.builtin.command: grep -q '^\[sshd\]' /etc/fail2ban/jail.d/sshd.local ansible.builtin.command: grep -q '^\[sshd\]' /etc/fail2ban/jail.d/sshd.local
changed_when: false changed_when: false
- name: ListenAddress bound to the fixture mesh IP (mesh-only mode)
ansible.builtin.command: grep -q '^ListenAddress 100.99.0.1$' /etc/ssh/sshd_config.d/10-boma.conf
changed_when: false
- name: Sysctl drop-in for ip_nonlocal_bind is present
ansible.builtin.command: grep -q '^net.ipv4.ip_nonlocal_bind=1' /etc/sysctl.d/60-boma-nonlocal-bind.conf
changed_when: false
- name: Kernel ip_nonlocal_bind is live in this netns
ansible.builtin.command: sysctl -n net.ipv4.ip_nonlocal_bind
register: _nonlocal
changed_when: false
failed_when: _nonlocal.stdout | trim != '1'
# mesh concern: enabled but manage=false must be a clean no-op (no install/enrol) # mesh concern: enabled but manage=false must be a clean no-op (no install/enrol)
- name: Check whether netbird got installed - name: Check whether netbird got installed
ansible.builtin.command: which netbird ansible.builtin.command: which netbird

View file

@ -1,31 +1,4 @@
--- ---
- name: Resolve the sshd mesh listen address (override, else live wt0 fact)
ansible.builtin.set_fact:
base__ssh_listen_addr_resolved: >-
{{ base__ssh_listen_addr
or ansible_facts.get('wt0', {}).get('ipv4', {}).get('address', '') }}
when: base__ssh_listen_mesh_only | bool
- name: Fail closed — refuse to render sshd without a known mesh address
ansible.builtin.assert:
that:
- base__ssh_listen_addr_resolved | length > 0
fail_msg: >-
base__ssh_listen_mesh_only is true but no mesh address resolved (set
base__ssh_listen_addr or ensure wt0 is up so its fact is gathered). Refusing to
render sshd ListenAddress empty (which would listen on ALL interfaces).
when: base__ssh_listen_mesh_only | bool
- name: Allow sshd to bind the mesh IP before wt0 exists at boot
ansible.posix.sysctl:
name: net.ipv4.ip_nonlocal_bind
value: "1"
sysctl_set: true
state: present
reload: true
sysctl_file: /etc/sysctl.d/60-boma-nonlocal-bind.conf
when: base__ssh_listen_mesh_only | bool
- name: Ensure openssh-server is installed - name: Ensure openssh-server is installed
ansible.builtin.apt: ansible.builtin.apt:
name: openssh-server name: openssh-server

View file

@ -3,6 +3,3 @@ PasswordAuthentication {{ base__ssh_password_authentication }}
PermitRootLogin {{ base__ssh_permit_root_login }} PermitRootLogin {{ base__ssh_permit_root_login }}
PubkeyAuthentication yes PubkeyAuthentication yes
KbdInteractiveAuthentication no KbdInteractiveAuthentication no
{% if base__ssh_listen_mesh_only | bool %}
ListenAddress {{ base__ssh_listen_addr_resolved }}
{% endif %}

View file

@ -11,7 +11,7 @@ module "askari" {
location = "hel1" # Helsinki location = "hel1" # Helsinki
image = "debian-13" image = "debian-13"
ansible_ssh_pubkey = var.ansible_ssh_pubkey ansible_ssh_pubkey = var.ansible_ssh_pubkey
ssh_admin_cidrs = [] # mesh-only: SSH is reached over wt0; WAN :22 retired (mesh-hardening 1/3) ssh_admin_cidrs = var.ssh_admin_cidrs
public_web = true # Caddy 80/443 + NetBird 3478 (M4) public_web = true # Caddy 80/443 + NetBird 3478 (M4)
labels = { labels = {
env = "offsite" env = "offsite"

View file

@ -26,17 +26,12 @@ resource "hcloud_ssh_key" "ansible" {
resource "hcloud_firewall" "this" { resource "hcloud_firewall" "this" {
name = "${var.name}-fw" name = "${var.name}-fw"
# SSH from the control node only and only when admin CIDRs are set. An empty # SSH from the control node only.
# ssh_admin_cidrs removes the WAN :22 rule entirely (mesh-only SSH; reach the host over rule {
# wt0, break-glass = Hetzner console). Mesh-hardening 1/3. direction = "in"
dynamic "rule" { protocol = "tcp"
for_each = length(var.ssh_admin_cidrs) > 0 ? [1] : [] port = "22"
content { source_ips = var.ssh_admin_cidrs
direction = "in"
protocol = "tcp"
port = "22"
source_ips = var.ssh_admin_cidrs
}
} }
# Public web (Caddy 80/443) + NetBird STUN/TURN (3478/udp) only when public_web # Public web (Caddy 80/443) + NetBird STUN/TURN (3478/udp) only when public_web

View file

@ -24,9 +24,8 @@ variable "ansible_ssh_pubkey" {
} }
variable "ssh_admin_cidrs" { variable "ssh_admin_cidrs" {
description = "Source CIDRs allowed to reach SSH over the WAN. Empty = no WAN SSH rule (mesh-only)." description = "Source CIDRs allowed to reach SSH (e.g. ubongo's address/32)"
type = list(string) type = list(string)
default = []
} }
variable "public_web" { variable "public_web" {

View file

@ -97,12 +97,3 @@ def test_ingress_missing_port_raises():
cat = {"svc": {"host": "docker01", "ingress": [{"from": "lan"}]}} cat = {"svc": {"host": "docker01", "ingress": [{"from": "lan"}]}}
with pytest.raises(ValueError): with pytest.raises(ValueError):
fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS) fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
def test_public_zone_resolves_to_anywhere():
catalog = {"web": {"host": "askari",
"ingress": [{"from": "public", "port": 443, "proto": "tcp"}]}}
zones = {"public": "0.0.0.0/0"}
rules = fr.resolve_firewall_rules(catalog, zones, "askari",
{"askari": {"ansible_host": "100.99.226.39"}}, {})
assert rules == [{"proto": "tcp", "port": 443, "sources": ["0.0.0.0/0"]}]