ab79288be8c88a6e560cab8b6b4ccadb7848ef08
7
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
177b2aca57 |
feat(ci): pivot to ArgoCD GitOps, fix Traefik ingress after real-cluster check
Direct SSH access to the actual clusters (node1-3 "prod" HA, dev-k8s-1-3
"dev") revealed two things that made the previous design wrong:
1. Neither cluster has vncmail/vnc-ca namespaces or a bulwark ingress at
all - the "live sandbox" referenced in this repo's docs/manifests was
never actually applied anywhere. Both ingress.yaml's ingressClassName
(public) and cert-manager issuer (letsencrypt-prod) were also wrong:
both clusters run Traefik (class is literally named `traefik`), and
only dev-k8s has any ClusterIssuer at all (`letsencrypt-staging`).
node1-3 has zero ClusterIssuers configured.
2. dev-k8s already has ArgoCD installed, idle, zero Applications - more
idiomatic to use it than have GitLab Runner execute kubectl directly.
Pivots .gitlab-ci.yml: build+push image, then commit the tag into a small
per-overlay Component (overlays/{dev,prod}/image-tag/) that ArgoCD's
Application watches - CI never touches the cluster, only the registry and
this repo. dev's Application (vncmail-dev) is registered and applied
already (manual sync for now, until the one-time namespace secret
bootstrap is done - see VNCMAIL-SETUP.md). prod's Application is
scaffolded in deploy/argocd/ but deliberately not applied - it targets a
different cluster (node1-3) that isn't registered with ArgoCD yet, and
there's still no real prod hostname/Stalwart/ClusterIssuer.
Fixes base/ingress.yaml to the real ingressClassName: traefik (was the
nginx-style `public`, which doesn't exist on either cluster) and gives
each overlay its own cert-manager issuer patch instead of one hardcoded
value, since dev and prod need different (or, for prod, nonexistent)
issuers.
|
||
|
|
3512f935d1 |
feat(ci): GitLab CI/CD dev→prod pipeline, kustomize base+overlays
Multiple developers now work on this repo, and the only working deploy
trigger required pushing to GitHub - which contradicts the standing
GitLab-canonical policy for this repo - while every actual deploy was a
manual kubectl run against one environment (no prod exists at all).
Restructures deploy/k8s/ into base/ + overlays/{dev,prod}: overlays/dev
is a verified byte-for-byte no-op for the live sandbox (kubectl kustomize
diff against the old flat layout is empty), overlays/prod is scaffolded
but inert (placeholder hostname + JMAP_SERVER_URL, since neither a prod
hostname decision nor a prod Stalwart exist yet). deploy/k8s/ca/ (the
EJBCA internal CA) is untouched and never referenced by either overlay.
Adds .gitlab-ci.yml: verify (MR gate, no push/deploy) -> build+deploy-dev
(automatic on push to dev, one image name/tag-only environments, fixing
the old -dev/-beta naming split) -> promote (manual, protected
`production` environment, retags the exact dev digest via
`docker buildx imagetools create` - never rebuilds - and is left as a
documented TODO for the actual `kubectl apply` until prod is real).
Updates VNCMAIL-SETUP.md and deploy/k8s/README.md to describe the new
flow and correct the aspirational promotion description that assumed a
"production image" CI never actually built.
Also fixes a pre-existing lint error (no-control-regex false positive on
an intentional DN-sanitizing character class in lib/smime-ca/ejbca.ts)
that was blocking this commit's pre-commit hook - unrelated to this
change otherwise, confirmed already present on dev before this branch.
Runner/RBAC/registry setup is an infra prerequisite this commit cannot
provide - documented in the pipeline plan, not part of this diff.
|
||
|
|
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>
|
||
|
|
759ab7fe8c |
feat(ca): EJBCA Community manifests + root ceremony runbook for A-01/A-06
Manifests and a runbook for the internal CA that issues 1-year S/MIME certificates. Per the agreed split: these are applied by hand, and the root-key ceremony in section 3 is deliberately NOT automated - the whole value of an offline root is that its private key never exists on a machine that runs services or tooling. Structural recommendation up front (section 0), because it decides whether promoting to vncmail later is a config change or a re-rooting: name the root for the ORGANISATION, not the environment. One root, generated once at prod grade, with per-environment intermediates under it. Promotion is then "issue a second intermediate from the same root" - a one-hour ceremony - and the trust anchor already distributed to laptops, phones and partners does not change. A throwaway "VNC Sandbox Root" instead means redistributing a new anchor to every device and every external party who ever verified a signature. That cost is invisible today and expensive later. Security shape of the deployment: - Own namespace (vnc-ca), NOT vncmail. The webmail pod is internet-facing; the CA signs certificates. A compromise of the former must not be a compromise of the latter. - Port 8080 (CRL + OCSP) is the ONLY thing the public ingress routes, and only two path prefixes. Not the admin web, not the REST API, not the public enrolment pages. - Port 8443 (admin + REST, client-cert authenticated) is never exposed through an ingress - cluster-internal or kubectl port-forward only, enforced by NetworkPolicy as defence in depth. - The RA credential the enrolment route uses gets its own EJBCA role limited to issue/revoke under one profile. It lives on an internet-facing pod, so its blast radius should be "mint an S/MIME cert" and not "reconfigure the CA". Two things the runbook makes you prove rather than assume: - The NetworkPolicy actually enforces. Applying one on a CNI that does not implement it succeeds silently and protects nothing, so section 6 has a probe that MUST time out - a 401 means the REST API is exposed cluster-wide. - The CA backup restores. ejbca-db-data holds the intermediate private key and, with key recovery on, escrowed user decryption keys; an untested CA backup is a belief. Section 7 surfaces a decision rather than making it silently. S/MIME is unlike TLS in that losing a private key makes every message ever encrypted to that user permanently unreadable - re-issuing does not help, the old mail was encrypted to the old key. So key escrow is on by default here, which is the defensible choice when mail is a business record, but it means the CA operator can decrypt user mail. That is worth deciding consciously and being able to explain, not discovering. MariaDB rather than the container's embedded H2 deliberately: H2 is not supported for data you intend to keep, and the database is the one component that must not need re-platforming on promotion. Image tag pinned. The env-var contract is the part most likely to have drifted between EJBCA releases, so the runbook says to verify it against the tag pulled rather than trusting these values, and gives the log grep that shows the failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
22e5e97da9 |
feat(theme): VNClagoon brand theme (navy + cyan) as default, dark-first
Add builtin-vnclagoon theme (cyan #00D4FF accent on navy #0A0E1A, DM Sans body + Syne headings, self-hosted OFL fonts); set as default theme policy; default mode dark. Add VNCmail wordmark SVGs (on-dark/on-light) + wire logo/company via k8s secret template. Placeholder wordmark — swap official styleguide SVG. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
476c76c420 |
docs(deploy): sharpen k8s admin runbook — inventory, pre-flight, ordered apply
Self-contained guide: exactly what to deploy (7 objects + image), 3 cluster values to match against bulwark, copy-paste apply order, verify, update/rollback, troubleshooting table. Plain kubectl apply (no GitOps). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a3d551b640 |
feat(deploy): k8s manifests for microk8s (vncmail.sandbox.vnc.de)
Bulwark is stateful (local /app/data) — Vercel serverless (read-only fs) crashes it. Deploy as a container with 4 persistent volumes on microk8s, alongside bulwark.sandbox.vnc.de. Adds deploy/k8s/ (namespace, pvc, deployment, service, ingress, secret template, runbook) + rewrites setup doc off Vercel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |