Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
146dd9a906 | ||
|
|
d1c07fe021 | ||
|
|
5c43fbac65 | ||
|
|
cf7d5e4316 | ||
|
|
be23cf3e26 | ||
|
|
4791653f2b | ||
|
|
523f482957 | ||
|
|
90a79d29d3 | ||
|
|
523a463fd7 | ||
|
|
a209a618fd | ||
|
|
6253d546f8 |
No files matched your search
@@ -0,0 +1,127 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: docs/pm-agent-registry-pull-prereq
|
||||
title: pm-agent reconciled exactly as designed — and is the first workload ever to pull from the in-cluster registry, which does not work yet
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Documentation only. No manifest, no policy, no code.
|
||||
|
||||
PR #57 merged and `pm-agent` reconciled. I measured what landed, and the measurement turned
|
||||
up **two prerequisites for the next operator step that nothing in this repository
|
||||
declares** — and that the handoff entry for #57 could not have known, because it listed the
|
||||
image pull as unverified and that is exactly where it bit.
|
||||
|
||||
So this entry records the post-merge measurement, and
|
||||
`platform/pm-agent/README.md`, `docs/plans/pm-agent-issue-to-pr.md` and
|
||||
`runbooks/pm-agent.md` now name both prerequisites where they say "build, push, pin the
|
||||
digest". Before this, that step read as one action; it is three, and the two new ones are
|
||||
not an agent's to perform.
|
||||
|
||||
**The finding is not specific to pm-agent.** `conformance-runner` has the identical
|
||||
registry path (`gitea.home.keithjackson.dev/home-network/...`), the identical "operator
|
||||
pushes, a digest goes in a PR" model, and has never been pulled either — so the same two
|
||||
blockers sit in front of the L2/L3 conformance wiring. Measured: **no pod anywhere in the
|
||||
cluster references that registry** except the pm-agent dispatcher, which is therefore the
|
||||
first workload ever to try it.
|
||||
|
||||
## What I verified
|
||||
|
||||
Read-only, through the pinned toolbox, against the live prod cluster.
|
||||
|
||||
**The merge reconciled, completely and exactly as designed.**
|
||||
|
||||
- `GitRepository/flux-system` artifact revision: `main@sha1:95597bd6883637…` — the merge
|
||||
commit. `platform-config`, `pm-agent` and `apps-wave-0` all report that same
|
||||
`lastAppliedRevision` with `Ready=True`. Compared field to field, per CLAUDE.md §2b,
|
||||
rather than assumed from the merge.
|
||||
- **The security boundary is live.** `ClusterPolicy/pm-agent-job-shape`:
|
||||
`validationFailureAction=Enforce`, `failurePolicy=Fail`, `Ready=True`, and all six rules
|
||||
present (`worker-must-be-the-committed-shape`,
|
||||
`worker-must-be-one-container-with-no-args`,
|
||||
`worker-volumes-must-be-workspace-or-tmp-emptydirs`,
|
||||
`worker-may-not-declare-init-or-ephemeral-containers`,
|
||||
`worker-env-names-must-be-the-committed-set`, `worker-may-not-use-envfrom`). This is the
|
||||
check that mattered most: a ClusterPolicy added to the wrong kustomization list ships
|
||||
green and never reaches the cluster, which is what happened to the conformance boundary
|
||||
on 2026-09-29.
|
||||
- Namespace contents: both ServiceAccounts, `pm-agent-config` (17 keys), the
|
||||
`pm-agent-credentials` Secret, the `default-deny` NetworkPolicy, and both
|
||||
CiliumNetworkPolicies with `VALID=True`.
|
||||
- **SOPS decryption works through the new overlay patch.** Checked by KEY NAMES and VALUE
|
||||
LENGTHS only, never values (CLAUDE.md §6): keys `gitea-token`, `claude-code-oauth-token`,
|
||||
lengths 76 and 52 bytes — which are the base64 lengths of the committed placeholder
|
||||
strings, so the Secret holds the decrypted placeholders and not ciphertext. A
|
||||
ciphertext apply succeeds silently and would have left `ENC[AES256_GCM,…]` values an
|
||||
order of magnitude longer.
|
||||
- **`pm-agent-worker` really is bound to nothing.** The namespace has exactly one
|
||||
RoleBinding (`pm-agent` → Role `pm-agent`, subject `ServiceAccount/pm-agent`), and no
|
||||
ClusterRoleBinding anywhere names either pm-agent ServiceAccount.
|
||||
- `gitea-allow-pm-agent-ingress` is applied in the `gitea` namespace, matching
|
||||
`io.kubernetes.pod.namespace: pm-agent`.
|
||||
- The dispatcher is `ImagePullBackOff` on the all-zero placeholder digest — the designed,
|
||||
visible failure.
|
||||
|
||||
**The two prerequisites, measured.**
|
||||
|
||||
1. **The nodes cannot verify the registry's TLS certificate.** containerd's error on the
|
||||
pull is not about the digest:
|
||||
|
||||
failed to resolve reference "gitea.home.keithjackson.dev/home-network/agent-runner@sha256:000…":
|
||||
tls: failed to verify certificate: x509: certificate is not valid for any names,
|
||||
but wanted to match gitea.home.keithjackson.dev
|
||||
|
||||
"not valid for **any** names" means the certificate presented to the node carries no
|
||||
matching SAN at all. From the operator's workstation the same name is healthy: it
|
||||
resolves to `192.168.2.20` and serves a Let's Encrypt certificate
|
||||
`CN=*.home.keithjackson.dev` with SANs `*.home.keithjackson.dev, home.keithjackson.dev`
|
||||
— and `Certificate gateway/wildcard-home` is `Ready=True` for exactly those names. So
|
||||
the certificate that *should* be served is fine, and what the NODE reaches is not what
|
||||
the workstation reaches. **Correcting the digest will not clear this.**
|
||||
2. **Anonymous pulls from this registry are refused.** Not inferred from a bare 401 — that
|
||||
would have been wrong, because this Gitea answers 401 for *everything* anonymously:
|
||||
`/v2/home-network/agent-runner/manifests/latest`, `/v2/home-network/conformance-runner/…`,
|
||||
a randomly-named nonexistent repository, and `nosuchowner/nosuchimage` all return 401,
|
||||
and `/api/v1/packages/Home-Network` returns `{"message":"token is required"}`. The
|
||||
registry *does* issue a token to an anonymous client, but its `access` claim is `null` —
|
||||
no pull scope. So the conclusion that holds is the simple one: **an unauthenticated
|
||||
client gets nothing from this registry**, so a pull with no credential cannot succeed.
|
||||
And `grep` over the tree finds **no `imagePullSecrets` anywhere** — not in
|
||||
`platform/pm-agent/deployment.yaml`, not in `infra/agent/worker-job-template.yaml`, not
|
||||
in `conformance/incluster/`.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **Why the node sees a nameless certificate.** I measured that it does, and that the LAN
|
||||
does not. I did not diagnose it: I did not read the nodes' resolver or
|
||||
`/etc/rancher/k3s/registries.yaml` (node files, not reachable read-only through the API),
|
||||
and I did not check whether the node resolves the name to the same `192.168.2.20` the
|
||||
workstation does or to something else.
|
||||
|
||||
**A correction to a remark I first wrote here:** I cited
|
||||
`cluster-vars`'s `GATEWAY_VIP: 192.168.1.53` as suspicious next to the nodes'
|
||||
`192.168.2.x`. That was noise — the operator confirms those base `cluster-vars` values are
|
||||
**dev-era leftovers for a cluster that is decommissioned**, so the 192.168.1.x addresses in
|
||||
that file say nothing about prod and should not be read as evidence about anything. The
|
||||
measured facts stand on their own and none of them depended on that knob.
|
||||
- **Whether the package would be pullable once authenticated.** Nothing has ever been
|
||||
pushed to that path, so "a credential is sufficient" is untested. The only thing
|
||||
established is that no credential is insufficient.
|
||||
- **Which fix the operator wants.** Two routes, and they are not equivalent:
|
||||
(a) an `imagePullSecret` — a `dockerconfigjson` holding a Gitea token, SOPS-encrypted
|
||||
like `pm-agent-credentials`, referenced from the Deployment *and* from the committed
|
||||
worker Job template; note the Job template's shape is pinned by admission, and
|
||||
`imagePullSecrets` is a field that policy does not currently constrain, so adding one is
|
||||
a template change that needs a deliberate look at whether the policy should pin it too.
|
||||
(b) allowing anonymous reads of that package in Gitea — no manifest change at all, but it
|
||||
makes the image world-readable to anything that can reach the host.
|
||||
I have implemented neither: both need an operator action I must not take, and (b) is a
|
||||
posture decision. The docs now present both.
|
||||
- **Everything the #57 entry listed as unverified about RUNTIME remains unverified.** The
|
||||
image has still never been built or pushed. No dispatcher has authenticated to Gitea, no
|
||||
issue has been triaged, no worker Job has been created — so the admission policy, though
|
||||
now provably applied and Enforce, has still never refused or admitted a real Job. The
|
||||
network policies have not carried traffic.
|
||||
- **I did not re-run the merge gate against `main` after the merge.** This branch's own gate
|
||||
run is the evidence for this change; the merged state's is CI run on PR #57.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
date: 2026-10-06
|
||||
branch: fix/new-peer-ignores-template-comments
|
||||
title: new-peer.sh refused the first VPN address because its duplicate check matched its own commented-out example
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
The operator's first real enrolment failed:
|
||||
|
||||
```
|
||||
$ ./infra/vpn-ingress/new-peer.sh keith-phone 2
|
||||
new-peer.sh: 10.90.0.2/32 is already allocated in platform/vpn-ingress/peers.yaml. Pick another octet.
|
||||
```
|
||||
|
||||
**Nothing was allocated.** `peers.yaml` had zero peers — the pod's own init container reported
|
||||
`wg0.conf assembled: 0 peer(s)`. The duplicate-address check grepped the WHOLE file, and
|
||||
`peers.yaml` line 66 is a **commented-out example stanza** I wrote, containing literally
|
||||
`# AllowedIPs = 10.90.0.2/32`. So `.2` — the first address anybody would ask for, and the one my
|
||||
own runbook uses in its example — was permanently un-allocatable, with a message asserting the
|
||||
opposite of the truth.
|
||||
|
||||
Both halves fixed, because either alone leaves the trap half-set:
|
||||
|
||||
- **`infra/vpn-ingress/new-peer.sh`**: the check strips comment lines before matching
|
||||
(`grep -vE '^[[:space:]]*#' | grep -q …`). That is the correct predicate rather than a patch: a
|
||||
real stanza's `AllowedIPs` line is never commented, while both the template and every peer's
|
||||
`# <name> — <owner>` label always are.
|
||||
- **`platform/vpn-ingress/peers.yaml`**: the example now uses placeholders that cannot match
|
||||
anything real — `# <device-name> — <owner>, enrolled <YYYY-MM-DD>` and
|
||||
`AllowedIPs = 10.90.0.<octet>/32`. A future grep cannot be fooled by it either.
|
||||
|
||||
The bug class is worth naming: **a documentation example that is byte-identical to real data will
|
||||
eventually be parsed as real data.** The same shape caused two earlier defects in this component —
|
||||
`flux-postbuild-substitution` flagged a `${...}` written inside a comment *explaining* that hazard,
|
||||
and `no-plaintext-secrets` flagged the `.sops.yaml.example` because the kustomization named it in a
|
||||
comment. Three instances now; the rule is to make examples structurally unmatchable, not merely
|
||||
commented out.
|
||||
|
||||
No sensitive paths (`platform/vpn-ingress/**`, `infra/vpn-ingress/**`, `decisions/**`), so this
|
||||
merges on a green gate with no override.
|
||||
|
||||
## What I verified
|
||||
|
||||
Run in the pinned toolbox against the live tree, 2026-10-06.
|
||||
|
||||
- **`.2` now works**, and it went through the REAL key path: `new-peer.sh keith-phone 2` rendered a
|
||||
profile with `Address 10.90.0.2/32`, `AllowedIPs = 10.90.0.2/32` in the printed stanza, full
|
||||
tunnel, at `.cache/wg-peers/keith-phone.conf` (0600). The throwaway keypair was deleted after.
|
||||
- **This incidentally closes a gap left open in #60**: the profile could only have been rendered by
|
||||
deriving the server's public key via `sops -d --extract … | wg pubkey` from
|
||||
`wg-server-key.sops.yaml` — so that pipeline is now proven **against the operator's real key**,
|
||||
not just against a placeholder it was expected to reject.
|
||||
- **A real duplicate is still refused.** Appending an uncommented
|
||||
`AllowedIPs = 10.90.0.5/32` stanza and asking for octet 5 produced the correct refusal; the tree
|
||||
was restored afterwards and `git diff --stat` showed only the intended template edit.
|
||||
- **Zero active allocations remain**: `grep -vE '^[[:space:]]*#' peers.yaml | grep -c AllowedIPs`
|
||||
→ `0`.
|
||||
- **Toolbox gate**: rc 3, with `spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`,
|
||||
`l1` and `approval-policy` all pass; rc 3 is the two environmental skips (`gitea-settings`,
|
||||
conformance L2/L3). **Not green. Reported as rc 3.**
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **No peer is enrolled by this branch.** The keypair I generated was a throwaway and was deleted;
|
||||
the operator must run the script themselves, since the device's private key must exist only on
|
||||
the device. So the end of the enrolment path — a stanza merged into `peers.yaml`, Flux rolling
|
||||
the DaemonSet, and `wg show` listing a peer — is still unexercised.
|
||||
- **The duplicate-NAME warning** (`grep -qi "^ *# *${NAME} "`) deliberately still inspects
|
||||
comments, because per-peer labels are comments. It is no longer tripped by the template, but I
|
||||
did not test it against a real enrolled peer.
|
||||
- **Nothing about client traffic.** The WAN port-forward is still not configured and there is still
|
||||
no off-LAN vantage point, so the tunnel has carried no packet.
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
date: 2026-10-06
|
||||
branch: docs/pm-agent-registry-pull-prereq
|
||||
title: The nodes resolve gitea to the WAN, so the failing certificate is the router's — the second node process to need the issuer pin
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
The image pull for `pm-agent` failed TLS verification, and the previous entry on this branch
|
||||
honestly recorded it as **not diagnosed**. The operator asked for the diagnosis before any
|
||||
registry design. This is it, and the fix turns out to be a pattern this repository already
|
||||
has.
|
||||
|
||||
`infra/ansible/inventory/group_vars/k3s_prod/vars.yml` now pins
|
||||
`gitea.{{ k3s_base_domain }}` → `192.168.2.20` in `node_baseline_hosts_pins`, beside the
|
||||
Keycloak issuer pin that is there for the identical reason.
|
||||
|
||||
**It reaches the nodes the way every other node setting in this project does — through
|
||||
Ansible, from the inventory.** Node configuration is Ansible's (and, for the dev VMs,
|
||||
OpenTofu's); nothing here touches a node by hand. For prod specifically, Ansible is the
|
||||
*only* surface: `infra/PROD-PROVISIONING.md` records that prod is 3× bare metal and that the
|
||||
dev path (OpenTofu → Proxmox VM → cloud-init) does not apply, so there is no
|
||||
cloud-init/tofu half to keep in parity for this. The one scoped, idempotent command:
|
||||
|
||||
cd infra/ansible && ansible-playbook playbooks/node-baseline.yml \
|
||||
-e k3s_hosts=k3s_prod --tags hosts
|
||||
|
||||
`--tags hosts` runs ONLY the `/etc/hosts` task, so applying this pin cannot drift any other
|
||||
baseline setting. Running the play is an operator action (it has become/root on prod), but
|
||||
the configuration itself is declared, reviewed and version-controlled — not typed on a node.
|
||||
|
||||
The docs that described this as an undiagnosed blocker now describe the cause and the fix:
|
||||
`platform/pm-agent/README.md`, `runbooks/pm-agent.md` (its symptom row now names the pin and
|
||||
the play), and `docs/plans/pm-agent-issue-to-pr.md`.
|
||||
|
||||
Also recorded, in the two places an operator reads when creating the credential: **the agent
|
||||
authenticates to Gitea with a local account and a scoped PAT, by decision** (operator,
|
||||
2026-10-06), not with a federated identity. Written down because the alternative looks
|
||||
obviously better until you check whether it is possible — see below.
|
||||
|
||||
## What I verified
|
||||
|
||||
Every step measured, read-only, in this order.
|
||||
|
||||
**The failing certificate is the router's, not the Gateway's.**
|
||||
|
||||
- containerd's error names TLS, not the digest: `tls: failed to verify certificate: x509:
|
||||
certificate is not valid for any names, but wanted to match gitea.home.keithjackson.dev`.
|
||||
"not valid for **any** names" means the presented certificate carries no SANs at all.
|
||||
- `192.168.2.20` is the shared Gateway's own LoadBalancer VIP
|
||||
(`gateway/cilium-gateway-shared`, from the Cilium pool `192.168.2.20-30`), and its `https`
|
||||
listener serves `*.home.keithjackson.dev` with `wildcard-home-tls`. From the operator's
|
||||
workstation the name resolves there and serves the real Let's Encrypt
|
||||
`CN=*.home.keithjackson.dev` with correct SANs. **So the certificate and the Gateway are
|
||||
healthy.**
|
||||
- **The node asks a different resolver and gets a different answer.** Prod's
|
||||
`node_baseline_dns_servers` is the LAN gateway, not Pi-hole — deliberately, as that file
|
||||
explains. Public DNS for this zone is a CNAME to `wan.home.keithjackson.dev`: measured via
|
||||
both `1.1.1.1` and `8.8.8.8`, `gitea.home.keithjackson.dev` →
|
||||
`wan.home.keithjackson.dev.` → `136.51.115.252`, and the apex answers the same.
|
||||
- **That address serves a certificate with no names.** Measured from the workstation against
|
||||
the WAN address with the right SNI:
|
||||
|
||||
subject=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
|
||||
issuer=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
|
||||
No extensions in certificate
|
||||
|
||||
No extensions means no `subjectAltName`, which is exactly "not valid for any names". The
|
||||
`CN=%D` is the router's placeholder.
|
||||
|
||||
**This repository had already measured the same thing, for Keycloak, and I did not need to
|
||||
re-derive it.** `infra/ansible/inventory/group_vars/k3s_prod/vars.yml` says, above the pin:
|
||||
"Since platform/gateway/gateway.yaml set an external-dns `target`, every public name is a
|
||||
CNAME to wan.home.keithjackson.dev = the home WAN IP. The SOHO router does NOT hairpin: from
|
||||
the LAN, WAN:443 answers with the router's own self-signed placeholder cert (measured from
|
||||
k3s-prod-2/-3 2026-10-03: `curl` exit 60, ssl_verify_result=18)." Same cause, same symptom,
|
||||
one name later. The pin exists precisely for node processes that must reach the Gateway
|
||||
directly; containerd is one, and nobody had added it.
|
||||
|
||||
**The parity the pin has to satisfy:** `192.168.2.20` equals the live
|
||||
`GATEWAY_VIP`, and `k3s_base_domain` equals the live `BASE_DOMAIN` — both read from the
|
||||
cluster, not from the comment that claims prod serves `prod.home.keithjackson.dev` (it does
|
||||
not; `flux-system/cluster-vars` reports `BASE_DOMAIN=home.keithjackson.dev` and
|
||||
`GATEWAY_VIP=192.168.2.20`, and Ansible's `k3s_base_domain` is `home.keithjackson.dev`).
|
||||
That comment is stale history.
|
||||
|
||||
**The credential decision, and why the obvious alternative is not available.** Gitea's API
|
||||
accepts basic auth, PATs, SSH signatures and its own OAuth2 bearer tokens; as an OAuth2
|
||||
provider Gitea implements **only the Authorization Code Grant** (plus PKCE/OIDC), so there
|
||||
is no client-credentials grant, and nothing in Gitea validates a token issued by a
|
||||
third-party OIDC provider. A Keycloak service-account token therefore cannot authenticate to
|
||||
Gitea's API or to git-over-HTTP. Read from Gitea's own documentation
|
||||
(`docs.gitea.com/development/api-usage`, `/development/oauth2-provider`), not assumed.
|
||||
|
||||
**Gate:** `./ci/gate.sh` in the pinned toolbox from a §0 worktree — see the branch's run; rc 3
|
||||
with `gitea-settings` the only stage that did not run.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **That the pin fixes the pull.** It is IaC, and nothing has applied it: `/etc/hosts` on the
|
||||
nodes is written by the `node_baseline` play's `hosts`-tagged task, which is an operator
|
||||
action. I did confirm the mechanism is not inert — `roles/node_baseline/tasks/main.yml`
|
||||
renders `node_baseline_hosts_pins` into a marked `blockinfile` with `backup: true`, and
|
||||
`state: absent` when the list empties, so a removed entry really leaves the node. Until that runs
|
||||
and an image exists, "the node now reaches the Gateway" is a design claim. The evidence
|
||||
that it *will* is the Keycloak pin working for the same cause — which I also did not
|
||||
re-measure.
|
||||
- **Why public DNS answers at all for an `internal` service.** `catalog/gitea.yaml` sets no
|
||||
`exposure`, so it inherits `internal`, and the renderer contract says an internal service's
|
||||
HTTPRoute carries `external-dns.alpha.kubernetes.io/controller: none` and publishes no
|
||||
public record. Yet `gitea.home.keithjackson.dev` resolves publicly to the WAN CNAME. I did
|
||||
not chase that: it may be a zone-level wildcard rather than a published record, and it did
|
||||
not change the diagnosis. It is worth someone's attention, because "internal services
|
||||
publish no public DNS" is an L0-checked claim and this looks like a case where the *name*
|
||||
resolves regardless of what the renderer withheld.
|
||||
- **Registry authentication.** Unchanged and still open: an unauthenticated client gets
|
||||
nothing from this registry, and no `imagePullSecret` exists anywhere in the tree. With the
|
||||
credential model now decided (local Gitea accounts with PATs) the shape is a
|
||||
`dockerconfigjson` for an account whose token carries `read:package`, referenced from the
|
||||
Deployment and the committed worker Job template — and the worker Job template's shape is
|
||||
pinned by admission, so whether the policy should also pin `imagePullSecrets` deserves a
|
||||
deliberate look rather than a quiet addition. Not built: minting that token is an operator
|
||||
action, and I did not want to guess at a second credential's scope.
|
||||
- **Anything about runtime.** The image still does not exist. No dispatcher has
|
||||
authenticated, no issue has been triaged, no worker Job has been created, and the admission
|
||||
policy — live, `Enforce`, six rules — has still never admitted or refused a real Job.
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
date: 2026-10-06
|
||||
branch: fix/vpn-ingress-ip-forward
|
||||
title: The VPN tunnel never came up — a hostNetwork pod cannot write net.ipv4.ip_forward, and wg-quick treats a failed PostUp as fatal
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
The remote-access VPN reconciled for the first time with a real server key (#60 then #62) and
|
||||
**the tunnel did not come up**. One line in my own manifest was the whole cause.
|
||||
|
||||
Measured in the `wireguard` container on prod, 2026-10-06:
|
||||
|
||||
```
|
||||
[#] ip link add dev wg0 type wireguard
|
||||
[#] wg addconf wg0 /dev/fd/63
|
||||
[#] ip -4 address add 10.90.0.1/24 dev wg0
|
||||
[#] ip link set mtu 1420 up dev wg0
|
||||
[#] sysctl -q -w net.ipv4.ip_forward=1
|
||||
sysctl: permission denied on key "net.ipv4.ip_forward"
|
||||
[#] ip link delete dev wg0
|
||||
**** Tunnel /config/wg_confs/wg0.conf failed, will stop all others! ****
|
||||
```
|
||||
|
||||
Everything up to that point worked. **The cause is not a missing capability.** With
|
||||
`hostNetwork: true` the runtime mounts `/proc/sys` **read-only** in the container — confirmed in the
|
||||
pod's own `/proc/mounts`: `proc /proc/sys proc ro,nosuid,nodev,noexec,relatime` — precisely because
|
||||
those sysctls are the *host's* globals and a container must not rewrite them. `CAP_NET_ADMIN` cannot
|
||||
lift a read-only mount, so this can never succeed from here. And `wg-quick` treats any failed
|
||||
`PostUp` as fatal, so it deleted the interface it had just finished building.
|
||||
|
||||
**The line was never needed.** `node_baseline` writes `net.ipv4.ip_forward=1` to
|
||||
`/etc/sysctl.d/99-k3s-cilium.conf`, and reading the value from inside the pod returns `1`. I had
|
||||
written the header comment *correctly* — "ip_forward is ALREADY 1 on these nodes … so no pod sysctl
|
||||
is needed" — and then emitted the sysctl anyway, justified in its own comment as "re-asserted so
|
||||
this component does not silently depend on another role's sysctl file surviving". That defensive
|
||||
re-assertion is the entire outage. **The lesson is narrow and reusable: in a hostNetwork pod,
|
||||
verify host state, never write it.**
|
||||
|
||||
Fix:
|
||||
|
||||
- **`platform/vpn-ingress/daemonset.yaml`**: the `PostUp = sysctl …` line is gone, replaced by a
|
||||
**read** in the init container — `grep -qx 1 /proc/sys/net/ipv4/ip_forward` — which fails closed
|
||||
with a message naming the `node_baseline` role. Reading `/proc/sys` is permitted; only writing is
|
||||
not. Fail-closed still matters: a tunnel that comes up but cannot forward would satisfy every
|
||||
probe in this component and silently drop every client's traffic.
|
||||
- The header bullet that said "no pod sysctl is needed" now also says **do not attempt one**, with
|
||||
the mechanism, so the next author does not re-add it for the same reason I did.
|
||||
- The `securityContext` comment claimed NET_ADMIN was for "create the wg0 link, set the host sysctl,
|
||||
and write the netfilter rules". The middle clause was false; corrected.
|
||||
- The image's `chown: … Operation not permitted` startup run — plus its own "Permissions could not
|
||||
be set … we will not provide support for it" — is now documented as **expected and harmless**, in
|
||||
both the manifest and the runbook, so nobody chases it. Capabilities are dropped to `NET_ADMIN`
|
||||
only, so its s6 init has no `CAP_CHOWN`; `/config` is a writable tmpfs, everything runs as root,
|
||||
and `wg-quick` proceeds past it (as it did here — the failure was 30 lines later).
|
||||
- **`runbooks/vpn-remote-access.md`**: a "what a healthy start looks like" block with the real log
|
||||
lines; `ip_forward is 0` added to the init-failure table; and the note that `0/1 Running` with
|
||||
`Startup probe failed: Unable to access interface: No such device` is the signature of `wg-quick`
|
||||
tearing the tunnel down — read the `[#]` line it died on.
|
||||
- **`platform/vpn-ingress/README.md`**: a table of what the first reconcile settled (below).
|
||||
|
||||
No sensitive paths: `platform/vpn-ingress/**`, `runbooks/**` and `decisions/**` only. This merges on
|
||||
a green gate with no override.
|
||||
|
||||
## What I verified
|
||||
|
||||
Live prod, read-only, 2026-10-06. **Four questions this component's design rested on are now
|
||||
measured rather than assumed — three went the right way, one was the defect above.**
|
||||
|
||||
- **The GitOps path is proven end to end.** `Kustomization vpn-ingress` `lastAppliedRevision:
|
||||
main@sha1:cf7d5e4` equals the GitRepository artifact revision. The Secret carries
|
||||
`kustomize.toolkit.fluxcd.io/name: vpn-ingress`, so Flux delivered it.
|
||||
- **SOPS decryption works, proven by digest rather than by eye.** The value in
|
||||
`platform/vpn-ingress/wg-server-key.sops.yaml` on `main`, decrypted, is 44 bytes, is **not** the
|
||||
placeholder, and **`wg pubkey` accepts it**. Its SHA-256 equals the SHA-256 of the live Secret's
|
||||
decoded `data.privatekey` (`d7c8f8de6f252649…` both sides). No key material was printed at any
|
||||
point — lengths, booleans and digests only.
|
||||
- **The `wg pubkey` guard fired correctly, and its `ENC[` branch was never needed.** During the
|
||||
kubelet window before the updated Secret propagated, all three pods reported *"It is still the
|
||||
committed REPLACE-ME PLACEHOLDER"* — the guard distinguishing a stale/unfilled value from
|
||||
ciphertext, exactly as designed. It cleared on its own once the mount refreshed; **a transient
|
||||
`REPLACE-ME` CrashLoop for a minute or two after the key PR merges is expected, not a fault.**
|
||||
- **The kernel module reaches the pod**: `ip link add dev wg0 type wireguard` succeeded and
|
||||
`/proc/modules` lists `wireguard`. So the `node_baseline` addition works and `CAP_SYS_MODULE` is
|
||||
correctly not required.
|
||||
- **`allowPrivilegeEscalation: false` does NOT break the image's s6 init** — the container reaches
|
||||
`wg-quick` normally. The flag stays; the fear recorded in #55 is closed.
|
||||
- **The iptables backends MATCH, which was my single biggest documented doubt.** The image ships
|
||||
`iptables v1.8.13 (nf_tables)` via `/usr/sbin/xtables-nft-multi`, and Debian 13 is nf_tables. The
|
||||
nightmare case — rules accepted into a table the kernel never consults, so handshakes succeed and
|
||||
every forwarded connection hangs — cannot occur on this pairing.
|
||||
- **`net.ipv4.ip_forward` is `1`** in the host netns the pod shares, and `/proc/sys` is `ro` there.
|
||||
Both read directly from the pod.
|
||||
- **Toolbox gate** from an isolated worktree: **rc 3**, with `spec-immutability`, `decisions-log`,
|
||||
`lint`, `l0`, `render-golden`, `l1` and `approval-policy` all **pass**; rc 3 is the two
|
||||
environmental skips (`gitea-settings`, conformance L2/L3). **Not green. Reported as rc 3.**
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **That the tunnel now comes up.** This fix has not reconciled. The next reconcile is the test, and
|
||||
its success criterion is specific: pods `1/1 Running`, and `wg show` reporting `interface: wg0`
|
||||
with `listening port: 51820`.
|
||||
- **That the MASQUERADE and FORWARD rules actually apply.** They are the next statements after the
|
||||
one that failed, so **they have never executed**. The backends matching (above) removes the worst
|
||||
failure mode but proves nothing about the rules themselves. First check after the pods go Ready:
|
||||
`iptables -t nat -S POSTROUTING | grep 10.90.0.0/24` on a node.
|
||||
- **Anything about client traffic.** Still zero peers enrolled, still no WAN port-forward, still no
|
||||
off-LAN vantage point. The VPN has carried no packet.
|
||||
- **Whether the transient `REPLACE-ME` window has an upper bound.** It cleared within ~2 minutes
|
||||
here; I did not determine the kubelet refresh behaviour, so a longer stall after a key change is
|
||||
uncharacterised.
|
||||
- **The `sops set` hand-off command**, still. The operator filled the key in by their own route
|
||||
(#62), so the exact quoting I documented inside `dev.sh run bash -c` remains unexercised.
|
||||
@@ -233,9 +233,21 @@ about runtime behaviour in this document is a design claim, not a measurement.
|
||||
|
||||
## Follow-ups, in the order they are worth doing
|
||||
|
||||
1. **Build, push, pin.** Operator runs `infra/agent/build-image.sh`, pushes, and one PR
|
||||
replaces both placeholder digests. Expect the build to need iteration: the nodejs module
|
||||
stream and the npm install have never run against this base.
|
||||
1. **Make the registry pullable, THEN build, push, pin.** Measured after the merge, and
|
||||
neither half is declared anywhere in this repository
|
||||
(`decisions/2026-10-05-pm-agent-registry-pull-prereq.md`):
|
||||
(i) the nodes resolved the registry hostname to the WAN IP and failed TLS against the
|
||||
router's `CN=%D` placeholder certificate — **diagnosed and fixed** by pinning
|
||||
`gitea.{{ k3s_base_domain }}` to the Gateway VIP in `node_baseline_hosts_pins`, the same
|
||||
pin the Keycloak issuer already carries, applied by
|
||||
`ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags hosts`;
|
||||
(ii) the pull is still unauthenticated and no `imagePullSecret` exists in the tree — with
|
||||
the credential decision now being local Gitea accounts with PATs, that means a
|
||||
`dockerconfigjson` for an account whose token carries `read:package`. Only then does the
|
||||
digest matter. Operator runs `infra/agent/build-image.sh`, pushes, and one PR replaces both
|
||||
placeholder digests. Expect the build itself to need iteration too: the nodejs module
|
||||
stream and the npm install have never run against this base. The same two blockers sit in
|
||||
front of `conformance-runner`, which shares the registry path and has never been pulled.
|
||||
2. **Create the credentials** per `pm-agent-credentials.sops.yaml.example`, and **verify the
|
||||
bot's permission is `write`, not `admin`** — that measurement is the control that keeps
|
||||
the agent out of its own approval gate.
|
||||
|
||||
+12
-2
@@ -90,9 +90,19 @@ esac
|
||||
# pointing at an external host would be REACHABLE: it could clone a repository that is not
|
||||
# this one and push this repository's work somewhere else. The allowlist is baked in here,
|
||||
# where changing it requires rebuilding the image whose digest admission pins.
|
||||
#
|
||||
# ONE ENTRY, AND IT IS THE CLUSTER-LOCAL SERVICE NAME. This agent runs INSIDE the cluster, so
|
||||
# it reaches Gitea the way every other in-cluster consumer does — `*.svc.cluster.local` —
|
||||
# which is by design a different path from how a client outside the cluster reaches the same
|
||||
# service. The external hostname `gitea.home.keithjackson.dev` used to be a second accepted
|
||||
# value here; it is removed deliberately. It was never needed (nothing sets it), it routes
|
||||
# in-cluster traffic out through the Gateway and back for no reason, and as an ACCEPTED value
|
||||
# it widened this allowlist to a host reachable over the `world:443` egress rule — which is
|
||||
# the exact shape of injection the allowlist exists to refuse. The cluster-local name also
|
||||
# depends on none of public DNS, Cloudflare, the Gateway or the certificate.
|
||||
case "${PM_GITEA_URL}" in
|
||||
http://gitea.gitea.svc.cluster.local:3000|https://gitea.home.keithjackson.dev) ;;
|
||||
*) die "PM_GITEA_URL '${PM_GITEA_URL}' is not an allowed git host (see this script's header)" ;;
|
||||
http://gitea.gitea.svc.cluster.local:3000) ;;
|
||||
*) die "PM_GITEA_URL '${PM_GITEA_URL}' is not the allowed in-cluster git host (see this script's header)" ;;
|
||||
esac
|
||||
[[ "${PM_GITEA_OWNER}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$ ]] || die "PM_GITEA_OWNER is malformed"
|
||||
[[ "${PM_GITEA_REPO}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$ ]] || die "PM_GITEA_REPO is malformed"
|
||||
|
||||
@@ -86,12 +86,22 @@ refuses "branch equal to the base branch" "must not be the base branch" \
|
||||
# --- where code comes FROM and goes TO -------------------------------------
|
||||
# The namespace's egress policy permits tcp/443 to the world (the model needs it), so an
|
||||
# injected host would be REACHABLE. These are the cases that matter most.
|
||||
refuses "a redirected git host" "is not an allowed git host" \
|
||||
refuses "a redirected git host" "is not the allowed in-cluster git host" \
|
||||
PM_GITEA_URL=https://evil.invalid
|
||||
refuses "a real, reachable, WRONG git host" "is not an allowed git host" \
|
||||
refuses "a real, reachable, WRONG git host" "is not the allowed in-cluster git host" \
|
||||
PM_GITEA_URL=https://github.com
|
||||
refuses "the right host on a smuggled path" "is not an allowed git host" \
|
||||
refuses "the right host on a smuggled path" "is not the allowed in-cluster git host" \
|
||||
PM_GITEA_URL=http://gitea.gitea.svc.cluster.local:3000/../..
|
||||
# This agent is IN the cluster, so it reaches Gitea by its cluster-local service name. The
|
||||
# EXTERNAL hostname is refused too — it used to be a second accepted value, and as an
|
||||
# accepted value it widened this allowlist to a host reachable over the `world:443` egress
|
||||
# rule, which is the injection shape the allowlist exists to refuse.
|
||||
refuses "this Gitea's EXTERNAL hostname" "is not the allowed in-cluster git host" \
|
||||
PM_GITEA_URL=https://gitea.home.keithjackson.dev
|
||||
refuses "the cluster-local name over https" "is not the allowed in-cluster git host" \
|
||||
PM_GITEA_URL=https://gitea.gitea.svc.cluster.local:3000
|
||||
refuses "the cluster-local name, wrong port" "is not the allowed in-cluster git host" \
|
||||
PM_GITEA_URL=http://gitea.gitea.svc.cluster.local:8080
|
||||
refuses "a malformed owner" "PM_GITEA_OWNER is malformed" \
|
||||
'PM_GITEA_OWNER=../other-org'
|
||||
refuses "a malformed repository" "PM_GITEA_REPO is malformed" \
|
||||
|
||||
@@ -327,6 +327,29 @@ node_baseline_hosts_pins:
|
||||
- ip: "192.168.2.20"
|
||||
names:
|
||||
- "keycloak.{{ k3s_base_domain }}"
|
||||
# gitea: the SECOND node process to need this pin, for the identical reason, found
|
||||
# 2026-10-06. containerd pulls images as a NODE process, so it resolves through the
|
||||
# public upstreams above and gets the CNAME to wan.home.keithjackson.dev — the WAN IP,
|
||||
# which this router does not hairpin. The pull then fails on the router's own
|
||||
# placeholder certificate rather than on anything to do with the image:
|
||||
#
|
||||
# failed to resolve reference "gitea.home.keithjackson.dev/home-network/agent-runner@sha256:…":
|
||||
# tls: failed to verify certificate: x509: certificate is not valid for any names,
|
||||
# but wanted to match gitea.home.keithjackson.dev
|
||||
#
|
||||
# Measured from the workstation the same day, against the WAN address with that SNI:
|
||||
# subject=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
|
||||
# No extensions in certificate <- no SANs at all, hence "not valid for ANY names"
|
||||
# while the same name via PiHole answers 192.168.2.20 and serves the real
|
||||
# Let's Encrypt *.home.keithjackson.dev. So the certificate and the Gateway are fine;
|
||||
# only the node's ANSWER is wrong. Same root cause, same fix, as the issuer pin above.
|
||||
#
|
||||
# WHO NEEDS IT: any workload whose image lives in the in-cluster Gitea registry —
|
||||
# today platform/pm-agent (agent-runner) and conformance/incluster
|
||||
# (conformance-runner), neither of which has ever been pulled. A pull is still
|
||||
# UNAUTHENTICATED after this pin, which is a separate thing to settle; this only makes
|
||||
# the node reach the right endpoint.
|
||||
- "gitea.{{ k3s_base_domain }}"
|
||||
|
||||
# --- platform hostname suffix, ANSIBLE SIDE (R-ENV-02, R-DNS-01) --------------------
|
||||
# PROD SERVES ITS OWN SUBDOMAIN. [parity] MUST equal BASE_DOMAIN in clusters/prod/
|
||||
|
||||
@@ -133,7 +133,13 @@ CLIENT_ADDR="${CLIENT_BASE}.${OCTET}"
|
||||
# --- refuse a collision, because the failure is otherwise invisible -----------------------------
|
||||
# Two devices sharing an address does not error anywhere: the server routes that address to
|
||||
# whichever peer handshook most recently, so BOTH devices appear to work intermittently.
|
||||
if [ -r "$PEERS_FILE" ] && grep -q "AllowedIPs *= *${CLIENT_ADDR}/32" "$PEERS_FILE"; then
|
||||
# COMMENT LINES ARE NOT ALLOCATIONS. This used to grep the whole file and so matched the
|
||||
# COMMENTED-OUT EXAMPLE STANZA in peers.yaml, which made `.2` — the first address anyone would
|
||||
# ask for — permanently un-allocatable with a message claiming it was already taken. Strip
|
||||
# comments first: a real stanza's `AllowedIPs` line is never commented, while both the template
|
||||
# and every peer's `# <name> — <owner>` label are.
|
||||
if [ -r "$PEERS_FILE" ] \
|
||||
&& grep -vE '^[[:space:]]*#' "$PEERS_FILE" | grep -q "AllowedIPs *= *${CLIENT_ADDR}/32"; then
|
||||
die "${CLIENT_ADDR}/32 is already allocated in platform/vpn-ingress/peers.yaml. Pick another octet."
|
||||
fi
|
||||
if [ -r "$PEERS_FILE" ] && grep -qi "^ *# *${NAME} " "$PEERS_FILE"; then
|
||||
|
||||
@@ -22,6 +22,25 @@ requires.
|
||||
|
||||
---
|
||||
|
||||
## Why the agent authenticates to Gitea with a local account and a PAT
|
||||
|
||||
Not Keycloak, and that is a decision rather than an omission (operator, 2026-10-06): the
|
||||
agents only ever talk to Gitea, so a local Gitea account with a scoped personal access token
|
||||
is the whole requirement, and it is drastically simpler than federating a machine identity.
|
||||
|
||||
It is also what Gitea can actually do. Keycloak service accounts were considered and are not
|
||||
available for this: Gitea's API accepts basic auth, PATs, SSH signatures and **its own**
|
||||
OAuth2 bearer tokens, and as an OAuth2 provider Gitea implements **only the Authorization
|
||||
Code Grant** — there is no client-credentials grant, and nothing in Gitea validates a token
|
||||
issued by a third-party OIDC provider. So a Keycloak service-account token cannot
|
||||
authenticate to Gitea's API or to git-over-HTTP at all. Keycloak remains what it is for human
|
||||
access: the browser login source registered on Gitea (`catalog/gitea.yaml`).
|
||||
|
||||
What still matters, and is enforced by the hand-off rather than by Keycloak: **the account
|
||||
must not be an administrator** — `infra/gitea/approval-policy.json` authorises
|
||||
`/approve-override` on live repository permission alone, so an admin-permission bot could
|
||||
waive the sensitive-path human review on its own pull request.
|
||||
|
||||
## It does nothing at all until an operator does two things
|
||||
|
||||
Both are deliberate, and both fail **visibly** rather than silently:
|
||||
@@ -31,6 +50,36 @@ Both are deliberate, and both fail **visibly** rather than silently:
|
||||
`infra/agent/build-image.sh` (which verifies it and refuses to push), push it as the
|
||||
operator, then update **both** places with the digest `podman push --digestfile` returned,
|
||||
in one pull request. Until then: `ImagePullBackOff`.
|
||||
|
||||
**Two things have to be true before any digest will pull, and neither is declared in this
|
||||
repository.** Both were measured after PR #57 merged — this service is the first workload
|
||||
in the cluster ever to reference the in-cluster registry, so nothing had exercised the
|
||||
path. Details and the exact commands:
|
||||
[`decisions/2026-10-05-pm-agent-registry-pull-prereq.md`](../../decisions/2026-10-05-pm-agent-registry-pull-prereq.md).
|
||||
|
||||
- **The nodes resolve the registry hostname to the WAN, so the TLS they fail to verify is
|
||||
the router's — DIAGNOSED 2026-10-06, and fixed in this change.** containerd pulls as a
|
||||
*node* process, so it resolves through the node's public upstreams
|
||||
(`node_baseline_dns_servers` = the LAN gateway) rather than through Pi-hole. The public
|
||||
answer for every name in this zone is a CNAME to `wan.home.keithjackson.dev` — the WAN
|
||||
IP — which this router does not hairpin; it answers :443 with its own placeholder
|
||||
certificate `CN=%D` carrying **no extensions, therefore no SANs**, which is precisely
|
||||
`x509: certificate is not valid for any names`. The Gateway, the wildcard certificate
|
||||
and the digest were never implicated. The fix is the pin this repository already uses
|
||||
for the Keycloak issuer, for the identical reason: `gitea.{{ k3s_base_domain }}` is now
|
||||
in `node_baseline_hosts_pins` — node configuration is Ansible's, declared in the
|
||||
inventory, never typed on a node. Apply it scoped to that one task:
|
||||
`cd infra/ansible && ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod
|
||||
--tags hosts`.
|
||||
- **The pull is still unauthenticated** once the node reaches the right endpoint. An
|
||||
unauthenticated client gets nothing from this Gitea's registry, and there is no
|
||||
`imagePullSecret` anywhere in the tree. Consistent with the agent credential decision
|
||||
(local Gitea accounts with PATs), the fix is a `dockerconfigjson` for a local Gitea
|
||||
account whose token carries `read:package`, referenced from the Deployment and the
|
||||
committed worker Job template — not built yet, because minting that token is yours.
|
||||
|
||||
The same two apply to `conformance-runner`: same registry path, same push-then-pin model,
|
||||
never pulled either.
|
||||
2. **The credentials are placeholders.** `pm-agent-credentials.sops.yaml` is encrypted to the
|
||||
repository's age recipient but holds `REPLACE-ME-…` strings. The hand-off —
|
||||
which Gitea account to create, which scopes, and why it must *not* be an administrator —
|
||||
@@ -96,7 +145,7 @@ Job from a committed template. **The model's output is data, never a command.**
|
||||
| **cannot** | merge anything — the gate is the approver, and the service makes no merge call |
|
||||
| **cannot** | reach the Kubernetes API: no ServiceAccount token is mounted, the `pm-agent-worker` identity is bound to nothing, and the egress policy does not include the API server. `kubectl` is on its `PATH` (the gate's toolchain needs it) and is inert |
|
||||
| **cannot** | reach anything except this Gitea and tcp/443 — no package registry, no GitHub |
|
||||
| **cannot** | clone from or push to any host but this Gitea: `pm-worker` validates `PM_GITEA_URL` against an allowlist baked into the image |
|
||||
| **cannot** | clone from or push to anywhere but `gitea.gitea.svc.cluster.local:3000`: `pm-worker` validates `PM_GITEA_URL` against a one-entry allowlist baked into the image. In-cluster consumers reach a service by its cluster-local name — a different path by design from how a client outside the cluster reaches it — so the external hostname is not an accepted value |
|
||||
| **cannot** | authenticate a `git push` **during the model session**: `GIT_ASKPASS` is unset for its duration, so the restriction is structural rather than a request in a prompt |
|
||||
| **cannot** | be created in any shape but the committed one — see the admission policy |
|
||||
|
||||
|
||||
@@ -51,6 +51,15 @@
|
||||
# (2) MINT THE GITEA TOKEN as that user (log in as `pm-agent`, or use the admin API to
|
||||
# create a token for it): Settings -> Applications -> Generate New Token.
|
||||
#
|
||||
# A LOCAL GITEA ACCOUNT WITH A PAT IS THE DECIDED MODEL (operator, 2026-10-06), not
|
||||
# Keycloak. The agents only ever talk to Gitea, so this is the whole requirement and it
|
||||
# is far simpler than federating a machine identity. It is also the only thing that
|
||||
# works: Gitea's API accepts basic auth, PATs, SSH signatures and its OWN OAuth2 bearer
|
||||
# tokens, and as a provider Gitea implements only the Authorization Code Grant — no
|
||||
# client-credentials grant, and nothing that validates a third-party OIDC token. A
|
||||
# Keycloak service-account token therefore cannot authenticate to Gitea's API or to
|
||||
# git-over-HTTP. Do not "upgrade" this to Keycloak without re-reading that.
|
||||
#
|
||||
# Scopes: `write:repository` and `write:issue`. Nothing else — in particular NOT
|
||||
# `write:admin`, NOT `write:organization`, NOT `write:user`.
|
||||
#
|
||||
|
||||
@@ -173,6 +173,18 @@ NetworkPolicy applies.
|
||||
(`latest handshake` under ~3 min means live). Never dump `wg showconf` — it prints the private key.
|
||||
* **Full bring-up and verification:** `runbooks/vpn-remote-access.md`.
|
||||
|
||||
## Measured on prod, 2026-10-06 — what is now known rather than assumed
|
||||
|
||||
The first real reconcile settled four things this component's design rested on. Three went the
|
||||
right way; one was a defect in my own manifest.
|
||||
|
||||
| question | answer |
|
||||
|---|---|
|
||||
| Does the **kernel module** reach the pod? | **Yes.** `ip link add dev wg0 type wireguard` succeeds, and `/proc/modules` lists `wireguard`, after the `node_baseline` run. `CAP_SYS_MODULE` is correctly not needed. |
|
||||
| Does `allowPrivilegeEscalation: false` break the image's **s6 init**? | **No.** The container reaches `wg-quick` normally. The flag stays. |
|
||||
| Is the image's **iptables** backend compatible with the host's? | **Yes — both nft.** The image ships `iptables v1.8.13 (nf_tables)` via `xtables-nft-multi`, and Debian 13 is nf_tables. This was the documented worst fear (rules silently landing in a table the kernel does not consult); it is closed. |
|
||||
| Can the pod set **`net.ipv4.ip_forward`**? | **No, and it must not try.** `/proc/sys` is mounted **read-only** under `hostNetwork` (`proc /proc/sys proc ro,…`), because those are the host's globals; `sysctl -w` fails with EPERM whatever the capabilities. A failed `PostUp` is fatal to `wg-quick`, so this one line took the whole tunnel down. The node already has it set to `1`; the init container now **verifies** rather than writes. |
|
||||
|
||||
## Prerequisites outside this directory
|
||||
|
||||
1. **The `wireguard` kernel module on every node.** Added to `node_baseline_kernel_modules`
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
# /etc/sysctl.d/99-k3s-cilium.conf), so no pod sysctl is needed — and `net.ipv4.ip_forward`
|
||||
# is NOT in the kubelet's safe-sysctl allowlist, so a pod-netns version of this would have
|
||||
# required `--allowed-unsafe-sysctls` on all three kubelets (a node-restart change).
|
||||
# COROLLARY LEARNED THE HARD WAY (prod, 2026-10-06): this pod must not merely avoid NEEDING
|
||||
# the sysctl, it must not ATTEMPT it. hostNetwork implies /proc/sys is mounted read-only, so
|
||||
# `sysctl -w` fails with EPERM regardless of capabilities, and wg-quick treats a failed PostUp
|
||||
# as fatal and deletes the interface. Verify the value; never write it.
|
||||
# * no LoadBalancer VIP, so no CiliumL2AnnouncementPolicy lease lottery (the defect that cost
|
||||
# this LAN an 8-day Minecraft outage) and no `externalTrafficPolicy` trade-off at all.
|
||||
# * no double encapsulation. WireGuard's UDP lands on the node's own NIC, so the underlay is a
|
||||
@@ -171,6 +175,19 @@ spec:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The pod shares the HOST netns, so this reads the node's real setting. Reading
|
||||
# /proc/sys is permitted even though writing is not (see the PostUp note below).
|
||||
# Fail closed: a tunnel that comes up but cannot forward would look healthy to every
|
||||
# probe here and simply drop every client's traffic.
|
||||
if ! grep -qx 1 /proc/sys/net/ipv4/ip_forward; then
|
||||
echo "FATAL: net.ipv4.ip_forward is 0 on this node, so forwarded VPN traffic would" >&2
|
||||
echo " be dropped. This pod CANNOT set it: with hostNetwork the runtime mounts" >&2
|
||||
echo " /proc/sys read-only, because it is the host's global setting." >&2
|
||||
echo " Fix on the NODE: run the node_baseline role (it writes" >&2
|
||||
echo " /etc/sysctl.d/99-k3s-cilium.conf) — runbooks/vpn-remote-access.md step 1." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo '[Interface]'
|
||||
echo "Address = $base.1/$mask"
|
||||
@@ -184,9 +201,20 @@ spec:
|
||||
echo 'MTU = 1420'
|
||||
printf 'PrivateKey = %s\n' "$key"
|
||||
# ── Forwarding + SNAT, applied in the HOST netns when wg0 comes up ──
|
||||
# ip_forward is already 1 from node_baseline; re-asserted so this component does not
|
||||
# silently depend on another role's sysctl file surviving.
|
||||
echo 'PostUp = sysctl -q -w net.ipv4.ip_forward=1'
|
||||
# NO `sysctl -w net.ipv4.ip_forward=1` HERE. It is IMPOSSIBLE from this pod and it was
|
||||
# the only thing stopping the tunnel coming up — MEASURED on prod 2026-10-06:
|
||||
# [#] sysctl -q -w net.ipv4.ip_forward=1
|
||||
# sysctl: permission denied on key "net.ipv4.ip_forward"
|
||||
# [#] ip link delete dev wg0
|
||||
# and wg-quick then tore the whole tunnel down. The cause is not a missing
|
||||
# capability: with `hostNetwork: true` the runtime mounts /proc/sys READ-ONLY in the
|
||||
# container (confirmed in /proc/mounts: `proc /proc/sys proc ro,...`), precisely
|
||||
# because those sysctls are the HOST's globals and a container must not rewrite
|
||||
# them. CAP_NET_ADMIN does not and cannot lift a read-only mount.
|
||||
# It was never needed anyway: node_baseline writes net.ipv4.ip_forward=1 to
|
||||
# /etc/sysctl.d/99-k3s-cilium.conf, and reading it from this pod returns 1. The
|
||||
# init container now VERIFIES that (a read, which /proc/sys does allow) and fails
|
||||
# closed with a pointed message, which is what the write was pretending to achieve.
|
||||
# SNAT client traffic to the node's address. REQUIRED: without it a LAN host would
|
||||
# reply to 10.90.0.x, find no route, hand the packet to the router, and the router
|
||||
# would drop it — so every connection would hang rather than fail. Matching on
|
||||
@@ -246,8 +274,10 @@ spec:
|
||||
# `kubectl get pod -o wide` shows what this listener owns.
|
||||
- { name: wireguard, containerPort: 51820, hostPort: 51820, protocol: UDP }
|
||||
securityContext:
|
||||
# NET_ADMIN is the whole privilege budget: create the wg0 link, set the host sysctl, and
|
||||
# write the two netfilter rules above. Everything else is dropped, and the container is
|
||||
# NET_ADMIN is the whole privilege budget: create the wg0 link and write the netfilter
|
||||
# rules above. (It does NOT let the pod set a host sysctl — /proc/sys is read-only under
|
||||
# hostNetwork — which is why nothing here tries.) Everything else is dropped, and the
|
||||
# container is
|
||||
# NOT privileged — which also keeps it clear of restrict-host-namespaces' second rule.
|
||||
# SYS_MODULE is deliberately NOT granted: the `wireguard` module is loaded by the node
|
||||
# (infra/ansible/roles/node_baseline, node_baseline_kernel_modules), so this container
|
||||
@@ -261,6 +291,16 @@ spec:
|
||||
- NET_ADMIN
|
||||
# readOnlyRootFilesystem intentionally UNSET: the image's s6 init writes under /run and
|
||||
# /var during startup. Same deferral as crafty/headlamp.
|
||||
#
|
||||
# EXPECTED, HARMLESS STARTUP NOISE — do not "fix" it. Because capabilities are dropped
|
||||
# to NET_ADMIN only, the image's s6 init cannot chown /config (no CAP_CHOWN) and logs a
|
||||
# run of `chown: ... Operation not permitted` plus "Permissions could not be set. This
|
||||
# is probably because your volume mounts are remote or read-only" and a scary "we will
|
||||
# not provide support for it". It is wrong about the cause and it does not matter:
|
||||
# /config is a writable tmpfs emptyDir, everything here runs as root (uid 0) anyway, and
|
||||
# wg-quick proceeds normally — verified on prod 2026-10-06, where the tunnel's only
|
||||
# failure was the sysctl above, well past these warnings. Granting CAP_CHOWN to silence
|
||||
# log noise would widen the privilege set of an internet-facing pod for nothing.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
|
||||
@@ -60,10 +60,16 @@ data:
|
||||
#
|
||||
# COPY THIS TEMPLATE, uncomment, one block per device:
|
||||
#
|
||||
# # pixel-8 — Keith's phone, enrolled 2026-10-05
|
||||
# # <device-name> — <owner>, enrolled <YYYY-MM-DD>
|
||||
# [Peer]
|
||||
# PublicKey = <the public key new-peer.sh printed>
|
||||
# AllowedIPs = 10.90.0.2/32
|
||||
# AllowedIPs = 10.90.0.<octet>/32
|
||||
#
|
||||
# The placeholders above are deliberately NOT valid values. An earlier version of this template
|
||||
# used a real-looking `10.90.0.2/32`, and new-peer.sh's duplicate-address check — which grepped
|
||||
# the whole file — matched it, so `.2` could never be allocated and the script insisted it was
|
||||
# already taken. The check now ignores comments; keeping the example unmatchable too means a
|
||||
# future grep cannot be fooled either.
|
||||
#
|
||||
# Keep the `# <name> — <owner>, enrolled <date>` comment line. It is the only place a key is
|
||||
# tied to a human-recognisable device; `wg show` can only ever print the key itself, so without
|
||||
|
||||
@@ -11,7 +11,7 @@ stringData:
|
||||
#ENC[AES256_GCM,data:fUoPkfWvasisdTtwdhjeOVOlmdQkTl6Bp9YxYyji8g6k+ClATds3BnHJWOj2iplaGpT2XaNakq1+D4YyOi7QbFvNJui+e4qRpqcY8hWnazvZIHJYxjxfAfzW,iv:xtKd6oMZQumAQJAsNLHqJB+o0wdQJ+vL1kI1mc9jL7w=,tag:aImqLgbSmhsRI36/t5kpyg==,type:comment]
|
||||
#ENC[AES256_GCM,data:npDsCW6rQleolIpur5gFkX2jSRrIQzBlGaIPs2Schv0YU3q8AanG39z4QqT3ASDmskV5YuH29Bo0ujEAgBf52YIjQCp58PDDETofrVKOJzhHMa7bV+HCxUFE,iv:7E7thyFBleyqZpqJRGApKvIq3ubCFOkshF/mxim3YPc=,tag:joIrhMe2I6kUnx1ouOPdZw==,type:comment]
|
||||
#ENC[AES256_GCM,data:wHUFx09FgP/2+LTyw61EFbKA5UaqfjU7CeGk6Xc5av2y4r4AvQJRefDM5EGlahKecDFEFsf7fSQRFRHtUJqrQTtw+6tcj3ubSCPW4iJvhyJJv0xdb/0puRmcgj+2NfgF,iv:xgDOSDLqMQ1wcI9vrkKqfNMgIDFEusFnnO7Rzq/aOa4=,tag:m1m/fgjkBsG91c+3daqOpA==,type:comment]
|
||||
privatekey: ENC[AES256_GCM,data:irLENWa1Z3IJSQ==,iv:Qu2Yr6F5+M6P8cQRuhB5J7joxCcudUhwoEWDiL941Lo=,tag:ZNwcBZEkswtnkX0vGKfMAQ==,type:str]
|
||||
privatekey: ENC[AES256_GCM,data:ML8CPNm4JwhQHUTUUxZO/I+9X15BCIGqOb1Uw9EmX5RTAsxQ9N97YvkYlbY=,iv:5ZUsFdMMmeGsy4fNbk6qbJkzweMVWXZ5g6E7NciAGUg=,tag:pAHv3ieLjoEyFbqbWXsVgg==,type:str]
|
||||
sops:
|
||||
kms: []
|
||||
gcp_kms: []
|
||||
@@ -27,8 +27,8 @@ sops:
|
||||
SUUzN1FnMlI4bkkzbVNlMWhUVnJZODAKQ+Iy86CIv/Oj9E9j4E/0y5hDbcctRuqB
|
||||
OR825KRPRaTdF/G2hudE1R1JbM4wGbPLMzv078R4ZXHDPGvDttSEkA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2026-10-06T04:20:58Z"
|
||||
mac: ENC[AES256_GCM,data:GpP/zTX4FtJyrlaCS/R0/sAtFbkM0W8A5sqNxb5ZziSgLUXI+oUKYB09igN6vx5cjRNH7rSmXduS/actaT5jhP8kuF1n/iQfG56PsCJHL2vneefzcEOKXeGquAwDOY+iGmNsZ5R8ySrM/Fq7HYLMJasWosyTh3YlBs8GRYiHmU4=,iv:438+pDzHLGyh+OMQCMzGBEBrZUPNp5BbjrrMdiEiqJ8=,tag:JqLu6zZWKtcoB8mp4HSZig==,type:str]
|
||||
lastmodified: "2026-10-06T06:10:31Z"
|
||||
mac: ENC[AES256_GCM,data:6wAGAwWL7jI/NEeswBnUiWoRty+bb5DEsGEAPXajKNNYCwtm5yuXBrcOI/q4w6H8FG+nqVAECkdpAOI3S9mjDfc2zlWAzfCJJdDic92RKPXOC0tOH/M1BBSGJ1fVdJGAZZT1yAVxmtYy9WohJpawv3kmsVv+JnD+TIqmZ1CJHXo=,iv:srs4o0olSwmraQQgmdPxb+4h/t6fHHeFKJ2z914kGgY=,tag:jjoIKhU8Tu9i5SJkvkfcCg==,type:str]
|
||||
pgp: []
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
version: 3.9.4
|
||||
@@ -67,7 +67,9 @@ Work through these in order; the first three are by far the most likely.
|
||||
| Symptom in the log | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `GET /user returned no login — is GITEA_TOKEN valid?` and a CrashLoop | the credentials are still the committed **placeholders**, or the token was revoked | fill them in per `platform/pm-agent/pm-agent-credentials.sops.yaml.example` |
|
||||
| `ImagePullBackOff`, digest `sha256:0000…` | the image has never been pushed; the committed digest is the deliberate placeholder | `infra/agent/build-image.sh`, operator pushes, then one PR updates the digest in **both** `deployment.yaml` and `configmap.yaml` (`PM_WORKER_IMAGE`) |
|
||||
| `ImagePullBackOff`, digest `sha256:0000…` | the image has never been pushed; the committed digest is the deliberate placeholder | `infra/agent/build-image.sh`, operator pushes, then one PR updates the digest in **both** `deployment.yaml` and `configmap.yaml` (`PM_WORKER_IMAGE`) — but read the row below first |
|
||||
| `ImagePullBackOff` with `x509: certificate is not valid for any names` | **diagnosed.** containerd pulls as a node process, resolves through the node's public upstreams, and gets the CNAME to the WAN IP — which this router does not hairpin, so it answers :443 with its own `CN=%D` placeholder certificate (no SANs at all). Nothing to do with the digest, the Gateway or the wildcard cert | the `/etc/hosts` pin for `gitea.{{ k3s_base_domain }}` → `192.168.2.20` in `infra/ansible/inventory/group_vars/k3s_prod/vars.yml` fixes it — the same pin the Keycloak issuer already has. Apply it with the project's own tooling, scoped to just that task: `cd infra/ansible && ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags hosts`. Full evidence: `decisions/2026-10-06-pm-agent-node-resolves-gitea-to-wan.md` |
|
||||
| `ImagePullBackOff` with `401 Unauthorized` once TLS works | the pull is unauthenticated. An anonymous client gets nothing from this Gitea's registry, and no `imagePullSecret` exists anywhere in the tree | add one (a `dockerconfigjson`, SOPS-encrypted like `pm-agent-credentials`, referenced from the Deployment **and** the committed worker Job template) or allow anonymous reads of the package in Gitea — a posture decision, so yours |
|
||||
| `PM_WORKER_IMAGE must be digest-pinned` | someone put a tag in the ConfigMap | use the digest from `podman push --digestfile` |
|
||||
| `scope: ONLY issues labelled 'agent'` | `PM_REQUIRE_LABEL` is set; unlabelled issues are out of scope by configuration | label the issue, or clear the knob |
|
||||
| `cycle: 2 worker(s) active` and nothing new | at `PM_MAX_ACTIVE_WORKERS` | wait, or raise it (RAM is the binding constraint: each worker's limit is 2Gi) |
|
||||
|
||||
@@ -134,6 +134,36 @@ specific failure in its log — so you never get a server that silently cannot c
|
||||
| `It is EMPTY` | the value was blanked |
|
||||
| `still SOPS CIPHERTEXT (starts with ENC[)` | the Secret reached the cluster undecrypted — this environment's overlay is missing its `vpn-ingress` decryption patch. **This is the dangerous one:** the Secret applies cleanly and every Flux condition stays green |
|
||||
| `wg pubkey rejected it` with none of the above | a mangled value, or a *public* key pasted by mistake |
|
||||
| `net.ipv4.ip_forward is 0 on this node` | the node_baseline role has not run here (step 1). The pod **cannot** set this itself — under `hostNetwork` the runtime mounts `/proc/sys` read-only, because it is the host's global setting |
|
||||
|
||||
### What a healthy start looks like
|
||||
|
||||
Measured on prod 2026-10-06. The init container logs one line, naming the shape and never the key:
|
||||
|
||||
```
|
||||
wg0.conf assembled: 0 peer(s), server 10.90.0.1/24, udp/51820.
|
||||
```
|
||||
|
||||
then the `wireguard` container brings the tunnel up:
|
||||
|
||||
```
|
||||
**** Found WG conf /config/wg_confs/wg0.conf, adding to list ****
|
||||
**** Activating tunnel /config/wg_confs/wg0.conf ****
|
||||
[#] ip link add dev wg0 type wireguard
|
||||
[#] wg addconf wg0 /dev/fd/63
|
||||
[#] ip -4 address add 10.90.0.1/24 dev wg0
|
||||
[#] ip link set mtu 1420 up dev wg0
|
||||
```
|
||||
|
||||
**Ignore the `chown: ... Operation not permitted` run before that**, and the image's own
|
||||
"Permissions could not be set ... we will not provide support for it". Capabilities are dropped to
|
||||
`NET_ADMIN` only, so its s6 init has no `CAP_CHOWN`; `/config` is a writable tmpfs, everything runs
|
||||
as root, and `wg-quick` proceeds. It is wrong about the cause and it does not matter.
|
||||
|
||||
**A pod stuck `0/1 Running` with `Startup probe failed: Unable to access interface: No such device`
|
||||
means `wg-quick` tore the tunnel down** — read the `wireguard` container log for the `[#]` line it
|
||||
died on. That is the shape of a real failure; the probe is doing its job of refusing to call a
|
||||
server without a `wg0` "ready".
|
||||
|
||||
## Step 3 — forward udp/51820 on the router (once)
|
||||
|
||||
|
||||
Reference in new issue
Block a user