Close RAIL-HO-WP-0009 declared-state gaps; leave live 6443 prune gated
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Make the k3s API tunnel-only (ADR-005), stop declaring Flannel VXLAN
open to Anywhere, tag the base role so firewall can be scoped, and
schedule the Goss declared-vs-live check. CoulombCore sets ufw_manage
false so a converge cannot enable UFW there. T02 still needs operator
approval for make converge-firewall HOST=Railiance01.
This commit is contained in:
codex 2026-08-15 15:41:59 +02:00
parent 434121be99
commit 4d9e77c968
29 changed files with 793 additions and 99 deletions

View file

@ -1,6 +1,12 @@
---
# Base role defaults.
# When false, this role will not enable or rewrite UFW. Use that for hosts
# whose live packet filter is not UFW (CoulombCore: iptables INPUT DROP with
# a Plesk-era accept list). Enabling UFW there is an availability decision,
# not a side effect of an unrelated converge.
ufw_manage: true
# Source addresses permitted to reach the k3s API (6443/tcp).
#
# WHY THIS EXISTS
@ -10,21 +16,45 @@
# config WEAKER than reality: re-running this role would have removed the
# restriction and exposed the Kubernetes API to the internet. Found 2026-08-11.
#
# The allowlist is therefore declared here and converged, not hand-edited.
# Operator addresses rotate (dynamic ISP leases). An allowlist is a treadmill:
# each rotation is either an outage or a stale grant to whoever the ISP
# reassigns the address to. RAIL-HO-WP-0009-T04 therefore keeps this list
# empty. Reach the API over the ops-bridge SSH tunnel
# (`k3s-api-railiance01`, local port 16444; `k3s-api-coulombcore`, 16443).
# See docs/adr/ADR-005-k3s-api-tunnel-only.md.
#
# Deliberately empty by default. A host that sets no sources gets NO public
# access to 6443 — which is the safe failure. SSH (22) is unaffected, so a host
# converged with an empty list is always recoverable.
#
# Set the real values in inventory/group_vars/all.yaml. Each entry:
# Each entry, if any:
# - address: "203.0.113.10"
# comment: "k3s-api-operator-workstation"
k3s_api_allowed_sources: []
# Source addresses whose k3s API access must be REMOVED on convergence.
#
# Operator addresses rotate (dynamic ISP leases). Without this, every rotation
# leaves a standing grant to an address the ISP has since reassigned to someone
# else. Move an address here when it stops being yours; convergence then prunes
# it rather than leaving it to accumulate.
# Move an address here when it stops being yours (or when the public
# allowlist is retired); convergence then prunes it.
k3s_api_revoked_sources: []
# Source addresses permitted to send Flannel VXLAN (8472/udp).
#
# Empty by default. A single-node cluster does not need a public VXLAN
# grant; adding an unrestricted 8472/udp allow would expose the pod network
# to injection. Set this to the other nodes' addresses only when the cluster
# becomes multi-node (RAIL-BS-WP-0007 / ThreePhoenix HA).
#
# Each entry:
# - address: "203.0.113.20"
# comment: "flannel-vxlan-peer"
flannel_vxlan_allowed_sources: []
# Extra UFW allows that are not k3s. Used for provider agents that must stay
# reachable (HostEurope Nydus on 2224/tcp). Empty by default so a Hetzner
# host does not inherit a HostEurope-only hole.
#
# Each entry:
# - port: "2224"
# proto: tcp
# comment: "nydus-ex-api dashboard agent"
ufw_extra_allowed: []

View file

@ -1,4 +1,9 @@
---
- name: Restart sshd
ansible.builtin.service:
name: ssh
state: restarted
- name: Restart fail2ban
ansible.builtin.service:
name: fail2ban

View file

