Files
SRCmail/deploy/k8s
Bernd RodlerandClaude Opus 5 3afa7ce012 feat(smime): CaProvider seam + server-side enrolment route (A-02, C-08 half)
Corrects an architecture call I got wrong earlier in the session. I had said
CaProvider would live in the plugin. It cannot, for two independent reasons:

  1. EJBCA's REST API authenticates with a CLIENT CERTIFICATE. A browser
     cannot present one from fetch, and must not hold one anyway - the RA
     credential is the authority to mint certificates, so putting it
     anywhere script-reachable turns any XSS into a certificate factory.
  2. Only the server can answer "does this person actually own this
     address?" A browser asserting its own identity to a CA is not
     authentication.

So: the plugin generates the keypair and CSR (private key never leaves the
device), and this layer decides which addresses the certificate may assert.
api.http.post is the bridge, and the fact that it forwards the user's JMAP
auth header is what makes the identity check possible at all.

The design decision worth calling out: the CSR is NOT trusted for identity,
and the route does not parse it to police what it asks for. It doesn't need
to. The route supplies the subject and the rfc822Name SAN itself from
addresses it verified independently; the CSR contributes only a public key
and proof of possession. A CSR hand-crafted to claim the CEO's address does
not have to be detected and rejected - the extension it asks for simply
never reaches the certificate.

That property depends entirely on EJBCA ignoring CSR-supplied subjects and
extensions, which is three checkboxes in the certificate profile. Added to
the runbook as the most important line in it, with a concrete verification
using a hostile CSR - because with those overrides ON, the enrolment route
still looks correct in review while issuing certificates for any address.

Identity comes from Stalwart via Identity/get, not from the auth cookie's
username. The cookie is encrypted and server-minted so it cannot be forged,
but it is still the wrong authority: the right answer to "may this person
have a signing certificate for this address" is held by the mail server
that already decides "may this person send from this address". Anything else
invents a second, weaker answer to a settled question.

It also handles two cases the cookie cannot:

  - an alias the account legitimately sends as, which belongs ON the
    certificate and which the cookie does not know about
  - an administrative principal with no mailbox, which must get NOTHING.
    Not hypothetical: admin@sandbox.vnc.de authenticates successfully and
    has no mail session, so trusting the cookie would have issued it a
    certificate for an address it cannot send from.

Wildcard identities (*@domain) are filtered out. Stalwart can legitimately
report one for an account allowed to send as anything in a domain, but it is
a capability, not an address - and a rfc822Name SAN of *@vnc.de is either
rejected by clients or, worse, honoured.

Other deliberate choices:

- Pins EJBCA's own chain for the mTLS connection instead of the public root
  store. EJBCA serves a self-signed cert on that listener by design, and
  rejectUnauthorized:false would be worse than either option - it would let
  anything on the cluster network impersonate the CA and harvest CSRs.
- CA error bodies are logged server-side and replaced with generic messages.
  An enrolment endpoint should not double as a way to probe CA config.
- DN component values are RFC 4514 escaped. The CN comes from a display
  name; an unescaped comma or plus would inject additional RDNs.
- getCaProvider() returns null rather than throwing when unconfigured, so
  the route 503s and nothing else is affected. Enrolment is opt-in; a
  missing CA secret must not stop anyone reading their mail.
- revoke() is documented as needing to work when enrolment is broken. It is
  the incident-response path, and a design that can only revoke through the
  same path that issues is one outage from being unable to answer a key
  compromise.

Typechecks clean. Not yet exercised against a live CA - the browser half of
C-08 (keypair + CSR generation in the plugin) and a real EJBCA to enrol
against are both still outstanding, so nothing here has issued a
certificate yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 13:02:21 +02:00
..

VNCmail+ — Admin Deployment Guide (microk8s)

Deploy VNCmail+ (VNC's Bulwark fork) as a container at vncmail.sandbox.vnc.de, alongside the existing bulwark.sandbox.vnc.de. Plain kubectl apply — no GitOps needed.

Why a container (not Vercel): Bulwark is stateful — it writes settings/admin/ telemetry to /app/data, which needs persistent volumes.


1. What you are deploying

# Object File Purpose
1 Namespace vncmail namespace.yaml Isolates the app
2 4× PersistentVolumeClaim pvc.yaml /app/data/{settings,admin,admin-state,telemetry}
3 Secret vncmail-env secret.yaml (you create it) App config (JMAP URL, session secret, branding)
4 Secret ghcr-pull (you create it — command below) Pull the private image from GHCR
5 Deployment vncmail-plus deployment.yaml The app pod
6 Service vncmail-plus service.yaml ClusterIP :80 → pod :3000
7 Ingress vncmail-plus ingress.yaml TLS host vncmail.sandbox.vnc.de

Image: ghcr.io/brvncde-dotcom/vncmail-plus-dev:latest (built automatically by CI from the dev branch). For anything beyond the sandbox, pin a digest — see §5.


2. Pre-flight — confirm 3 cluster values (2 min)

The manifests use microk8s defaults. Copy the exact values the existing Bulwark uses so VNCmail+ matches your cluster:

# Find bulwark's ingress and read off its class + cert-manager annotations:
kubectl get ingress -A | grep -i bulwark
kubectl get ingress <bulwark-ingress-name> -n <bulwark-ns> -o yaml

# List available storage classes and ingress classes:
kubectl get sc
kubectl get ingressclass
kubectl get clusterissuer      # cert-manager issuers (if used)

Then edit if they differ from the defaults below:

Value Default in manifests File to edit
StorageClass microk8s-hostpath pvc.yaml (all 4)
IngressClass public ingress.yaml
cert-manager issuer letsencrypt-prod ingress.yaml

3. Deploy (copy-paste, in order)

cd deploy/k8s

# a) Namespace
kubectl apply -f namespace.yaml

# b) Image-pull secret — the GHCR package is private.
#    Use a GitHub PAT (classic) with the read:packages scope.
kubectl create secret docker-registry ghcr-pull \
  --namespace vncmail \
  --docker-server=ghcr.io \
  --docker-username=brvncde-dotcom \
  --docker-password='<GITHUB_PAT_read:packages>' \
  --docker-email=br@vnc.biz

# c) App config secret — copy the template, set a real SESSION_SECRET, apply.
cp secret.example.yaml secret.yaml
#    edit secret.yaml:  SESSION_SECRET: "$(openssl rand -base64 32)"
kubectl apply -f secret.yaml

# d) Everything else (PVCs, Deployment, Service, Ingress)
kubectl apply -k .

Alternative to (b): make the GHCR package public (GitHub → Packages → vncmail-plus-dev → Package settings → Change visibility), then delete the imagePullSecrets: block from deployment.yaml.


4. Verify

kubectl -n vncmail rollout status deploy/vncmail-plus     # -> successfully rolled out
kubectl -n vncmail get pods,pvc,ingress

# DNS: point vncmail.sandbox.vnc.de at the same ingress IP as bulwark.sandbox.vnc.de.
# cert-manager issues TLS once DNS resolves. Then:
curl -sI https://vncmail.sandbox.vnc.de/api/health         # -> HTTP/2 200

Open https://vncmail.sandbox.vnc.de and log in with a full email address (e.g. bernd.rodler@sandbox.vnc.de) — Stalwart authenticates the full email, not a bare username.


5. Update to a new build

# CI rebuilds ghcr.io/brvncde-dotcom/vncmail-plus-dev on every push to `dev`.
kubectl -n vncmail rollout restart deploy/vncmail-plus     # pulls :latest (imagePullPolicy: Always)

# Production: pin a digest instead of :latest so rollouts are deterministic.
kubectl -n vncmail set image deploy/vncmail-plus \
  vncmail-plus=ghcr.io/brvncde-dotcom/vncmail-plus-dev@sha256:<digest>

Rollback: kubectl -n vncmail rollout undo deploy/vncmail-plus


6. Troubleshooting

Symptom Cause / fix
Pod ImagePullBackOff ghcr-pull secret missing/expired, or package still private. Recreate the secret (§3b) or make the package public.
Pod CrashLoopBackOff, logs show EACCES/permission on /app/data Volume not writable by uid 1001. securityContext.fsGroup: 1001 is set in deployment.yaml — keep it; some storage drivers also need it on the PVC.
PVC stuck Pending Wrong storageClassName in pvc.yaml. Set it to one from kubectl get sc.
Ingress has no address / no cert Wrong ingressClassName or cert issuer. Match bulwark's (§2). Check kubectl -n vncmail describe ingress vncmail-plus.
Login shows "Ein Fehler ist aufgetreten" Use the full email (user@sandbox.vnc.de), not a bare username.
Can't reach Stalwart Check JMAP_SERVER_URL in the secret = https://stalwart.sandbox.vnc.de.