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

@ -26,29 +26,37 @@ ops_bridge_user: tegwick
ops_bridge_pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKQmXbAVlEa8dzGx8Hk2S7AITpz6sMWdCN0MeMOzL82u ops-bridge@custodian"
# ---------------------------------------------------------------------------
# k3s API firewall allowlist (6443/tcp)
# k3s API firewall (6443/tcp) — tunnel only (ADR-005)
#
# The k3s API is NOT world-reachable. Only these sources may connect.
# Added 2026-08-11 after discovering the live host was source-restricted by hand
# while this repo still declared 6443 open to Anywhere — meaning a convergence
# run would have exposed the Kubernetes API to the internet.
# Public allowlist is empty. Reach the API over ops-bridge:
# bridge up k3s-api-railiance01 # local 16444
# bridge up k3s-api-coulombcore # local 16443
#
# These are operator egress addresses, not secrets. They are dynamic: when your
# ISP rotates the lease, MOVE the old entry to k3s_api_revoked_sources rather
# than deleting the line, so convergence prunes the stale grant instead of
# leaving it standing for whoever the address gets reassigned to.
#
# Durable alternative worth considering: reach the API over the ops-bridge SSH
# tunnel instead (see railiance-infra/docs/deploy-stack.md, the
# k3s-api-coulombcore pattern) and allow no public sources at all.
k3s_api_allowed_sources:
- address: "89.244.90.236"
comment: "k3s-api-operator-current"
- address: "89.244.90.255"
comment: "k3s-api-operator-current"
# Revoked addresses are pruned on a firewall-tagged converge so rotated or
# retired grants do not remain standing. Do not add new public sources here
# without amending ADR-005.
k3s_api_allowed_sources: []
k3s_api_revoked_sources:
- address: "89.244.90.248"
comment: "hand grant added 2026-08-12/15; retired by ADR-005"
- address: "89.244.90.236"
comment: "rotated ISP lease; retired by ADR-005"
- address: "89.244.90.255"
comment: "rotated ISP lease; retired by ADR-005"
- address: "89.244.90.246"
comment: "rotated ISP lease, superseded 2026-08-11"
- address: "85.132.220.102"
comment: "historic operator address, retired"
comment: "historic operator address; also the 2026-08-15 workstation lease"
# Single-node clusters need no public VXLAN grant. Set peer addresses here
# only when a second node must exchange Flannel frames (RAIL-BS-WP-0007).
flannel_vxlan_allowed_sources: []
# HostEurope Nydus agent — provider dashboard, root-password reset, backups.
# Required by the VPS platform (hosteurope/260308-dependency-nydus.md).
# Source-restricting it would break the provider; Anywhere is intentional.
ufw_extra_allowed:
- port: "2224"
proto: tcp
comment: "nydus-ex-api dashboard agent"

View file

@ -1,6 +1,12 @@
# Host-specific variables for CoulombCore (92.205.130.254)
# k3s single-node cluster host — HostEurope
# Do not enable or rewrite UFW on this host. Live filter is iptables INPUT
# DROP with a Plesk-era accept list (UFW status: inactive). Enabling UFW
# here would take down 80/443 and the rest of the accepted surface unless
# every live accept is declared first. RAIL-HO-WP-0009-T03.
ufw_manage: false
# Swapfile (T01)
swap_size_gb: 4
swap_swappiness: 10

View file

@ -18,12 +18,24 @@ def load_tf_outputs():
return {}
def load_host_vars(name):
"""Load host_vars/<name>.yml if it exists."""
"""Load host_vars/<name>.yml if it exists.
The inventory script is ansible/inventory_from_yaml.py. Ansible does not
auto-load a host_vars directory next to a script inventory, so this has
to emit hostvars itself. Look in ansible/inventory/host_vars first (where
CoulombCore.yml actually lives), then the unused repo-root path.
"""
script_dir = os.path.dirname(__file__)
path = os.path.join(script_dir, '..', 'inventory', 'host_vars', f'{name}.yml')
if os.path.exists(path):
with open(path) as f:
return yaml.safe_load(f) or {}
candidates = [
os.path.join(script_dir, 'inventory', 'host_vars', f'{name}.yml'),
os.path.join(script_dir, 'inventory', 'host_vars', f'{name}.yaml'),
os.path.join(script_dir, '..', 'inventory', 'host_vars', f'{name}.yml'),
os.path.join(script_dir, '..', 'inventory', 'host_vars', f'{name}.yaml'),
]
for path in candidates:
if os.path.exists(path):
with open(path) as f:
return yaml.safe_load(f) or {}
return {}
def main():

View file

@ -5,8 +5,13 @@
- ../inventory/group_vars/secrets.sops.yaml
roles:
- role: base
tags: [base]
- role: sops_agent
tags: [sops]
- role: custodian_agent # injects ~/.ssh/id_custodian_agent.pub into authorized_keys
tags: [custodian_agent]
- role: swapfile # provisions swap file (size + swappiness from host_vars)
tags: [swap]
- role: resource_limits # nproc PAM caps + systemd user slice memory limits
tags: [resource_limits]
# - role: wireguard # enable if you configure WireGuard variables

View file

@ -0,0 +1,32 @@
---
# Fetch the last on-host Goss timer result. Does not run a new check.
# Usage: ansible-playbook ansible/playbooks/goss-status.yaml
# make goss-status
- hosts: all
become: true
gather_facts: false
tasks:
- name: Read last Goss timer status
ansible.builtin.slurp:
src: /var/lib/railiance/goss/last.status
register: goss_status
failed_when: false
- name: Show last Goss timer status
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }}:
{{ goss_status.content | default('') | b64decode | trim
if goss_status.content is defined
else 'no timer result yet' }}
- name: Fail when the last on-host check reported FAILED
ansible.builtin.stat:
path: /var/lib/railiance/goss/FAILED
register: goss_failed
- name: Report failed hosts
ansible.builtin.fail:
msg: "Goss baseline last run failed on {{ inventory_hostname }}"
when: goss_failed.stat.exists | default(false)

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