--- - name: Require the executable baseline contract tags: [base, baseline] ansible.builtin.assert: that: - baseline_required_packages is defined - baseline_ssh_directives is defined - baseline_user is defined - baseline_security is defined - baseline_firewall is defined - ufw_manage == baseline_firewall.managed fail_msg: >- Resolve a baseline_profile from spec/server-baseline.yaml through the dynamic inventory before running this role. - name: Ensure base packages tags: [base, packages] ansible.builtin.package: name: "{{ baseline_required_packages }}" state: present update_cache: true - name: Harden SSH tags: [base, ssh] ansible.builtin.copy: dest: /etc/ssh/sshd_config.d/10-hardening.conf owner: root group: root mode: '0644' content: | {% for directive in baseline_ssh_directives | dict2items %} {{ directive.key }} {{ directive.value }} {% endfor %} notify: Restart sshd - name: Ensure baseline operator user exists tags: [base, user] ansible.builtin.user: name: "{{ baseline_user.name }}" state: present shell: "{{ baseline_user.shell }}" create_home: true - name: Ensure declared passwordless sudo posture tags: [base, user, sudo] ansible.builtin.copy: dest: "/etc/sudoers.d/{{ baseline_user.name }}" owner: root group: root mode: '0440' content: "{{ baseline_user.name }} ALL=(ALL) {{ baseline_user.sudo }}:ALL\n" validate: /usr/sbin/visudo -cf %s - name: Ensure .ssh directory exists for ops_bridge_user tags: [base, ssh] ansible.builtin.file: path: "/home/{{ ops_bridge_user | default('tegwick') }}/.ssh" state: directory owner: "{{ ops_bridge_user | default('tegwick') }}" group: "{{ ops_bridge_user | default('tegwick') }}" mode: '0700' - name: Inject ops-bridge public key into authorized_keys tags: [base, ssh] ansible.posix.authorized_key: user: "{{ ops_bridge_user | default('tegwick') }}" key: "{{ ops_bridge_pubkey }}" comment: "ops-bridge@{{ inventory_hostname }}" state: present when: ops_bridge_pubkey is defined and ops_bridge_pubkey | length > 0 - name: Configure UFW default incoming policy tags: [base, firewall, ufw] ansible.builtin.ufw: state: enabled policy: deny direction: incoming when: ufw_manage | bool - name: Allow UFW routing when VXLAN peers are declared tags: [base, firewall, ufw] ansible.builtin.ufw: policy: allow direction: routed when: ufw_manage | bool and (flannel_vxlan_allowed_sources | length > 0) - name: Allow SSH in UFW tags: [base, firewall, ufw] ansible.builtin.ufw: rule: allow name: OpenSSH when: ufw_manage | bool - name: Reject web ports in the generic UFW exception list tags: [base, firewall, ufw] ansible.builtin.assert: that: - (ufw_extra_allowed | selectattr('port', 'defined') | map(attribute='port') | map('string') | intersect(['80', '443'])) | length == 0 fail_msg: >- Ports 80/443 must use public_web_ports plus an ADR-0008 reef declaration; they cannot be smuggled through ufw_extra_allowed. when: ufw_manage | bool - name: Validate public web ports against the reef declaration tags: [base, firewall, ufw] become: false delegate_to: localhost ansible.builtin.command: argv: - python3 - "{{ role_path }}/../../../scripts/validate-reef-exposure.py" - --reef-declaration - "{{ reef_declaration_path }}" - --ports - "{{ public_web_ports | join(',') }}" changed_when: false when: ufw_manage | bool and (public_web_ports | length > 0) - name: Allow grant-backed public web ports tags: [base, firewall, ufw] ansible.builtin.ufw: rule: allow port: "{{ item | string }}" proto: tcp comment: "adr-0008-reef-public-web" loop: "{{ public_web_ports }}" when: ufw_manage | bool - name: Allow declared extra UFW ports tags: [base, firewall, ufw] ansible.builtin.ufw: rule: allow port: "{{ item.port }}" proto: "{{ item.proto | default('tcp') }}" comment: "{{ item.comment | default('extra-allow') }}" loop: "{{ ufw_extra_allowed }}" loop_control: label: "{{ item.port }}/{{ item.proto | default('tcp') }}" when: ufw_manage | bool # k3s API access is source-restricted and empty by default (tunnel-only). # See roles/base/defaults/main.yml and docs/adr/ADR-005-k3s-api-tunnel-only.md. # Order matters: remaining grants (if any) are added BEFORE the blanket rule # is removed, so a non-empty allowlist never opens a window without API access. - name: Allow k3s API from approved operator sources only tags: [base, firewall, ufw] ansible.builtin.ufw: rule: allow port: '6443' proto: tcp from_ip: "{{ item.address }}" comment: "{{ item.comment | default('k3s-api-operator') }}" loop: "{{ k3s_api_allowed_sources }}" loop_control: label: "{{ item.address }}" when: ufw_manage | bool - name: Remove blanket k3s API rule if present (must not be world-reachable) tags: [base, firewall, ufw] ansible.builtin.ufw: rule: allow port: '6443' proto: tcp delete: true when: ufw_manage | bool - name: Revoke k3s API access for retired operator sources tags: [base, firewall, ufw] ansible.builtin.ufw: rule: allow port: '6443' proto: tcp from_ip: "{{ item.address }}" delete: true loop: "{{ k3s_api_revoked_sources }}" loop_control: label: "{{ item.address }}" when: ufw_manage | bool - name: Warn when no operator source is allowed to reach the k3s API tags: [base, firewall, ufw] ansible.builtin.debug: msg: >- k3s_api_allowed_sources is empty, so 6443/tcp is closed to all external sources on this host. Reach the API over the ops-bridge tunnel (k3s-api-railiance01 on 16444, k3s-api-coulombcore on 16443). SSH is unaffected and the host remains recoverable. when: ufw_manage | bool and (k3s_api_allowed_sources | length == 0) - name: Allow Flannel VXLAN from declared cluster peers only tags: [base, firewall, ufw] ansible.builtin.ufw: rule: allow port: '8472' proto: udp from_ip: "{{ item.address }}" comment: "{{ item.comment | default('flannel-vxlan-peer') }}" loop: "{{ flannel_vxlan_allowed_sources }}" loop_control: label: "{{ item.address }}" when: ufw_manage | bool - name: Remove blanket Flannel VXLAN rule if present (must not be world-reachable) tags: [base, firewall, ufw] ansible.builtin.ufw: rule: allow port: '8472' proto: udp delete: true when: ufw_manage | bool - name: Enable fail2ban tags: [base, fail2ban] ansible.builtin.service: name: fail2ban state: started enabled: true - name: Configure declared fail2ban jails tags: [base, fail2ban] ansible.builtin.copy: dest: "/etc/fail2ban/jail.d/{{ item }}.conf" owner: root group: root mode: '0644' content: | [{{ item }}] enabled = true port = {{ 'ssh' if item == 'sshd' else item }} filter = {{ item }} maxretry = 5 bantime = 3600 findtime = 600 loop: "{{ baseline_security.fail2ban_jails }}" notify: Restart fail2ban - name: Set declared HISTCONTROL tags: [base, histcontrol] ansible.builtin.copy: dest: /etc/profile.d/histcontrol.sh owner: root group: root mode: '0644' content: | export HISTCONTROL={{ baseline_security.histcontrol }} - name: Set timezone tags: [base, timezone] community.general.timezone: name: "{{ timezone | default('UTC') }}"