Security Posture¶
This is a homelab on a private network, and several deliberate shortcuts follow from that. They are listed so that someone reading the manifests can tell a decision from an oversight.
Assumed trust boundary¶
The cluster assumes a trusted L2 network segment: anything with a port on it is treated as friendly. There is no VPN requirement, no mutual TLS between components, and no network segmentation inside the cluster.
The published hostnames are a partial exception. Certificates come from Let's
Encrypt through a DNS-01 challenge against a public zone, so
argo.infra.k8s.wlkr.ch and its siblings are publicly resolvable and appear in
Certificate Transparency logs, even though they point at RFC1918 addresses. Plan
names accordingly.
Provisioning¶
Provisioning is the least protected phase, by design: it has to work before any of the cluster's own security exists.
| Property | Detail |
|---|---|
| Ignition configs are served unauthenticated over HTTP | Anything on the segment can fetch http://<boot-server>:8000/ignition-<host>.json while the boot server is running |
| Those configs embed join credentials | The inlined kubeadm config carries the bootstrap token and the certificateKey, together enough to join a new control-plane node |
| The control-plane configs embed the etcd encryption key | /etc/kubernetes/enc/encryption-config.yaml is inlined with the key that encrypts every Secret at rest. It has no TTL: anyone who fetched a control-plane config while the boot server was up can read Secrets out of any etcd backup, for the life of the cluster |
Nodes join with --discovery-token-unsafe-skip-ca-verification |
A joining node does not verify the API server's CA |
Sysext updates set Verify=false |
On first boot Ignition checks each image against the sha256 the boot server was given, so a substituted image fails. Later sysupdate downloads travel over HTTPS with signature verification off |
| The OS image is signature-checked | flatcar-install is given -b/-V, so the node downloads the image and its detached signature and checks both against Flatcar's key before writing anything |
The mitigation is time: make serve is a foreground command, the bootstrap
token has a 24 hour TTL and the certificate key expires after two hours. The
encryption key has no clock, which is why the boot server listens only on
boot_server_ip, serves files by name and lists nothing. Stop the boot
server when provisioning is finished. Flatcar is installed to disk, so
nothing needs it after a build — see
Every boot after the first.
output/credentials/ holds the generated bootstrap token and certificate key
in plaintext. The directory is 0700 and output/ is gitignored, but the
values are reused across make config runs — the Ansible password lookup
reads back an existing file. Delete them to force new ones.
Secrets¶
Secrets live in OpenBao and reach workloads as native
Kubernetes Secret objects through the
External Secrets Operator. Nothing
sensitive is committed to Git.
- OpenBao is sealed with Shamir and unsealed by hand, so the 5 key shares are the root of trust for every other secret; losing all of them loses everything, and nothing outside the cluster holds a copy.
- A Kubernetes
Secretis base64, not encryption. Anyone withget secretsin a namespace can read what ESO materialised there. Encryption at rest protects the bytes in etcd, not the API.
Encryption at rest¶
The API server encrypts secrets with secretbox before they reach etcd
(ansible/templates/kubeadm.yaml.j2; the key file in
ansible/templates/butane_node_config.yaml.j2). Without it an etcd backup, a
stolen disk, or read access to /var/lib/etcd yields every credential the
cluster holds.
| Property | Detail |
|---|---|
| Provider | secretbox, with identity listed after it |
| Key | 32 random bytes, generated once by make config into output/credentials/encryption_key |
| Scope | secrets only; ConfigMaps and other resources are unencrypted |
| Distribution | The same key on every control-plane node, written by Ignition to /etc/kubernetes/enc/encryption-config.yaml (mode 0600) |
With identity last, new writes are encrypted and Secrets written before it
was enabled stay readable but are not rewritten. To encrypt what already
exists, once the API servers have restarted:
The key is static, with no rotation, and lives in output/credentials/ beside
the kubeadm token and certificate key — that directory is what decrypts etcd.
A KMS provider would remove the static key at the cost of a dependency the
cluster must reach before it can serve Secrets.
Runtime¶
Tetragon records every exec in every pod and alerts when anything but the control plane reads the cluster's key material. It is detection, not prevention: nothing is killed, and a policy that fails to load on a new kernel watches nothing until its alert fires.
Audit logging¶
The API server records who did what, to which object, and whether it was
allowed; the audit half of the Pod Security Admission labels has nowhere to
go without it. Policy, retention and queries are in
Audit Logging.
Authorization¶
AppProjects constrain the workloads, not the platform.
payload/platform/argocd-projects/projects.yaml defines three. infra allows
sourceRepos: "*" and every group and kind in every namespace: it is this
repository deploying this repository, and a restriction there guards against
nothing the PR review does not. system is confined to the argocd namespace.
apps has the boundary to draw, because its Applications come from a second
repository with a lighter review: it accepts only the workloads repository and
the chart repositories the workloads use, may not write into any platform
namespace (authentik excepted, for the blueprint ConfigMap a workload ships
there), and at cluster scope may create nothing but namespaces.
Network policy covers ten namespaces. openbao, cert-manager,
external-secrets, monitoring, external-dns, kubelet-csr-approver,
kured, logging, cnpg-system and trivy-system have default-deny ingress
CiliumNetworkPolicy rules; every other namespace, and all egress everywhere,
is unrestricted. See Security Policies.
Pod Security Admission is on, but mostly auditing. Every platform namespace
carries enforce at the level it demonstrably needs and warn/audit at a
stricter one, so violations are visible without breaking what runs: measure
first, enforce second.
Image references are checked at admission. An untagged or latest image
is refused; a registry outside the allowlist is recorded and warned about but
admitted. Signatures are not verified: few of the upstreams sign, so a
verifier would exempt nearly everything. See
Security Policies.
Both Gateways admit routes from every namespace
(allowedRoutes.namespaces.from: All). Any namespace can attach an HTTPRoute
to infra-gateway and claim a hostname under *.infra.k8s.wlkr.ch.
Exposed interfaces¶
Every platform UI on the infra gateway is behind Authentik:
| Service | Authentication |
|---|---|
| ArgoCD | Authentik OIDC; local admin disabled. The server runs with --insecure because TLS terminates at the Gateway |
| Grafana | Authentik OIDC; login form disabled |
| Hubble UI | Authentik proxy outpost |
| Rook dashboard | Authentik proxy outpost |
| Prometheus | Authentik proxy outpost |
| Alertmanager | Authentik proxy outpost |
| OpenBao UI | Token or configured auth method — not behind Authentik |
Authentik is therefore a dependency of reaching any of them; the break-glass paths are in When Authentik is down. OpenBao is left out deliberately: it holds Authentik's own database password, so putting it behind Authentik would be a loop.
What would tighten this up¶
Roughly in order of value against effort:
- Extend default-deny ingress to the namespaces not yet covered, then start on egress — the larger and more breakable half.
- Narrow
sourceReposon the AppProjects to this repository and the Helm repositories actually in use. - Restrict
allowedRoutesoninfra-gatewayto the platform namespaces, and promote the registry allowlist toDeny. - Add a second Alertmanager receiver on a different transport, so a failure of the mail path is not itself unmonitored.
- Move etcd encryption to a KMS provider, removing the static
encryption_keyinoutput/credentials/. - Get the backups out of the cluster: Velero and the etcd snapshots write to the Ceph object store they are backing up.
See Known Limitations for the operational counterparts.