@ -1,5 +1,6 @@
---
- name: Ensure base packages
tags: [base, packages]
ansible.builtin.package:
name:
- apt-transport-https
@ -15,6 +16,7 @@
update_cache: true
- name: Harden SSH
tags: [base, ssh]
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/10-hardening.conf
owner: root
@ -24,13 +26,10 @@
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
- name: Restart sshd
ansible.builtin.service:
name: ssh
state: restarted
notify: Restart sshd
- 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
@ -39,6 +38,7 @@
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 }}"
@ -47,27 +47,46 @@
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 (required for k3s flannel pod networking)
- 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
# k3s API access is source-restricted. See roles/base/defaults/main.yml for why
# this is declared rather than hand-applied. Order matters below: grants are
# added BEFORE the blanket rule is removed, so convergence never opens a window
# in which the operator cannot reach the API.
- 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'
@ -77,15 +96,19 @@
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'
@ -95,29 +118,49 @@
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. This is the safe default, not an error. SSH is
unaffected and the host remains recoverable. Set the allowlist in
inventory/group_vars/all.yaml to restore API access.
when: k3s_api_allowed_sources | length == 0
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 in UFW
- 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 fail2ban SSH jail
tags: [base, fail2ban]
ansible.builtin.copy:
dest: /etc/fail2ban/jail.d/sshd.conf
owner: root
@ -134,6 +177,7 @@
notify: Restart fail2ban
- name: Set HISTCONTROL to ignorespace
tags: [base, histcontrol]
ansible.builtin.copy:
dest: /etc/profile.d/histcontrol.sh
owner: root
@ -143,5 +187,6 @@
export HISTCONTROL=ignorespace
- name: Set timezone
tags: [base, timezone]
community.general.timezone:
name: "{{ timezone | default('UTC') }}"

View file

@ -0,0 +1,59 @@
#!/bin/bash
# Recurring declared-vs-live check. Installed by the goss role.
# Writes TAP + a one-line status; notifies only on pass/fail transitions.
set -euo pipefail
GOSS_BIN="${GOSS_BIN:-/usr/local/bin/goss}"
GOSS_FILE="${GOSS_FILE:-/etc/goss/baseline.yaml}"
STATE_DIR="${STATE_DIR:-/var/lib/railiance/goss}"
NOTIFY_URL="${RAILIANCE_GOSS_NOTIFY_URL:-}"
HOST="$(hostname -s)"
mkdir -p "${STATE_DIR}"
chmod 0755 "${STATE_DIR}"
if [[ ! -x "${GOSS_BIN}" || ! -f "${GOSS_FILE}" ]]; then
echo "skip: goss binary or baseline missing" | tee "${STATE_DIR}/last.status"
exit 0
fi
set +e
"${GOSS_BIN}" -g "${GOSS_FILE}" validate --format tap > "${STATE_DIR}/last.tap"
rc=$?
set -e
if [[ "${rc}" -eq 0 ]]; then
result=pass
else
result=fail
fi
printf 'result=%s host=%s ts=%s rc=%s\n' \
"${result}" "${HOST}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${rc}" \
> "${STATE_DIR}/last.status"
prev=""
if [[ -f "${STATE_DIR}/last.result" ]]; then
prev="$(cat "${STATE_DIR}/last.result")"
fi
echo "${result}" > "${STATE_DIR}/last.result"
logger -t railiance-goss "baseline ${result} on ${HOST} (rc=${rc})"
if [[ "${result}" == "fail" ]]; then
touch "${STATE_DIR}/FAILED"
else
rm -f "${STATE_DIR}/FAILED"
fi
if [[ -n "${NOTIFY_URL}" && "${result}" != "${prev}" ]]; then
payload=$(printf \
'{"summary":"Goss baseline %s on %s","event_type":"note","author":"railiance-goss-timer"}' \
"${result}" "${HOST}")
curl -sS -m 10 -X POST "${NOTIFY_URL}" \
-H 'Content-Type: application/json' \
-d "${payload}" >/dev/null || \
logger -t railiance-goss "notify failed for ${HOST} ${result}"
fi
exit "${rc}"

View file

@ -0,0 +1,4 @@
---
- name: Reload systemd daemon
ansible.builtin.systemd:
daemon_reload: true

View file

@ -32,6 +32,39 @@
group: root
mode: "0644"
- name: Install recurring Goss check wrapper
ansible.builtin.copy:
src: goss-baseline-check.sh
dest: /usr/local/sbin/goss-baseline-check
owner: root
group: root
mode: "0755"
- name: Install Goss baseline systemd service
ansible.builtin.template:
src: railiance-goss-baseline.service.j2
dest: /etc/systemd/system/railiance-goss-baseline.service
owner: root
group: root
mode: "0644"
notify: Reload systemd daemon
- name: Install Goss baseline systemd timer
ansible.builtin.template:
src: railiance-goss-baseline.timer.j2
dest: /etc/systemd/system/railiance-goss-baseline.timer
owner: root
group: root
mode: "0644"
notify: Reload systemd daemon
- name: Enable hourly Goss baseline timer
ansible.builtin.systemd:
name: railiance-goss-baseline.timer
enabled: true
state: started
daemon_reload: true
- name: Run Goss assertions (TAP output)
ansible.builtin.command:
cmd: "{{ goss_bin }} -g {{ goss_dir }}/baseline.yaml validate --format tap"

