Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/security-contracts.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Security Contracts

on:
pull_request:
branches:
- main
push:
branches:
- main

permissions:
contents: read

jobs:
validate:
name: Validate security contracts
runs-on: ubuntu-24.04
timeout-minutes: 15

steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
cache: pip
cache-dependency-path: ansible/requirements.txt

- name: Install YAML parser
run: python -m pip install --requirement ansible/requirements.txt

- name: Test Terraform deployment security boundary
run: python tests/test_terraform_security_contract.py

- name: Test OpenSSH hardening contract
run: python tests/test_ssh_hardening.py
8 changes: 8 additions & 0 deletions .github/workflows/terraform-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@ on:
permissions:
contents: read

env:
# The root module intentionally leaves its cloud block deployment-neutral.
# These non-routable CI identities satisfy static initialization only because
# this workflow disables backend access.
TF_CLOUD_ORGANIZATION: ci-validation
TF_WORKSPACE: ci-validation

jobs:
validate:
name: Validate Terraform
runs-on: ubuntu-24.04
timeout-minutes: 15

defaults:
run:
Expand Down
28 changes: 9 additions & 19 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,26 @@
crash.log
crash.*.log

# Exclude all .tfvars files, which are likely to contain sensitive data, such as
# password, private keys, and other secrets. These should not be part of version
# control as they are data points which are potentially sensitive and subject
# to change depending on the environment.
# Exclude all .tfvars files, which are likely to contain environment-specific
# or sensitive values.
*.tfvars
*.tfvars.json

# Ignore override files as they are usually used to override resources locally and so
# are not checked in
# Ignore local override files.
override.tf
override.tf.json
*_override.tf
*_override.tf.json

# Ignore transient lock info files created by terraform apply
# Ignore transient lock information.
.terraform.tfstate.lock.info

# Include override files you do wish to add to version control using negated pattern
# !example_override.tf
# Saved plans can contain sensitive values and must never be committed.
*tfplan*

# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
# example: *tfplan*

# Ignore CLI configuration files
# Ignore CLI configuration files.
.terraformrc
terraform.rc

# Optional: ignore graph output files generated by `terraform graph`
# *.dot

# Optional: ignore plan files saved before destroying Terraform configuration
# Uncomment the line below if you want to ignore planout files.
# planout
# Optional local graph output.
*.dot
71 changes: 44 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,36 +5,53 @@ A self-managed, single-node Kubernetes cluster running on a cloud VM.
This is intentionally a single-node architecture. It is not highly available:
the control plane and workloads share one failure domain.

The cloud infrastructure is provisioned with Terraform and the cluster is
built with kubeadm and containerd.
The cloud infrastructure is provisioned with Terraform and the cluster is built
with kubeadm, containerd, and Cilium.

This repository contains the infrastructure, Kubernetes configuration, and
operational documentation for the cluster. Live production inventory,
credentials, and execution state are intentionally kept outside this public
repository.
## Repository boundary

## Architecture
This public repository contains reusable Terraform, Ansible roles, architecture
contracts, tests, and operational documentation. The private deployment
repository owns production resource identity, HCP Terraform selection,
credentials, operator access CIDRs, saved plans, and deployment history.

- Single cloud VM
- Kubernetes control plane and workloads on the same node
- Terraform-managed infrastructure
- kubeadm-managed Kubernetes
- containerd runtime
- Deterministic, separately invoked Cilium lifecycle
No production SSH key, inventory, account name, workspace name, or operator
network is intended to live in this repository.

The [Kubernetes and Cilium network architecture](docs/network-architecture.md)
defines the IPv4 address plan, initial VXLAN and cluster-pool IPAM design,
deferred features, and reusable Ansible configuration contract. Its
machine-readable companion is normative architecture data rather than a Helm
values file.

The Ansible lifecycle separates repeatable node convergence from Kubernetes
control-plane bootstrap. Bootstrap uses kubeadm only on a confirmed-fresh host
and validates existing cluster state on later runs. The dedicated Cilium
playbook remains a separate, subsequent lifecycle step and performs end-to-end
network validation. See [the Ansible guide](ansible/README.md) for invocation
and recovery details.
## Lifecycle

