Working directory for a ContainerSSH + Kubernetes dev box. This file gives agents and maintainers the context needed to work here safely.
A personal dev sandbox for running ContainerSSH with the Kubernetes backend. The deployment target is ContainerSSH's persistent execution mode: the first SSH connection creates a stable per-user pod, later connections exec into that same pod, and disconnecting does not delete it. It consists of:
charts/containerssh/— a Helm chart (v2,containerssh-0.3.1,appVersion: 0.6) with an optional official Kata Containers dependency that deploys ContainerSSH itself plus optional extras.config-server/— a small Go server implementing the ContainerSSH config webhook protocol (built ongo.containerssh.io/containersshconfig/webhook), serving pod templates selected by SSH username.auth-server/— a small Go server implementing the ContainerSSH auth webhook protocol (auth/webhook) that answers "given an SSH public key, which authentik user owns it?" with one exactattributes.sshPublicKeyquery (spec inauth-server/spec.md).
Reference docs (both official, version 0.6):
- Installation in Kubernetes: https://containerssh.io/v0.6/getting-started/installation/ (Kubernetes tab)
- Kubernetes backend reference: https://containerssh.io/v0.6/reference/kubernetes/
Source of truth for ContainerSSH internals: /Users/matthiasmatt/Documents/Work/oss/ContainerSSH
(this repo is checked out next to dev.box and is frequently used to verify behavior).
Repo:
git@github.com:TU-Wien-dataLAB/dev.box.git. See Deployment status for the live release and verified behavior.
dev.box/
├── AGENTS.md ← this file
├── charts/containerssh/ ← the Helm chart
│ ├── Chart.yaml (name containerssh, v0.3.1, appVersion 0.6)
│ ├── values.yaml (everything is configurable from here)
│ ├── README.md
│ └── templates/
│ ├── _helpers.tpl
│ ├── configmap.yaml (renders ContainerSSH config.yaml from values)
│ ├── deployment.yaml (the ssh server pod)
│ ├── ingress-tcp.yaml (Traefik IngressRouteTCP for SSH — raw TCP, no TLS/cert-manager)
│ ├── service.yaml
│ ├── serviceaccount.yaml
│ ├── rbac.yaml (Role/RoleBinding for user pods in the user-pod namespace)
│ ├── networkpolicy.yaml (user pods: no ingress, egress internet-only)
│ ├── secret-hostkey.yaml
│ ├── configserver.yaml (bundled config server: ConfigMap + Deployment + Service)
│ ├── authserver.yaml (bundled auth server: Secret + Deployment + Service; auto-wires auth.*.webhook.url)
│ ├── NOTES.txt
│ └── tests/test-connection.yaml
├── config-server/ ← the config webhook server source
│ ├── main.go
│ ├── Dockerfile
│ ├── go.mod (go 1.25.3, requires go.containerssh.io/containerssh v0.6.0)
│ └── README.md
├── auth-server/ ← the authentik-backed auth webhook server source
│ ├── main.go (env/config, wiring, service lifecycle)
│ ├── auth_handler.go (OnPassword/OnPubKey/OnAuthorization; only /pubkey decides)
│ ├── authentik.go (authentik users API client: exact key lookup)
│ ├── auth_server_test.go (tests against an in-memory authentik mock)
│ ├── Dockerfile
│ ├── go.mod (go 1.25.3, requires go.containerssh.io/containerssh v0.6.0 + x/crypto)
│ └── README.md
└── tests/
├── chart-auth-rendering.sh (Helm auth fail-fast/render matrix)
├── cluster-deployment-spec.md (staged real-cluster acceptance plan)
└── spec.md (proposed image/wire contract framework)
ssh ubuntu@dev.box.example.com
│ 1. auth: SSH key presented
▼
ContainerSSH (Deployment, port 2222)
│ 1b. POST /pubkey (username, key) → auth-server
│ (exact key string → GET by attributes.sshPublicKey)
▼
auth-server (bundled, auto-wired auth.publicKey.webhook.url) ⇄ authentik API
│ success → authenticated as key owner
▼ 2. POST /config (username, ip, connectionId)
config-server (bundled, auto-wired configserver.url)
│ 3. reply: ONE pod config, merged over base config
│ (persistent: deterministic per-user metadata.name + dev.box/owner
│ label injected, box cap up to configServer.maxPodsPerUser)
▼
persistent user pod in namespace `containerssh-sessions`
(reused across connections; networkpolicy = no ingress, egress to internet only)
- Per SSH username → a pod template with that name:
ssh ubuntu@…→ubuntu.yaml. - Template lookup:
<username>.yaml→<username>.json→ error (connection denied). No catch-all or base-pod fallback;defaultis an ordinary template name used only byssh default@…. Empty/unsafe usernames are rejected rather than rewritten. - The config-server response is merged over the chart's base config (see merge rule below).
- The lifecycle is ONE stable, deterministic pod per (authenticated user, template): the config
server injects
kubernetes.pod.metadata.name=box-+ first 10 hex of SHA-256 overauthenticatedUsername+ resolved template, labels itdev.box/owner, and setscreateMissingPods: truein the per-request response. With basemode: persistent(normally also renderingcreateMissingPods: true), ContainerSSH creates that pod if absent, execs each SSH channel in it, and deliberately leaves it running after disconnect. The per-owner box cap (configServer.maxPodsPerUser, default 3) is enforced server-side in the config webhook; reconnects always pass, and listing failures deny (fail closed). Empty authenticated identities are denied. - SSH key auth is the auth-server's job: it queries authentik for exactly one owner of the
public-key string supplied by ContainerSSH via
attributes.sshPublicKey. Nowhere in ContainerSSH, this chart, or the auth server is a password verified against authentik — password auth is off by default (test allowlist only).
| Value | Default | Meaning |
|---|---|---|
image.repository/tag |
containerssh/containerssh, v0.6 |
SSH server image |
ssh.port / service.* |
2222 / ClusterIP |
SSH listener + exposure (NodePort/LB available) |
ingress.enabled / ingress.tcp.* |
false / ssh |
Traefik IngressRouteTCP (raw TCP) fronts the SSH port; no cert-manager/TLS — SSH is not HTTP |
ssh.hostKey.existingSecret / .privateKey |
"" |
stable host key; else ephemeral key fallback |
auth.*.webhook.url |
"" |
external password/publicKey/authz webhook URLs (v0.6 YAML keys: password, publicKey, authz — NOT pubkey); chart rendering requires password or publicKey unless auto-wired to the bundled auth-server |
auth.*.webhook.timeout |
30s |
per-request timeout; overall auth timeout defaults to 60s |
authServer.enabled |
false |
deploy the bundled authentik-backed auth server + auto-wire auth.publicKey/authz.webhook.url |
authServer.authentik.url / .token |
"" |
authentik base URL + read-only service token (or tokenSecret existing Secret) — required when enabled |
authServer.authentik.keyAttribute |
"" |
user attribute the presented key is looked up by (default sshPublicKey) |
authServer.requireGroup |
"" |
optional authentik group required after authentication (authz gate) |
kata.enabled |
true |
official Kata 4.2.0 subchart; installs QEMU runtime-rs and automatically selects kata-qemu-runtime-rs for backend pods only. Disable to omit installer and automatic RuntimeClass; explicit kubernetes.pod.spec.runtimeClassName overrides still work |
kubernetes.sessionNamespace |
containerssh-sessions |
where user pods run (chart force-manages) |
kubernetes.mode |
persistent |
chart default; connection/session also supported. Persistent normally renders createMissingPods: true, never force-renders generateName, and requires a bundled/external config server. With mixed-mode chart templates, base createMissingPods is omitted and injected only into persistent responses |
kubernetes.pod |
security hard defaults | base pod config inherited by matching templates |
kubernetes.podTemplates |
[] |
named pod templates (name = SSH username); needs configServer.enabled |
configServer.enabled |
false |
deploy the bundled config server + auto-wire configserver.url |
configServer.maxPodsPerUser |
3 |
per-owner live-box cap in persistent mode (0 = disabled); reconnects always pass; list-failure denies |
configserver.* |
url "" |
client-side config-server connection (timeout/TLS/mTLS) |
networkPolicy.enabled |
true |
backend user pods: deny ingress, egress internet-only (+ kube-dns) |
rbac.enabled |
true |
Role/RoleBinding for pod management in session namespace; the config server's own list-pods Role/RoleBinding render only when configServer.enabled |
log.level |
5 |
syslog-numbered (7 debug … 2 crit) |
- Log levels are syslog-numbered, higher = more verbose:
7debug,6info,5notice (default),4warning,3error,2crit. Do not write "0=trace…5=crit" anywhere. - Persistent mode is implemented and is the chart default. ContainerSSH v0.6 persistent mode
finds a pod by exact
kubernetes.pod.metadata.name; agenerateNameis not enough; on-demand creation needskubernetes.pod.createMissingPods: true. The chart normally renders basemode: persistent+createMissingPods: true; the config server also injectscreateMissingPods: true, a deterministic DNS-1123 pod name (box-+ first 10 hex of SHA-256 overauthenticatedUsername+ resolved template), and thedev.box/ownerlabel per persistent request. If a chart-managed template explicitly selectsconnection/session, the chart omits basecreateMissingPods: mergo skips false source values, so a base true would survive the mode override and fail upstream validation. Non-persistent responses skip persistent-only injection. The per-owner cap (configServer.maxPodsPerUser, default 3) is enforced server-side in the config webhook: reconnect-to-existing always passes, list-failure and empty authenticated identity deny (fail closed). Persistent mode without any config server fails chart rendering (story 15). Cluster-level disconnect/reconnect coverage still needs the staged acceptance plan. - Persistent means pod lifecycle, not durable storage. The pod survives SSH disconnects, but its writable layer does not survive pod deletion, eviction, node loss, or recreation. Any data that must survive those events needs a PVC or another durable store. Persistent user pods require an explicit lifecycle/cleanup policy; ContainerSSH intentionally skips pod removal in this mode. Pod-template changes also do not mutate an already-created persistent pod; recreate or migrate the pod explicitly when its image/spec must change.
- The chart force-manages
kubernetes.pod.metadata.namespaceinconfigmap.yaml(=sessionNamespace); it force-rendersmetadata.generateNameonly for non-persistent modes and stops doing so in persistent mode to keep the rendered config honest. In persistent mode the stablemetadata.nameis injected at runtime through the config-merge path (empty source fields are skipped), so the strict-YAML duplicate-key pitfall does not apply. Don't setmetadata.namespace/generateNamein the basekubernetes.pod.metadata— duplicate keys break config loading (strict YAML). - The config server / override merge: ContainerSSH merges the webhook response over the base
config via
structutils.Merge=mergo.Merge(dst, src, mergo.WithOverride). Empty/nil source fields are skipped, so partial per-template overrides are safe (proven by a mergo test); a template that only sets labels keeps the base pod's containers/spec. - Backend images must contain
containerssh-agent: the base pod runs/usr/bin/containerssh-agent wait-signal .... Replacing only its image with plainubuntu:22.04inherits that command and fails withstat /usr/bin/containerssh-agent: no such file or directory. Keep templates metadata-only unless the custom image implements the guest image contract. - Bundled webhook URLs need port
:8080, because their Services expose 8080 rather than port 80. The main SSH Service must additionally selectapp.kubernetes.io/component: server; the shared name/instance selector alone also selects the auth/config pods and randomly misroutes SSH. - Config server is fail-closed: with
configserver.urlset, ContainerSSH denies connections when the config POST fails (retries every 10 s, non-200 = "Cannot authenticate at this time"). Bundled server must be Ready before SSH works. - At least one authentication method is required by the chart: authz is post-auth and does not
count. ContainerSSH v0.6 can start with an omitted
authblock via legacy defaults but has no usable webhook authenticator; the chart therefore fails rendering unlessauthServer.enabledor a password/public-key webhook URL is set, and never rendersmethod: webhookwith an empty URL. auth.publicKey, neverauth.pubkey, in rendered config.yaml: ContainerSSH v0.6'sAuthConfigunmarshalspubkeyas a deprecated*boolflag in BOTH its legacy and new YAML structs — a map underpubkeyaborts config loading (cannot unmarshal !!map into bool, caught by the real-binary--dump-configcheck on 2026-09-15; this is what crash-looped the first cluster release). The public-key webhook section must render asauth.publicKey; the chart value key is alsoauth.publicKey.authzandpasswordare plain struct keys.- Auth server is also fail-closed: with an
auth.*.webhook.urlset, a non-200/error from the auth request denies the connection (ContainerSSH retries until the method'sauthTimeout). authentik must be reachable from the auth-server pod. Per-request webhook timeout is 30s. - Public-key lookup is strict and constant-cost: the auth server performs one exact authentik
filter for
attributes.sshPublicKeyas a single-element JSON list containing the key string from ContainerSSH. Exactly one user authenticates; zero or multiple users deny cleanly. Values are not parsed or normalized, and there is no directory scan. - Public-key only: the bundled chart configures no password webhook; the protocol-required
OnPasswordimplementation always denies. The SSH username intentionally selects the pod template and is not compared with the authentik owner; authenticated metadata records the owner. - The bundled auth server is read-only against authentik. Its service token needs only user-view permission.
- Config file loading applies struct defaults (
structutils.Defaultsininternal/config/loader_reader.go) — the chart only renders what it overrides. - Generated config changes must restart ContainerSSH. The main Deployment carries a
checksum/configpod-template annotation derived from the chart-managed ConfigMap. Without it, a Helm upgrade can leave the old process running with stale mode/settings (observed during the first persistent rollout). An externalexistingConfigMapcannot be checksummed by Helm; restart the Deployment manually after changing that object. - Named templates are required by the config server — unmatched SSH usernames return an
error in every mode (issue #6). There is no
defaultor base-config fallback.defaultis an ordinary template name. ContainerSSH retries non-200 config responses every ten seconds until timeout, then silently closes SSH. With both bundled servers enabled, the chart also passes an exact-nameAUTH_SERVER_ALLOWED_TEMPLATESJSON allowlist to auth-server; its authz gate returns HTTP 200 +success: falsefor unknown names, yielding immediate SSHPermission denied. An empty list denies all, unset disables the gate, and malformed policy fails auth-server startup. The optional authentik group gate still applies. External authz webhooks need their own early template gate; config-server rejection remains the safety net. - User pods vs. ContainerSSH pod: the chart ships security defaults for backend user pods
(
runAsNonRoot,runAsUser: 1000,allowPrivilegeEscalation: false, cpu/mem limits). The NetworkPolicy deliberately has no pod-securityenforcelabel — a restricted PSS profile would block default pods (they lackseccompProfile: RuntimeDefault). - containerssh image quirks:
containerssh/containerssh:v0.6is Alpine/busybox-based but its busybox has noopenssland nossh-keygen(verified) — host keys can't be generated inside that image; it auto-generates an ephemeral key at startup if none is configured (writes config → fails gracefully read-only). - No public config-server image —
config-server/must be built and pushed beforeconfigServer.enabled=truewill work. In persistent mode the bundled server also needs its own ServiceAccount withliston pods in the session namespace (created by the chart), or every connection is denied fail-closed when the cap listing fails.
# chart
helm dependency build charts/containerssh # pinned optional Kata dependency
helm lint charts/containerssh --set kubernetes.mode=connection \
--set auth.publicKey.webhook.url=https://auth.example.test
helm template smoke charts/containerssh -n containerssh \
--set kubernetes.mode=connection \
--set auth.publicKey.webhook.url=https://auth.example.test # render
tests/chart-auth-rendering.sh # auth render matrix
tests/chart-template-authorization.sh # template allowlist wiring
tests/chart-service-selector.sh # SSH Service isolation
tests/chart-kata-rendering.sh # Kata installer toggle + backend RuntimeClass
tests/chart-persistent-rendering.sh # persistent-mode render matrix (mode, cap, RBAC)
tests/chart-config-binary-validation.sh # rendered configs through real v0.6 binary (Docker)
helm package charts/containerssh -d /tmp/sshtest
# config server
(cd config-server && go build ./... && go vet ./... && go test ./...)
docker build -t config-server:dev config-server/ # image
# auth server
(cd auth-server && go build ./... && go vet ./... && go test ./...)
docker build -t auth-server:dev auth-server/ # image
# Live regression (local SSH port-forward + enrolled key + known ubuntu template):
python3 tests/ssh-template-rejection.py --key ~/.ssh/slurm_tu_wien # deny within 5s
tests/chart-config-binary-validation.sh feeds rendered connection/persistent bases plus
representative persistent and non-persistent webhook merges to the real
containerssh/containerssh:v0.6 --dump-config; every case must exit 0 without
CORE_CONFIG_ERROR. Set CONTAINERSSH_IMAGE to validate another image.
- Target cluster context:
container-ssh(created; control plane reachable). Current default context iscontainer-ssh— still pass--kube-context container-sshexplicitly (helm) /--context container-ssh(kubectl). - Helm release
containersshrevision 19 is deployed in namespacecontainersshwith chart0.3.1in persistent mode (upgraded 2026-09-30); ContainerSSH, auth-server, and config-server are all Ready. Ingress remains disabled (ClusterIP + local port-forward). Revision 19 added the auth-server template authorization gate (AUTH_SERVER_ALLOWED_TEMPLATES=["ubuntu"]) and changed onlyauthServer.image.tagin user values. Secret references, templates, and config-server tag were preserved. The main SSH server restarted because chart metadata changed its config checksum. - Kata 4.2.0 is installed by the chart's
kataDaemonSet, Ready on both workers (not the tainted control-plane nodes).kata-qemu-runtime-rsexists, and both workers advertisekatacontainers.io/kata-runtime=true. The generated base config selects it for new user pods. When first adding this dependency to an existing release, upstream's upgrade guard requires seedingcontainerssh-kata-deploy-statewithmultiInstallSuffix: ""anddeploymentMode: daemonset, plus Helm ownership metadata, after confirming there is no prior Kata installation; see the chart README. - An end-to-end Kata creation/reconnect check passed on 2026-09-30 using SSH username
kata-smoke(resolves to the default template). Both connections ran under guest kernel6.18.35and reused Running podbox-3950ba8a52with the same UID andruntimeClassName: kata-qemu-runtime-rs. Only this newly created test box was explicitly deleted afterwards, with a UID precondition. The original non-Kataubuntubox (box-8bbfc143fa) was explicitly deleted at the owner's request on 2026-09-30, with a UID precondition. The session namespace was empty afterwards. The owner's nextssh ubuntu@…recreatedbox-8bbfc143faon 2026-09-30 usingkata-qemu-runtime-rs(UIDb2abba32-495f-4212-a16c-b2490144789c). The new box is Running/Ready on workercontainer-ssh-worker-k4v2m-mfq8d; exec verified Kata guest kernel6.18.35. - The config server is pinned to immutable tag
sha-270e2fd(issue #6), image digestsha256:04597f9f6a11c52662ef874d69eb990435ac790de5952e39be005bef28929b19; auth-server is pinned tosha-72cadaa, image digestsha256:6f1a71ff5c46cc3d88ae967baf9641b057127bddb664118da0fb1644f4fc6baa. The authentik read token and stable host key are mounted from existing Secrets (containerssh-authentik-token,containerssh-host-key). - Issue #6 was verified live on 2026-09-30: the config webhook returned HTTP 200 for
ubuntuand HTTP 500 for an unmatched username, absentdefaulttemplate, and empty username. An enrolled-key SSH login asissue6-no-templatewas rejected (exit 255) after config retries, withCONFIG_SERVER_NO_TEMPLATESlogged. TwoubuntuSSH reconnects succeeded under Kata kernel6.18.35and retained podbox-8bbfc143faUIDb2abba32-495f-4212-a16c-b2490144789c. No user pods were created or deleted. - The revision 18 acceptance check missed prompt rejection: config HTTP 500 errors were retried
until timeout and SSH only reported connection closure. Revision 19 fixes this through an early
authz denial. Live checks returned HTTP 200 +
success: falsefor unknown, absentdefault, and empty template names. The SSH regression (tests/ssh-template-rejection.py, with a workingubuntupositive control) rejectedunknown,issue6-fast-test, anddefaultin 0.21–0.23 s withPermission denied; the unknown test never reached the config webhook. The same existing Kata pod UID was preserved, and no user pods were created/deleted. UseBatchMode=yesandIdentitiesOnly=yesfor a noninteractive client error; plain SSH may offer password prompts after a rejected key even though the server never accepts password authentication. - A real persistent-mode reconnect check passed on 2026-09-18: two sequential
ssh -i ~/.ssh/slurm_tu_wien -o IdentitiesOnly=yes -p 2222 ubuntu@localhost 'printf READY'connections crossed ContainerSSH → auth-server → production authentik → config-server → guest pod and reused the same Running pod (box-8bbfc143fa, owner labelmatthias.matt). The pod remained after both disconnects, as required. - The live
ubuntutemplate is metadata-only and therefore retainscontainerssh/containerssh-guest-image. Authentication uses one exactattributes.sshPublicKeylist query with username enforcement disabled. The enrolled value is a single-element list containing the canonical comment-free key. Live measurements after the exact-string rollout: enrolled key lookup 199 ms; unknown key denial 75 ms; no parsing, fingerprint cache, alternate query, or directory scan. The bundled chart configurespublicKeyandauthz(group gate off by default); password authentication is absent, and the auth server no longer serves/config.
Remaining before the staged persistent-mode validation:
- ✅ (implemented in this change) — persistent-mode contract: the chart renders
mode: persistent+createMissingPods: trueby default, stops force-renderinggenerateName, and fails rendering when persistent mode has no config server. In mixed-mode chart templates, basecreateMissingPodsis omitted and injected only for persistent responses. The config server injects a stable, collision-resistant DNS-1123metadata.namefrom the canonical authenticated user (authenticatedUsername) + resolved template, labels boxesdev.box/owner, and enforces the per-owner cap (configServer.maxPodsPerUser, default 3; reconnect-exempt, fail-closed on listing errors and empty identities). Deletion/retention is explicit: no auto-deletion anywhere, documented in the chart README/NOTES. The real-cluster disconnect/reconnect stage passed on 2026-09-18. - ✅ Both bundled images are pinned to immutable tags: config-server
sha-270e2fd, auth-serversha-72cadaa. - Run the remaining cap-boundary, negative, policy, and fail-closed stages in
tests/cluster-deployment-spec.md. - Optional, later:
ingress.enabled=true+ the one-time Traefik TCP entrypoint/port setup (see values.yamlingress, NOTES.txt).
Current auth/config smoke-install command (no ingress; the chart's default is now
persistent, so the bundled configServer must be enabled — this is the real target shape):
helm install containerssh charts/containerssh \
--kube-context container-ssh --namespace containerssh --create-namespace \
--set authServer.enabled=true \
--set authServer.authentik.url=https://authentik.example.com \
--set authServer.authentik.tokenSecret=containerssh-authentik-token \
--set configServer.enabled=truethen kubectl --context container-ssh port-forward -n containerssh svc/containerssh 2222:2222
and ssh -p 2222 ubuntu@localhost.