Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
251 lines
7.4 KiB
YAML
251 lines
7.4 KiB
YAML
---
|
|
- 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') }}"
|