## Status
1. Terraform provisions the Hetzner server, retained Primary IPv4, SSH-only
firewall, and initial administration key.
2. `playbooks/node.yml` converges the Debian host, enforces key-only SSH,
installs containerd, and installs pinned Kubernetes tooling.
3. `playbooks/bootstrap.yml` initializes only a confirmed-fresh kubeadm control
plane and validates healthy existing state on later runs.
4. `playbooks/cilium.yml` installs or validates the digest-pinned Cilium release
and proves Pod, Service, DNS, egress, and NetworkPolicy behavior.

Work in progress.
The [Kubernetes and Cilium network architecture](docs/network-architecture.md)
defines the address plan, initial VXLAN and cluster-pool IPAM design, deferred
features, and reusable Ansible input contract. Its machine-readable companion is
normative architecture data rather than a Helm values file.

See the [Ansible guide](ansible/README.md), the
[Terraform root-module guide](infra/hetzner/README.md), and the
[security model](docs/security.md) for invocation, trust boundaries, and
remaining operational work.

## Verification

Pull requests run Terraform formatting and validation, immutable-workflow
policy checks, Python security and architecture contracts, Ansible lint,
playbook syntax checks, package fixtures, and kubeadm/Cilium lifecycle tests.
Renovate tracks standard dependency files and reviewed custom pins without
automerge.

## Current status

The infrastructure, node-convergence, kubeadm-bootstrap, and Cilium lifecycles
are implemented and have passed production deployment and idempotence checks.
The project remains intentionally single-node and still requires dedicated
backup/restore, operating-system maintenance, Kubernetes upgrade, Cilium
upgrade, and non-root automation-user procedures before it should host
irreplaceable workloads.
14 changes: 14 additions & 0 deletions ansible/roles/base/handlers/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
- name: Validate the effective OpenSSH configuration
ansible.builtin.command:
argv:
- /usr/sbin/sshd
- -t
changed_when: false
listen: Validate and reload OpenSSH

- name: Reload the OpenSSH daemon
ansible.builtin.systemd_service:
name: ssh
state: reloaded
listen: Validate and reload OpenSSH
113 changes: 113 additions & 0 deletions ansible/roles/base/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,119 @@
The base role requires systemd. Detected
{{ ansible_facts.service_mgr }}.

- name: Inspect the OpenSSH daemon
ansible.builtin.stat:
path: /usr/sbin/sshd
get_checksum: false
get_mime: false
get_attributes: false
register: base_sshd_binary

- name: Require the OpenSSH daemon
ansible.builtin.assert:
that:
- base_sshd_binary.stat.exists
- base_sshd_binary.stat.isreg | default(false)
- base_sshd_binary.stat.executable | default(false)
fail_msg: >-
The base role requires the OpenSSH daemon at /usr/sbin/sshd so it can
enforce key-only administrative access.

- name: Create the OpenSSH configuration directory
ansible.builtin.file:
path: /etc/ssh/sshd_config.d
state: directory
owner: root
group: root
mode: "0755"

# This project still uses root for production automation. The role therefore
# retains root SSH, but only through a pre-provisioned public key. The 00 prefix
# makes these first-value-wins directives precede cloud-image drop-ins.
- name: Harden OpenSSH authentication and forwarding
ansible.builtin.copy:
content: |-
# Managed by the single-node-kubernetes base role.
PermitRootLogin prohibit-password
AuthenticationMethods publickey
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
HostbasedAuthentication no
GSSAPIAuthentication no
PermitEmptyPasswords no
PermitUserEnvironment no
PermitUserRC no
MaxAuthTries 3
LoginGraceTime 30
AllowAgentForwarding no
AllowTcpForwarding local
AllowStreamLocalForwarding local
GatewayPorts no
PermitTunnel no
X11Forwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
LogLevel VERBOSE
dest: /etc/ssh/sshd_config.d/00-single-node-kubernetes.conf
owner: root
group: root
mode: "0644"
validate: /usr/sbin/sshd -t -f %s
notify: Validate and reload OpenSSH

