Compare commits
No commits in common. "b0511179cbffc239005ec737107b0b728a3bc638" and "dfa363cecd3e9844886ccd016f7a0531398b378c" have entirely different histories.
b0511179cb
...
dfa363cecd
14 changed files with 14 additions and 130 deletions
|
|
@ -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 }
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -26,18 +26,13 @@ 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.
|
|
||||||
dynamic "rule" {
|
|
||||||
for_each = length(var.ssh_admin_cidrs) > 0 ? [1] : []
|
|
||||||
content {
|
|
||||||
direction = "in"
|
direction = "in"
|
||||||
protocol = "tcp"
|
protocol = "tcp"
|
||||||
port = "22"
|
port = "22"
|
||||||
source_ips = var.ssh_admin_cidrs
|
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
|
||||||
# (ADR-024, M4). Host nftables stays catalog-driven (ADR-020).
|
# (ADR-024, M4). Host nftables stays catalog-driven (ADR-020).
|
||||||
|
|
|
||||||
|
|
@ -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" {
|
||||||
|
|
|
||||||
|
|
@ -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"]}]
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue