diff --git a/roles/base/defaults/main.yml b/roles/base/defaults/main.yml index 301dee7..774e911 100644 --- a/roles/base/defaults/main.yml +++ b/roles/base/defaults/main.yml @@ -11,6 +11,14 @@ base__firewall_rollback_timeout: 45 # seconds before the auto-revert fires on a base__firewall_confirm_timeout: 20 # seconds to re-establish a fresh connection post-apply base__firewall_dropin_dir: /etc/nftables.d base__firewall_apply: true # set false to render+validate without applying (CI/Molecule) +base__firewall_input_only: false # true → the forward chain is `policy accept` (host-local + # INPUT filtering only). For hosts that forward/route + # container or NAT traffic (the control node's Docker + + # libvirt-NAT) where a forward default-deny would break + # them. Real service hosts keep this false (forward drop). +base__firewall_admin_addrs: [] # extra LAN source IPs allowed to SSH, besides wt0 + + # ssh-from-control. For an operator workstation reaching + # the host over the LAN (no mesh). Key-gated. (ADR-021) # SSH hardening + fail2ban (ADR-002) — `hardening` concern. base__ssh_password_authentication: "no" diff --git a/roles/base/molecule/default/converge.yml b/roles/base/molecule/default/converge.yml index 88afbae..6ab934d 100644 --- a/roles/base/molecule/default/converge.yml +++ b/roles/base/molecule/default/converge.yml @@ -6,6 +6,8 @@ vars: base__firewall_apply: false base__firewall_control_addr: 10.10.0.99 # test control-node LAN address + base__firewall_admin_addrs: + - "10.30.0.77" # fixture: an operator-workstation LAN source (admin-addr SSH allow) # Exercise the mesh concern's include path with the live actions gated off, so it # runs hermetically (no coordinator/key needed) and must be a clean no-op. base__mesh_enabled: true diff --git a/roles/base/molecule/default/verify.yml b/roles/base/molecule/default/verify.yml index 2557f69..d3a7741 100644 --- a/roles/base/molecule/default/verify.yml +++ b/roles/base/molecule/default/verify.yml @@ -51,6 +51,20 @@ - "'include \"/etc/nftables.d/*.nft\"' in nft" fail_msg: "missing drop-in include hook" + - name: Assert the forward chain defaults to policy drop (input_only off) + ansible.builtin.assert: + that: + - "'hook forward priority 0; policy drop;' in nft" + fail_msg: >- + forward chain must default to policy drop when base__firewall_input_only is + false (container isolation stays the norm on real service hosts) + + - name: Assert the admin-addr SSH allow rule (operator workstation on the LAN) + ansible.builtin.assert: + that: + - "'ip saddr 10.30.0.77 tcp dport 22 accept' in nft" + fail_msg: "missing admin-addr SSH allow rule from base__firewall_admin_addrs" + - name: Syntax-check the rendered ruleset (no apply) ansible.builtin.command: nft -c -f /etc/nftables.conf changed_when: false diff --git a/roles/base/templates/nftables.conf.j2 b/roles/base/templates/nftables.conf.j2 index b85ff86..ce33b53 100644 --- a/roles/base/templates/nftables.conf.j2 +++ b/roles/base/templates/nftables.conf.j2 @@ -12,13 +12,16 @@ table inet filter { {% if base__firewall_control_addr %} ip saddr {{ base__firewall_control_addr }} tcp dport {{ base__firewall_ssh_port }} accept {% endif %} +{% for addr in base__firewall_admin_addrs %} + ip saddr {{ addr }} tcp dport {{ base__firewall_ssh_port }} accept +{% endfor %} ip protocol icmp accept ip6 nexthdr ipv6-icmp accept {% for r in base__firewall_resolved %} ip saddr { {{ r.sources | join(', ') }} } {{ r.proto }} dport {{ r.port }} accept {% endfor %} } - chain forward { type filter hook forward priority 0; policy drop; } + chain forward { type filter hook forward priority 0; policy {{ 'accept' if base__firewall_input_only | bool else 'drop' }}; } chain output { type filter hook output priority 0; policy accept; } }