- name: Read the effective OpenSSH policy
ansible.builtin.command:
argv:
- /usr/sbin/sshd
- -T
- -C
- user=root,host=localhost,addr=127.0.0.1
register: base_sshd_effective_policy
changed_when: false

- name: Require the effective OpenSSH policy
ansible.builtin.assert:
that:
- item in base_sshd_effective_policy.stdout_lines
fail_msg: >-
The effective OpenSSH configuration did not contain the required
directive {{ item }}. Check the main sshd_config Include order and any
conflicting earlier directives before reloading the daemon.
loop:
- authenticationmethods publickey
- pubkeyauthentication yes
- passwordauthentication no
- kbdinteractiveauthentication no
- hostbasedauthentication no
- gssapiauthentication no
- permitemptypasswords no
- permituserenvironment no
- permituserrc no
- maxauthtries 3
- logingracetime 30
- allowagentforwarding no
- allowtcpforwarding local
- allowstreamlocalforwarding local
- gatewayports no
- permittunnel no
- x11forwarding no
- clientaliveinterval 300
- clientalivecountmax 2
- loglevel VERBOSE
loop_control:
label: "{{ item }}"

- name: Require key-only root SSH in the effective policy
ansible.builtin.assert:
that:
- >-
base_sshd_effective_policy.stdout
is search('(?m)^permitrootlogin (?:prohibit-password|without-password)$')
fail_msg: >-
The effective OpenSSH policy must permit root only through public-key
authentication.

- name: Check for the cgroup v2 controllers file
ansible.builtin.stat:
path: /sys/fs/cgroup/cgroup.controllers
Expand Down
59 changes: 59 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Security model

This project treats the public infrastructure repository as reusable source and
the private deployment repository as the production trust boundary.

## Implemented controls

- GitHub Actions and Docker actions are pinned to immutable commits or image
digests.
- Terraform providers are constrained and locked.
- Public-source deployments use an exact Git commit that must already be merged
into the public `main` branch.
- Terraform plans are saved and applied without replanning.
- The permanent cloud firewall exposes only SSH from explicit IPv4 `/32`
administration addresses.
- Production automation creates a separate runner-scoped SSH firewall and
deletes it through unconditional cleanup.
- SSH host keys are checked and deployment private keys are written with
restrictive permissions.
- OpenSSH permits root only through public-key authentication; password,
keyboard-interactive, host-based, GSSAPI, agent-forwarding, and remote
forwarding paths are disabled.
- Kubernetes, containerd, runc, Helm, Cilium, validation images, and downloaded
test artifacts use reviewed version and integrity pins.
- kubeadm bootstrap and Cilium deployment use fail-closed state classification,
host-local lifecycle locks, runtime validation, and second-run idempotence
checks.
- Privileged kubeconfigs remain on the control-plane host.

## Trust boundaries

The private deployment repository and its production environment own
credentials, account and workspace selection, operator access CIDRs, production
resource identity, and deployment history. The public repository must not
contain those values.

The Kubernetes node remains a single failure and security domain. A compromise
of the host, root account, control plane, or local etcd can compromise the
entire cluster.

## Residual risks requiring explicit lifecycle work

- Production automation still uses root over SSH. Key-only authentication is
enforced, but migration to a dedicated automation user with reviewed sudo
policy remains preferable.
- Kubernetes Secrets are not yet protected by an encryption-at-rest provider.
- API-server audit policy and centralized audit-log retention are not yet
configured.
- Operating-system security updates and reboot coordination require a dedicated
maintenance workflow so unattended changes do not unexpectedly interrupt the
single-node control plane.
- Local etcd, PKI, and workload data need tested backup, restore, and off-host
retention procedures before important workloads depend on the cluster.
- Upstream package signing keys and same-origin checksum manifests still depend
on upstream release infrastructure and TLS. Higher-assurance deployments
should mirror reviewed artifacts into a controlled repository.

These items should be handled as separately tested operational migrations rather
than hidden inside normal convergence.
Loading