View file

@ -0,0 +1,10 @@
[Unit]
Description=Railiance declared-vs-live Goss baseline
Documentation=file:///etc/goss/baseline.yaml
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/goss-baseline-check
Nice=10
# The wrapper records TAP even when assertions fail.
SuccessExitStatus=0 1

View file

@ -0,0 +1,12 @@
[Unit]
Description=Hourly Railiance Goss baseline (RAIL-HO-WP-0009-T05)
[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
RandomizedDelaySec=5min
Persistent=true
Unit=railiance-goss-baseline.service
[Install]
WantedBy=timers.target

View file

@ -10,6 +10,7 @@
# user_memory_swap_max: systemd MemorySwapMax (default: 512M)
- name: Set PAM nproc limits
tags: [resource_limits]
ansible.builtin.template:
src: nproc-limits.conf.j2
dest: /etc/security/limits.d/60-nproc-{{ resource_limit_user | default('tegwick') }}.conf
@ -18,6 +19,7 @@
mode: '0644'
- name: Ensure systemd user slice override directory
tags: [resource_limits]
ansible.builtin.file:
path: "/etc/systemd/system/user-{{ resource_limit_uid | default(1000) }}.slice.d"
state: directory
@ -26,6 +28,7 @@
mode: '0755'
- name: Set systemd user slice memory limits
tags: [resource_limits]
ansible.builtin.template:
src: user-slice-limits.conf.j2
dest: "/etc/systemd/system/user-{{ resource_limit_uid | default(1000) }}.slice.d/limits.conf"

View file

@ -1,5 +1,6 @@
---
- name: Install age
tags: [sops]
ansible.builtin.shell: |
set -euo pipefail
if ! command -v age >/dev/null; then
@ -9,12 +10,14 @@
executable: /bin/bash
- name: Install sops
tags: [sops]
ansible.builtin.get_url:
url: https://github.com/getsops/sops/releases/download/v3.9.0/sops-v3.9.0.linux.amd64
dest: /usr/local/bin/sops
mode: '0755'
- name: Create SOPS age dir
tags: [sops]
ansible.builtin.file:
path: /root/.config/sops/age
state: directory

View file

@ -6,17 +6,20 @@
# swap_swappiness: vm.swappiness value (default: 10)
- name: Check if swapfile exists with correct size
tags: [swap]
ansible.builtin.stat:
path: /swapfile
register: swapfile_stat
- name: Allocate swapfile (fallocate)
tags: [swap]
ansible.builtin.command:
cmd: "fallocate -l {{ (swap_size_gb | default(4)) | int }}G /swapfile"
creates: /swapfile
when: not swapfile_stat.stat.exists
- name: Set swapfile permissions
tags: [swap]
ansible.builtin.file:
path: /swapfile
owner: root
@ -24,17 +27,20 @@
mode: '0600'
- name: Format swapfile
tags: [swap]
ansible.builtin.command:
cmd: mkswap /swapfile
when: not swapfile_stat.stat.exists
- name: Enable swapfile
tags: [swap]
ansible.builtin.command:
cmd: swapon /swapfile
when: not swapfile_stat.stat.exists
ignore_errors: true # already active is not an error
- name: Ensure swapfile in /etc/fstab
tags: [swap]
ansible.builtin.lineinfile:
path: /etc/fstab
regexp: '^/swapfile'
@ -42,6 +48,7 @@
state: present
- name: Set vm.swappiness at runtime
tags: [swap]
ansible.posix.sysctl:
name: vm.swappiness
value: "{{ swap_swappiness | default(10) }}"
@ -49,6 +56,7 @@
reload: true
- name: Persist vm.swappiness across reboots
tags: [swap]
ansible.builtin.copy:
dest: /etc/sysctl.d/60-swappiness.conf
owner: root