diff --git a/roles/base/defaults/main.yml b/roles/base/defaults/main.yml index 681f0c2..6073a1a 100644 --- a/roles/base/defaults/main.yml +++ b/roles/base/defaults/main.yml @@ -21,6 +21,14 @@ base__fail2ban_findtime: 10m # base__ssh_authorised_keys lives in group_vars/all/vars.yml (per-person control 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 # 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 diff --git a/roles/base/molecule/default/converge.yml b/roles/base/molecule/default/converge.yml index 59a1284..61d44c9 100644 --- a/roles/base/molecule/default/converge.yml +++ b/roles/base/molecule/default/converge.yml @@ -11,6 +11,8 @@ base__mesh_enabled: true base__mesh_manage: false 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: lan: 10.30.0.0/24 srv: 10.20.0.0/24 diff --git a/roles/base/molecule/default/molecule.yml b/roles/base/molecule/default/molecule.yml index b23d8da..4c17329 100644 --- a/roles/base/molecule/default/molecule.yml +++ b/roles/base/molecule/default/molecule.yml @@ -19,6 +19,11 @@ platforms: volumes: - /sys/fs/cgroup:/sys/fs/cgroup:rw 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: name: ansible diff --git a/roles/base/molecule/default/prepare.yml b/roles/base/molecule/default/prepare.yml new file mode 100644 index 0000000..3690940 --- /dev/null +++ b/roles/base/molecule/default/prepare.yml @@ -0,0 +1,11 @@ +--- +- 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 diff --git a/roles/base/molecule/default/verify.yml b/roles/base/molecule/default/verify.yml index d1e0551..ae20e37 100644 --- a/roles/base/molecule/default/verify.yml +++ b/roles/base/molecule/default/verify.yml @@ -58,6 +58,18 @@ ansible.builtin.command: grep -q '^\[sshd\]' /etc/fail2ban/jail.d/sshd.local 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) - name: Check whether netbird got installed ansible.builtin.command: which netbird diff --git a/roles/base/tasks/ssh.yml b/roles/base/tasks/ssh.yml index 82aecee..2985b77 100644 --- a/roles/base/tasks/ssh.yml +++ b/roles/base/tasks/ssh.yml @@ -1,4 +1,31 @@ --- +- 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 ansible.builtin.apt: name: openssh-server diff --git a/roles/base/templates/sshd_hardening.conf.j2 b/roles/base/templates/sshd_hardening.conf.j2 index d0a845e..53f07fe 100644 --- a/roles/base/templates/sshd_hardening.conf.j2 +++ b/roles/base/templates/sshd_hardening.conf.j2 @@ -3,3 +3,6 @@ PasswordAuthentication {{ base__ssh_password_authentication }} PermitRootLogin {{ base__ssh_permit_root_login }} PubkeyAuthentication yes KbdInteractiveAuthentication no +{% if base__ssh_listen_mesh_only | bool %} +ListenAddress {{ base__ssh_listen_addr_resolved }} +{% endif %}