From c84fe7a3de91f9ff81119b4a944cc74e5d0a13e8 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 11 Aug 2026 23:56:28 +0200 Subject: [PATCH] Make the k3s API firewall allowlist declarative The live host restricted 6443/tcp to specific operator addresses, added by hand, while this role still declared the port open to Anywhere with no source restriction. The declared config was weaker than reality: running the base role would have REMOVED the restriction and exposed the Kubernetes API to the internet. Security was tightened on the host and never fed back into the source of truth. Found 2026-08-11 while diagnosing lost cluster access, which turned out to be an ISP lease rotation (89.244.90.246 -> .236) against a hand-maintained allowlist. Changes: - defaults: k3s_api_allowed_sources (empty = 6443 closed to all external sources, the safe failure; SSH unaffected so the host stays recoverable) and k3s_api_revoked_sources, so rotated addresses are pruned rather than left as standing grants to whoever the ISP reassigns them to - tasks: grant approved sources, then remove any blanket rule, then revoke retired ones. Order matters - grants are added before the blanket rule is deleted so convergence never opens a window with no API access - group_vars/all.yaml: the current operator address, plus the two stale grants (.246 rotated, 85.132.220.102 historic) marked for revocation - docs/verification.md: state that 6443 is source-restricted rather than listing it as a plainly allowed port Not yet converged against the live host - the role change is committed but running it is a production action needing operator approval. Co-Authored-By: Claude Opus 5 --- WORK-RECORDS.md | 4 + ansible/inventory/group_vars/all.yaml | 26 ++++++ ansible/roles/base/defaults/main.yml | 30 +++++++ ansible/roles/base/tasks/main.yml | 39 ++++++++- docs/verification.md | 6 +- ...ance01-resource-and-commercial-evidence.md | 81 +++++++++++++++++++ 6 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 ansible/roles/base/defaults/main.yml create mode 100644 workplans/RAIL-HO-WP-0008-railiance01-resource-and-commercial-evidence.md diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 8a43048..16c4f76 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -10,6 +10,7 @@ | --- | --- | --- | --- | --- | | workplan | RAIL-HO-WP-0006 | finished | — | workplans/RAIL-HO-WP-0006-forgejo-registry-ref-cleanup.md | | workplan | RAIL-HO-WP-0007 | finished | — | workplans/RAIL-HO-WP-0007-first-reef-rollout-and-s1-canonicalization.md | +| workplan | RAIL-HO-WP-0008 | ready | — | workplans/RAIL-HO-WP-0008-railiance01-resource-and-commercial-evidence.md | | task | RAIL-HO-WP-0006-T01 | done | — | workplans/RAIL-HO-WP-0006-forgejo-registry-ref-cleanup.md | | task | RAIL-HO-WP-0006-T02 | done | — | workplans/RAIL-HO-WP-0006-forgejo-registry-ref-cleanup.md | | task | RAIL-HO-WP-0006-T03 | done | — | workplans/RAIL-HO-WP-0006-forgejo-registry-ref-cleanup.md | @@ -23,3 +24,6 @@ | task | RAIL-HO-WP-0007-T02 | done | — | workplans/RAIL-HO-WP-0007-first-reef-rollout-and-s1-canonicalization.md | | task | RAIL-HO-WP-0007-T03 | done | — | workplans/RAIL-HO-WP-0007-first-reef-rollout-and-s1-canonicalization.md | | task | RAIL-HO-WP-0007-T04 | done | — | workplans/RAIL-HO-WP-0007-first-reef-rollout-and-s1-canonicalization.md | +| task | RAIL-HO-WP-0008-T01 | todo | — | workplans/RAIL-HO-WP-0008-railiance01-resource-and-commercial-evidence.md | +| task | RAIL-HO-WP-0008-T02 | todo | — | workplans/RAIL-HO-WP-0008-railiance01-resource-and-commercial-evidence.md | +| task | RAIL-HO-WP-0008-T03 | todo | — | workplans/RAIL-HO-WP-0008-railiance01-resource-and-commercial-evidence.md | diff --git a/ansible/inventory/group_vars/all.yaml b/ansible/inventory/group_vars/all.yaml index 13a6599..708913b 100644 --- a/ansible/inventory/group_vars/all.yaml +++ b/ansible/inventory/group_vars/all.yaml @@ -24,3 +24,29 @@ custodian_agent_pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC/V9fe5MGKdhTBz9Kw # Private key lives at ~/.ssh/id_ops on the workstation. Never commit the private key. ops_bridge_user: tegwick ops_bridge_pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKQmXbAVlEa8dzGx8Hk2S7AITpz6sMWdCN0MeMOzL82u ops-bridge@custodian" + +# --------------------------------------------------------------------------- +# k3s API firewall allowlist (6443/tcp) +# +# 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. +# +# 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" + +k3s_api_revoked_sources: + - address: "89.244.90.246" + comment: "rotated ISP lease, superseded 2026-08-11" + - address: "85.132.220.102" + comment: "historic operator address, retired" diff --git a/ansible/roles/base/defaults/main.yml b/ansible/roles/base/defaults/main.yml new file mode 100644 index 0000000..05c7f1b --- /dev/null +++ b/ansible/roles/base/defaults/main.yml @@ -0,0 +1,30 @@ +--- +# Base role defaults. + +# Source addresses permitted to reach the k3s API (6443/tcp). +# +# WHY THIS EXISTS +# --------------- +# The k3s API was originally opened to Anywhere by this role, and the source +# restriction was later added by hand on the live host. That left the declared +# 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. +# +# 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: +# - 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. +k3s_api_revoked_sources: [] diff --git a/ansible/roles/base/tasks/main.yml b/ansible/roles/base/tasks/main.yml index 19660a4..182d1c6 100644 --- a/ansible/roles/base/tasks/main.yml +++ b/ansible/roles/base/tasks/main.yml @@ -62,11 +62,48 @@ rule: allow name: OpenSSH -- name: Allow k3s API in UFW +# 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 k3s API from approved operator sources only 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 }}" + +- name: Remove blanket k3s API rule if present (must not be world-reachable) + ansible.builtin.ufw: + rule: allow + port: '6443' + proto: tcp + delete: true + +- name: Revoke k3s API access for retired operator sources + 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 }}" + +- name: Warn when no operator source is allowed to reach the k3s API + 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 - name: Allow Flannel VXLAN in UFW ansible.builtin.ufw: diff --git a/docs/verification.md b/docs/verification.md index 97aef90..cb36ad0 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -10,7 +10,11 @@ a reproducible, CI-friendly pass/fail verdict. of every managed node. It covers: - **Firewall** — UFW active, default deny inbound, required ports allowed - (SSH 22/tcp, k3s API 6443/tcp, Flannel VXLAN 8472/udp) + (SSH 22/tcp, Flannel VXLAN 8472/udp). The k3s API (6443/tcp) is + **source-restricted**, not world-open: only addresses in + `k3s_api_allowed_sources` may reach it, and `k3s_api_revoked_sources` is + pruned on convergence. A host with an empty allowlist has 6443 closed to all + external sources — the safe failure, recoverable over SSH. - **SSH daemon** — root login disabled, password auth disabled, pubkey auth enabled - **Services** — ufw, fail2ban, ssh.socket enabled and running - **Packages** — ufw, fail2ban, git, curl, vim, htop (age and sops installed as binaries) diff --git a/workplans/RAIL-HO-WP-0008-railiance01-resource-and-commercial-evidence.md b/workplans/RAIL-HO-WP-0008-railiance01-resource-and-commercial-evidence.md new file mode 100644 index 0000000..9e3cc40 --- /dev/null +++ b/workplans/RAIL-HO-WP-0008-railiance01-resource-and-commercial-evidence.md @@ -0,0 +1,81 @@ +--- +id: RAIL-HO-WP-0008 +type: workplan +title: "Publish railiance01 resource and commercial evidence" +domain: financials +repo: railiance-infra +status: ready +owner: codex +topic_slug: railiance +created: "2026-08-11" +updated: "2026-08-11" +related: + - RESOURCE-WP-0003 +state_hub_workstream_id: "7122657f-87c8-46b5-a725-a1af1ba0af12" +--- + +# RAIL-HO-WP-0008 — railiance01 resource and commercial evidence + +## Goal + +Provide the non-secret infrastructure facts needed to maintain +`resource:hosteurope:railiance01` in resource-control without making +resource-control authoritative for host provisioning or fin-hub's booked cost. +Origin: `RESOURCE-WP-0003-T04` delegated evidence gap. + +## T01 — Identify the provider resource and lifecycle + +```task +id: RAIL-HO-WP-0008-T01 +status: todo +priority: high +state_hub_task_id: "cde55584-229d-4e27-8fc8-0f54d78c71ed" +``` + +Record the Host Europe product/service class, non-secret provider resource ID, +account reference suitable for joins, country/region or residency evidence, +order or commissioning date, renewal date, cancellation deadline, and contract +term. Reference source evidence without committing invoices, credentials, +customer numbers, or payment instruments. + +Done when resource-control can update the provider and lifecycle fields with +provenance, while unknown or restricted fields remain explicitly classified. + +## T02 — Publish host capacity and utilization evidence + +```task +id: RAIL-HO-WP-0008-T02 +status: todo +priority: high +state_hub_task_id: "8b0e5547-23ae-4516-b6d7-9c196882d088" +``` + +Define a stable non-secret observation containing provisioned and usable CPU, +memory, root/local storage, traffic allowance where known, and timestamped +host-level utilization. Distinguish provider limits from operating-system and +Kubernetes observations. + +Done when at least one reproducible observation can be consumed by +resource-control and its source and cadence are documented. + +## T03 — Record infrastructure operations labor and exit inputs + +```task +id: RAIL-HO-WP-0008-T03 +status: todo +priority: medium +state_hub_task_id: "8bdaa96f-400b-455f-87db-d5c11e287142" +``` + +Define the recurring host-maintenance activities and measurable operator-labor +inputs, plus the host replacement/cancellation evidence required before exit. +Do not estimate another repository's workload labor. + +Done when resource-control can separate host infrastructure, recurring labor, +and migration effort in forecasts and optimization cases. + +## Acceptance + +- [ ] Provider identity and lifecycle evidence is non-secret and attributable. +- [ ] Capacity and utilization observations have timestamps and provenance. +- [ ] Host labor and exit inputs can be consumed without duplicating booked cost.