System Certificates
System Certificates
Admin path: System > System Certificates (view_system_certificates.cfm,
inc/cert_action.cfm, inc/import_certificate.cfm,
inc/acme_request_certificate.cfm, inc/acme_request_san_certificate.cfm,
inc/delete_system_certificate.cfm, inc/parse_certificate_details.cfm,
inc/get_system_cert_ids.cfm, inc/get_active_cert_paths.cfm).
This is the canonical certificate store for Hermes. Every X.509
certificate the gateway presents to the outside world is registered as a
row in system_certificates and selected by ID from one of the binding
pages: Console Settings (web console),
SMTP TLS Settings (Postfix SMTP banner), Email
Server > Settings (Dovecot IMAP/POP/Submission), and
SAN Management (per-mailbox-domain
autodiscover/autoconfig).
The page itself is purely a CRUD store plus a CSR generator and the
Let's Encrypt (ACME) integration. It does not bind certs to services
— that happens on the consuming pages, each of which writes to its own
row in parameters2.
Where certificate files live
The store has two ingest paths plus a system-managed placeholder. Each lays down files in a different directory tree.
type |
On-disk pattern | Source |
|---|---|---|
Imported |
/opt/hermes/ssl/<file_name>_hermes.pem (leaf), .key, .chain.pem, .bundle.pem (leaf + chain concatenated) |
Import Certificate modal or Generate CSR → external CA → import |
Acme |
/etc/letsencrypt/live/<file_name>/{fullchain,cert,privkey,chain}.pem |
Request ACME Certificate modal; renewals via Ofelia-scheduled certbot runs |
Imported (system) |
/opt/hermes/ssl/bootstrap_hermes.{bundle.pem,key,...} (Docker fresh installs); /etc/ssl/{certs,private}/ssl-cert-snakeoil.{pem,key} (legacy non-Docker) |
Installer (install_hermes_docker.sh) or Ubuntu ssl-cert package |
The bootstrap cert is a self-signed snakeoil that ships with every
fresh Docker install — Hermes needs something to bind to before the
admin imports a real cert. It is reserved as a placeholder for newly-added
mailbox domains; consumers that actually need a publicly-trusted cert
(SMTP TLS, the console) refuse to bind to it (see
SMTP TLS Settings § Selecting a certificate).
The system column and the SYSTEM badge
The system_certificates.system column (added by issue #252) is a
boolean flag marking install-generated rows. The UI surfaces this two
ways:
| Surface | Behavior when system = 1 |
|---|---|
| SYSTEM badge next to the friendly name | Rendered as a gray pill in the Name column |
| Delete button | Disabled with a tooltip ("System-managed certificate — cannot be deleted. Used as a placeholder when binding mailbox domains before a real cert is imported.") |
The delete-protection gate lives in cert_action.cfm and re-checks
system = 1 server-side so a crafted POST cannot bypass the disabled
button.
Legacy vs Docker file_name. Fresh Docker installs have
file_name = 'bootstrap'. Legacy non-Docker installs that survived a migration havefile_name = 'ssl-cert-snakeoil'(from the Ubuntussl-certpackage). Both are flaggedsystem = 1on installs where the column exists. Theinc/get_system_cert_ids.cfmhelper resolves the row IDs at runtime — code that needs to know "is this a system cert" reads from the helper, never from a hardcodedid = 1. This is the only correct gating signal;version_no = 'Docker'does not tell you which file_name pattern applies because both DEV (Docker, legacy install vintage) and Test (Docker, fresh install) report the same version string.
Cert path resolver — get_active_cert_paths.cfm
Most consumers don't want the row ID — they want the actual on-disk
paths to pass to nginx ssl_certificate, openssl cms -sign, Postfix's
smtpd_tls_cert_file, etc. The path layout differs between Imported
(/opt/hermes/ssl/...) and ACME (/etc/letsencrypt/live/.../...), and
the same logical name maps to different files for different consumers
(fullchain.pem for nginx vs cert.pem for openssl signer).
inc/get_active_cert_paths.cfm is the single place that knows this
layout. It reads the active console certificate from parameters2,
joins to system_certificates, and writes six caller-visible variables:
| Variable | Purpose |
|---|---|
hermesCertType |
"Imported", "Acme", or "Snakeoil" |
hermesCertIsSnakeoil |
true when no real cert is bound (signing callers must skip) |
hermesCertNginxPath |
Cert for nginx ssl_certificate (bundle for Imported, fullchain for Acme) |
hermesCertKeyPath |
Private key |
hermesCertSignerPath |
Leaf cert only — for openssl cms -sign |
hermesCertChainPath |
Intermediates only — for openssl cms -sign -certfile |
Any new code that touches certificate files should cfinclude this
helper rather than reinventing the path arithmetic. The legacy hardcoded
fallback (/etc/ssl/certs/ssl-cert-snakeoil.pem) was removed in #251
because the minimal Docker container doesn't have the ssl-cert package
and nginx crashed with BIO_new_file errors on the missing file.
Three ingest paths
1. Request ACME Certificate (Pro feature)
The Request ACME Certificate button issues a Let's Encrypt cert via an ephemeral certbot container. Disabled when no Pro license is active.
Admin clicks Request -> view_system_certificates.cfm action=requestacme
-> inc/acme_request_certificate.cfm
docker run --rm --name hermes_certbot --network host \
-v <repo>/config/hermes/var/www/html:/var/www/certbot \
-v <repo>/config/certbot/conf:/etc/letsencrypt \
-v <repo>/config/certbot/logs:/var/log \
certbot/certbot:latest \
certonly --webroot --webroot-path /var/www/certbot \
--email <admin> --agree-tos --no-eff-email \
[--dry-run] # staging mode
-d <domain>
- Staging mode adds
--dry-runand never lands a real cert. Always test with Staging first to confirm DNS + ports 80/443 work; Let's Encrypt's production rate limits will lock the domain out for a week if you burn through them with broken HTTP-01 challenges. - The webroot is mounted to
/var/www/certbotso certbot can write the challenge file where the live nginx vhost expects it. - Certs land in
config/certbot/conf/live/<domain>/(bind-mounted to/etc/letsencrypt/live/<domain>/in the commandbox container). - Renewals are driven by Ofelia (Scheduled Tasks). Each renewal runs the
same ephemeral certbot container with
renew; if the renewal succeeds, dependent services (nginx, Postfix, Dovecot) reload to pick up the new files. - ACME certs cannot be renewed manually from this page — the row exists for binding and deletion only; renewals are scheduled and silent.
Per-mailbox-domain ACME SAN certs (autoconfig + autodiscover + custom
prefixes) use a separate code path
(inc/acme_request_san_certificate.cfm) wired to
SAN Management. Both paths land
rows in the same system_certificates table.
2. Import Certificate
For certs issued by any CA other than Let's Encrypt (commercial CA, internal PKI, etc.). The admin pastes three PEM blobs in the Import Certificate modal:
| Field | Contents |
|---|---|
| Certificate (PEM) | Leaf cert between -----BEGIN CERTIFICATE----- and -----END CERTIFICATE----- |
| Unencrypted Key (PEM) | Private key — must be unencrypted (no passphrase). Encrypted keys are rejected because nginx / Postfix cannot prompt for a passphrase at startup. |
| Root & Intermediate CA Certificates (PEM) | Chain — root + intermediates concatenated, leaf-omitted |
On save, Hermes writes four files under /opt/hermes/ssl/:
<file_name>_hermes.pem (leaf only)
<file_name>_hermes.key (private key)
<file_name>_hermes.chain.pem (CA chain, no leaf)
<file_name>_hermes.bundle.pem (leaf + chain — for nginx ssl_certificate)
<file_name> is derived from the friendly name with special characters
sanitized. The row is inserted with type = 'Imported' and the
extracted Subject/Issuer/Serial/Fingerprint cached in the table for the
expandable row preview.
3. Generate CSR
For admins who want to use their own CA but don't have a key+CSR yet. The modal collects DN fields (Country, State, Locality, Organization, Department) plus a Certificate purpose radio toggle that drives the rest of the form:
| Purpose | CN source | SANs |
|---|---|---|
| Server certificate (single-name DV, ~$10/yr) | Admin enters Common Name field directly | Admin-entered FQDNs only |
| Mailbox certificate (SAN / UCC, $50–$200/yr) | Auto-derived as <first-prefix>.<mailbox_domain> matching Pro ACME's first--d-flag behavior |
Mandatory: autoconfig.<domain>, autodiscover.<domain>, plus every prefix from additional_sans. Additional admin entries auto-expand bare prefixes against the mailbox domain. |
Smart default: if mailbox_domains has any rows, the modal defaults to
Mailbox; otherwise it defaults to Server. The page-level
"Choosing the Right Certificate Type" card above the table walks the
admin through the cost difference and the "a basic DV cert will not
work for mailboxes" trap.
On submit, Hermes generates a 2048- or 4096-bit RSA key + matching CSR
and bundles them into a .rar archive at
/opt/hermes/tmp/<token>_csr_key.rar. The CSR-pending state is then
surfaced as a persistent callout at the top of the page (added by
#249) — the Download CSR button stays visible across page reloads
until the admin clicks Discard. Submit the CSR to the chosen CA,
receive the signed cert + chain, then come back and use Import
Certificate (steps 1 above) to register it. The private key in the
.rar is what you'll need for the import.
The CSR private key never leaves Hermes until the admin downloads the bundle. If the admin clicks Discard without downloading, the key is gone — there is no recovery. The Discard button warns about this; the persistent callout pattern (#249) was introduced because the one-shot download button that used to live inside the success alert was easy to miss on a page reload.
Service binding cross-reference
The certificate-store rows are referenced from four service-binding locations. Each location keeps its own copy of the cert ID — there is no cascading delete, so the deletion guard (next section) walks all four before allowing a row to be removed.
| Service | Where the binding lives | Set on page |
|---|---|---|
| Console (admin, user portal, NC, Ciphermail) | parameters2.parameter = 'console.certificate', module = 'console' |
Console Settings |
SMTP (Postfix smtpd_tls_cert_file) |
parameters2.parameter = 'smtp.certificate', module = 'certificates' |
SMTP TLS Settings |
| Webmail (Dovecot IMAP/POP) | parameters2.parameter = 'mail.certificate', module = 'certificates' |
Email Server > Settings |
| Mailbox SAN (per-domain autodiscover/autoconfig) | mailbox_domains.mailbox_certificate (multiple rows possible) |
Email Server > Domains, SAN Management |
The page renders four YES/NO columns (Console / SMTP / Webmail / Mailbox SAN) so an admin can see at a glance which services a given cert is in use by.
Deletion guard
inc/delete_system_certificate.cfm walks every consumer before allowing
a delete:
1. system column flag -> system-managed, refuse
2. parameters2 console.certificate -> assigned to Web Service, refuse
3. parameters2 smtp.certificate -> assigned to SMTP Service, refuse
4. parameters2 mail.certificate -> assigned to Mail Service, refuse
5. (mailbox_domains.mailbox_certificate check is in cert_action.cfm)
6. -> DELETE FROM system_certificates WHERE id = ?
7. plus filesystem cleanup:
Imported: rm /opt/hermes/ssl/<file_name>_hermes.{pem,key,chain.pem,bundle.pem}
Acme: docker run --rm certbot/certbot:latest delete --cert-name <file_name>
+ DELETE FROM mailbox_domains_sans WHERE acme_certificate = ?
The guard is stop-on-first-match with a specific error message per case so the admin knows which binding is blocking the delete and where to go to unbind. There is no "force delete" — the only way past the guard is to unbind on the consuming page first.
Certificate downloads (gated)
Each row has an expandable details panel with Download Certificate, Download Private Key, and Download CA Chain buttons. By default these are disabled for safety (downloading a private key over a web page is a sensitive operation). To enable, set
ALLOW_CERT_DOWNLOAD=yes
in /opt/hermes/config/security.conf on the host filesystem. The page
reads this file on every load (cached in the local request). When the
toggle is off, the buttons render disabled with a tooltip telling the
admin where to set the flag.
Downloads are streamed via a hidden iframe + class="no-preloader"
pattern (standard Hermes binary-download convention) so the page's
spinner overlay doesn't get stuck.
SAN validation sub-table (Pro feature)
When a row is bound to one or more entries in mailbox_sans
(autodiscover/autoconfig/custom subdomains for a mailbox domain), the
expanded details panel includes a Mailbox SAN Validation sub-table
showing IP-resolve and DNS-resolve status for each SAN. This is
populated by the
SAN Management validator and is
read-only here — it answers "do all the SANs on this cert actually
resolve to this server?" at a glance.
Failure semantics
| What breaks | What happens |
|---|---|
| CSR field validation (Country != 2 chars, bad CN chars, etc.) | session.m set with the specific error, cflocation back to the page, no file/DB writes |
Mailbox CSR with empty additional_sans table |
Refused with "No SAN prefixes configured in SAN Management. Cannot generate a mailbox certificate without at least autoconfig + autodiscover." |
| ACME staging dry-run fails (DNS, port 80, rate limit) | Raw certbot stderr surfaced in the error alert; no DB row added |
| ACME production fails | Same as staging — error alert with raw stderr |
| Import with mismatched key + cert | The import script's openssl-modulus check fails; error alert with detail |
| Delete blocked by binding | "The Certificate you are attempting to delete is assigned to the X Service" — admin must unbind first on the consuming page |
certbot delete fails on ACME row |
DB row kept, error surfaced; manual cleanup of the /etc/letsencrypt/live/<name>/ tree may be needed |
Files and containers touched
| Path | Owner | Role |
|---|---|---|
config/hermes/var/www/html/admin/2/view_system_certificates.cfm |
hermes_commandbox |
Page |
config/hermes/var/www/html/admin/2/inc/cert_action.cfm |
hermes_commandbox |
Action router (CSR, import, ACME, delete, discard) |
config/hermes/var/www/html/admin/2/inc/acme_request_certificate.cfm |
hermes_commandbox |
Single-domain ACME via certbot container |
config/hermes/var/www/html/admin/2/inc/acme_request_san_certificate.cfm |
hermes_commandbox |
Multi-SAN ACME (mailbox certs) |
config/hermes/var/www/html/admin/2/inc/import_certificate.cfm |
hermes_commandbox |
PEM paste-in handler |
config/hermes/var/www/html/admin/2/inc/delete_system_certificate.cfm |
hermes_commandbox |
Deletion guard + filesystem cleanup |
config/hermes/var/www/html/admin/2/inc/parse_certificate_details.cfm |
hermes_commandbox |
Single openssl x509 parse for subject/issuer/SAN/etc. |
config/hermes/var/www/html/admin/2/inc/get_system_cert_ids.cfm |
hermes_commandbox |
Resolver — which rows are system-managed |
config/hermes/var/www/html/admin/2/inc/get_active_cert_paths.cfm |
hermes_commandbox |
Resolver — on-disk paths for the active console cert |
/opt/hermes/ssl/ |
hermes_commandbox (bind-mounted) |
Imported cert files |
/etc/letsencrypt/live/<domain>/ |
hermes_commandbox (bind-mounted from config/certbot/conf/) |
ACME cert files |
/opt/hermes/tmp/<token>_csr_key.rar |
hermes_commandbox |
Pending CSR bundle |
/opt/hermes/config/security.conf |
host filesystem | ALLOW_CERT_DOWNLOAD toggle |
system_certificates table |
hermes_db_server (hermes DB) |
The canonical store |
certbot/certbot:latest image |
docker.io | Pulled on demand; ephemeral per request |
Every certbot invocation is docker run --rm against the public
certbot/certbot:latest image — Hermes never runs certbot directly on
the host. The container shares the host network (--network host) so
Let's Encrypt's HTTP-01 challenge can reach port 80 on the public IP.
Related
- SMTP TLS Settings — bind a System Certificate to Postfix SMTP TLS
- Console Settings — bind a System Certificate to the web console (nginx) and its hardening toggles
- Server Setup — Mail Server Hostname; should match the CN/SAN on the SMTP cert for STARTTLS verification
- Authentication Settings — Authelia; uses the console cert via its nginx-fronted vhost
- LDAP RemoteAuth — separate CA store at
/opt/hermes/certs/remoteauth/for upstream LDAP; not a System Certificate - SAN Management — per-mailbox-domain SAN prefixes that drive mailbox-cert CSR + ACME SAN issuance
- Intrusion Prevention — Fail2ban; not cert-related but documents the same nginx-restart cascade pattern this page avoids by not regenerating any nginx config
- Admin Console Firewall — IP allowlist for the console; layered above the TLS termination this page's certs drive