Full TLS certificate lifecycle for CommuniGate Pro: discover every domain on the server, filter by eligibility, order certs from Let’s Encrypt via Cloudflare DNS-01, install them back into CGP with a read-back fingerprint check, renew automatically before expiry, notify the postmaster on failure. Ships as a single hardened binary (Windows / Linux) with an embedded operator dashboard.
A daemon that automates every step of getting a public TLS certificate on a CGP server, and keeps them fresh forever after.
CommuniGate Pro ships with only a manual TLS surface: an admin generates a private key in the web UI, pastes in a CSR or certificate, and files it under the domain’s SecureCertificate. No ACME. No Let’s Encrypt integration. No renewal scheduler. Every 90 days, someone remembers — or they don’t, and the browser certificate error page shows up on a Monday morning.
cert-helper replaces all of that with an unattended service. Every 24 hours (configurable) it walks the CGP CLI, finds every domain whose cert is inside the renewal window, orders a fresh one from Let’s Encrypt using DNS-01 via Cloudflare (or HTTP-01 via a static webroot), converts the key to the format CGP requires (PKCS#1 traditional RSA), pushes it back over the CGP CLI, and reads the installed cert back to confirm the fingerprint matches what was sent. If anything fails, an email lands in the postmaster mailbox with the domain name, timestamp, and error.
It also handles the “which domains SHOULD get a public cert” question. A CGP server usually has dozens of domains — a handful are yours, the rest are customer domains whose MX records point to your server but whose DNS you don’t control. Trying to issue certs for those would burn Let’s Encrypt rate limits and fail public CT log review. cert-helper’s eligibility filter (DNS lookup + local-IP check + allow/deny list) sorts them out, and (optionally) pushes CertificateType=Disabled to CGP for the ineligible ones — replacing any external “keep applying Disabled” script.
Every renewal (manual or scheduled) runs the same 12-step pipeline. Understanding it makes troubleshooting simple — every step logs to cert-helper.log, so a failed run tells you exactly where it stopped.
DomainAliases array + host part of Preferred URL for HTTP. Each is passed through the eligibility filter individually; failing aliases are dropped (partial-cert fallback), surviving ones go into the SAN list._acme-challenge.<domain> with the ACME key-authorization hash. Record ID stored for cleanup.Ready (validated) or Invalid (failed).rsa crate directly and hand the key to rcgen for the CSR.poll_certificate, receive the PEM chain.UPDATEDOMAINSETTINGS <domain> { SecureCertificate=...; PrivateSecureKey=...; CAChain=...; CertificateType=Enabled; }. Immediately re-read via GETDOMAINEFFECTIVESETTINGS and compare SHA-256 fingerprints. Mismatch = fail loudly. Match = state DB updated with new fingerprint, not_after, installed_at.LISTDOMAINS, GETDOMAINEFFECTIVESETTINGS, UPDATEDOMAINSETTINGS) which have been stable since 6.0.postmaster@<main-domain> works; needs domain-administrator rights on every domain the helper will touch.acme-v02.api.letsencrypt.org (production) or acme-staging-v02.api.letsencrypt.org (staging).Zone.DNS:Edit on the zone containing your CGP domains. Global keys and tokens with excess scopes work but are strongly discouraged.license.key covering cert-helper in its Helpers array. Without one, cert-helper runs in beta mode until 2026-12-31 (unstamped dev binary) or refuses to start (stamped release binary).# 1. Create the install directory
New-Item -ItemType Directory C:\CommuniGate\Helpers\cert-helper -Force
# 2. Drop the binary + license
Copy-Item cert-helper.exe C:\CommuniGate\Helpers\cert-helper\
Copy-Item license.key C:\CommuniGate\Helpers\cert-helper\
# 3. Create the config from the template
Copy-Item cert-helper.toml.example C:\CommuniGate\Helpers\cert-helper\cert-helper.toml
notepad C:\CommuniGate\Helpers\cert-helper\cert-helper.toml
# see § Configuration reference
# 4. Register as a Windows Service (auto-start, restart-on-crash)
cd C:\CommuniGate\Helpers\cert-helper
.\cert-helper.exe --install-service
sc.exe start cert-helper
sc.exe query cert-helper
The service runs as LocalSystem. If you prefer a dedicated account, edit the service after install with sc.exe config cert-helper obj= "NT AUTHORITY\NetworkService".
The deploy tarball at /downloads/cert-helper/ contains a self-contained install-linux.sh that does everything below in one step. Or by hand:
sudo mkdir -p /opt/cert-helper /var/lib/cert-helper /var/log/cert-helper
sudo install -m 0755 cert-helper /opt/cert-helper/cert-helper
sudo install -m 0600 license.key /opt/cert-helper/license.key
sudo install -m 0600 cert-helper.toml /opt/cert-helper/cert-helper.toml
sudo vim /opt/cert-helper/cert-helper.toml # see § Configuration reference
sudo tee /etc/systemd/system/cert-helper.service <<'EOF'
[Unit]
Description=CGP Certificate Helper
After=network-online.target CGPro.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/opt/cert-helper/cert-helper --config /opt/cert-helper/cert-helper.toml
WorkingDirectory=/opt/cert-helper
Restart=always
RestartSec=30
User=root
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now cert-helper
sudo systemctl status cert-helper
Every section is optional at load time (defaults fill in what you omit) but you almost always want to review at least [cgp], [policy], [acme], and [cloudflare]. All master switches (SMTP send, auto-disable, ACME production URL) default to their safe value — nothing reaches out or mutates CGP until you opt in.
| Key | Default | Notes |
|---|---|---|
| host | localhost | Usually the daemon runs on the CGP host itself. |
| port | 106 | Standard CGP CLI. |
| admin_user | — | Required. Usually postmaster@<main-domain>. |
| admin_pass | — | Required. Plaintext in TOML; file should be mode 0600, root-owned. |
| Key | Default | Notes |
|---|---|---|
| path | state.sqlite | Absolute path recommended when running as service. |
| Key | Default | Notes |
|---|---|---|
| dir | . | Absolute path recommended. |
| file | cert-helper.log | Filename inside dir. |
| level | info | tracing-subscriber env-filter, e.g. info,cert_helper=debug. |
tail -f on stdout by design.| Key | Default | Notes |
|---|---|---|
| mode | allow_all_except | allow_all_except = default-eligible minus excludes. deny_all_except = default-ineligible plus includes. |
| list | [] | Domain names for the exclude/include set. |
| local_ips | auto | IPv4/IPv6 addresses of the mailserver. A domain is eligible only if at least one of its resolved A/AAAA records is in this set. Auto-discovered from AdminDomainName when omitted. |
| auto_disable | false | When true, push CertificateType=Disabled to CGP for domains just flipped to Ineligible. Replaces the customer’s external keep-applying-Disabled script. |
| Key | Default | Notes |
|---|---|---|
| directory_url | LE staging | Always staging by default. Switch to production only after end-to-end staging success (see § Staging vs production). |
| contact_email | — | Must contain a dot. LE rejects contactless-form addresses. |
| http01_dir | ./acme-challenges | Used only by HTTP-01. Ignored when [cloudflare] is present. |
| Key | Default | Notes |
|---|---|---|
| enabled | true | Master switch for the daily task. |
| interval_seconds | 86400 | Once every 24 hours is what you want in production. Testing? Set to 60 briefly to see it fire. |
| threshold_days | 30 | Renew when not_after < now() + this. LE recommends 30. |
Retry backoff on per-domain failure: 1h → 6h → 24h → 24h → fail. After the 5th consecutive failure the row flips to failed; the scheduler skips it; an operator can reset by clicking Renew now on the dashboard.
| Key | Default | Notes |
|---|---|---|
| enabled | false | Off by default. Turn on when you have a working relay. |
| host / port | localhost / 25 | Hand-rolled SMTP client, no auth, no TLS. Assumes a localhost relay. If you need auth or STARTTLS, an lettre-backed variant is a small refactor. |
| helo | — | HELO name. |
| from | — | Envelope + header From. |
| recipients | — | Array of To addresses. |
| subject_prefix | [cert-helper] | Prepended to every subject. |
| events.issuance_failed | true | End-to-end failure to obtain a cert. |
| events.sans_shrunk | true | New cert covers fewer SANs than the old one. |
| events.no_dns | true | Configured domain / alias doesn’t resolve. |
| events.not_local | true | Resolves but IP is not in local_ips. |
| Key | Default | Notes |
|---|---|---|
| enabled | true | Serves the read-write dashboard. |
| addr | 127.0.0.1:8765 | Bind loopback. There is no built-in auth — expose externally only via an authenticated reverse proxy. |
| Key | Default | Notes |
|---|---|---|
| api_token | — | Scoped Zone.DNS:Edit token. Never a global API key. |
| zone_id | — | Single-zone scope. Multi-zone support is a small follow-up if you need it. |
Omit the whole [cloudflare] section to fall back to HTTP-01. See § HTTP-01 alternative.
sc.exe start cert-helper. On Linux: systemctl start cert-helper.http://127.0.0.1:8765 (SSH port-forward if you’re remote: ssh -L 8765:127.0.0.1:8765 root@your-cgp-box).directory_url to production (see § Staging vs production).Read-write UI at http://127.0.0.1:8765. Auto-refreshes every 5 seconds.
License status, server time, config file path, DB path, CGP host, policy mode, local IPs (both configured and auto-discovered).
Total domains, and per-status counts (Active, Pending, Failed, Disabled, No-DNS, Ineligible).
One row per CGP domain. Columns: name, status pill (colour-coded), CGP-side CertificateType, issuer, fingerprint (truncated), days-to-expiry (red if past, amber if under threshold), attempt count, last error.
Per-row action buttons:
failed row can be retried.Disabled is respected by the scheduler and by discovery’s auto-disable path.Last 50 audit lines from the state DB: discover, renew-success, renew-failed, enable, disable, notification-sent.
View + edit the TOML in-place. Save writes to the file on disk and reloads. Fields that would break the running scheduler (interval, threshold) apply immediately; changes to [cgp] host/port take effect on the next connection.
The customer’s core requirement: most CGP domains should NOT get a public cert. This is what the filter does, per domain.
allow_all_except passes everything except the listed names. deny_all_except only passes listed names. Denied = whole cert dropped with reason PolicyDeny.DomainAliases array + host part of Preferred URL for HTTP. Each candidate is evaluated separately from now on.tokio::net::lookup_host). NXDOMAIN = NoDns. Timeout / other errors = DnsError.local_ips. Miss = NotLocal.Partial-cert fallback: a domain with 3 aliases where 1 fails still gets a cert, covering the 2 that passed. The dropped name goes into the dropped array and (if [smtp.events].sans_shrunk = true) triggers a notification email at renewal time.
Auto-disable: when auto_disable = true, a domain that just flipped from Active/Pending to Ineligible has its CertificateType set to Disabled via UPDATEDOMAINSETTINGS. This is idempotent — the setting is checked first, only written if it differs. Operator-set Disabled is never overridden.
When [renewal] enabled = true, a background tokio::time::interval task ticks every interval_seconds. First tick is skipped (avoids startup race). Each tick runs one renewal pass:
SELECT domain FROM domains WHERE status='active' AND not_after < datetime('now', '+' || threshold_days || ' days').Disabled / Ineligible / attempt_count ≥ MAX_ATTEMPTS (5).last_attempt + backoff(attempt_count) is in the future.renew_one(ctx, domain) — the same code path a manual Renew now uses.record_attempt(domain, error), increment attempt_count, set backoff. After 5 attempts flip to failed.[smtp.events].| Attempt | Backoff before next |
|---|---|
| 0 → 1 | 1 hour |
| 1 → 2 | 6 hours |
| 2 → 3 | 24 hours |
| 3 → 4 | 24 hours |
| 4 → 5 | — flips to failed, needs manual reset |
Four event classes, each independently toggleable. Every notification carries timestamp, domain, and error/context.
The pipeline could not obtain a cert — ACME account creation failed, order was rejected, all SANs failed validation, LE returned a hard error, or install into CGP failed the read-back verify.
A renewal that yielded a cert covering fewer SAN names than the previous cert. Body lists which names were lost and which were kept. Operator investigates: did an alias’s DNS move? Was a domain removed? Did a CF token lose access to a zone?
A configured domain or alias didn’t resolve at all (NXDOMAIN). Usually a stale DomainAliases entry.
Resolves but the IP isn’t in local_ips. Common cause: domain moved behind Cloudflare / another CDN, or your mailserver’s IP changed and local_ips is stale.
DNS-01 is the recommended path for any domain fronted by a CDN (which most CGP-served domains are). It doesn’t require the helper to serve HTTP on port 80.
Cloudflare dashboard → click your zone → the right sidebar shows Zone ID (32 hex chars).
[cloudflare][cloudflare]
api_token = "cfut_...your token..."
zone_id = "6b57eb04d8a0ee5d819f69499186e300"
Restart cert-helper. That’s it. First Test renewal or Renew now will use it.
cgatepro.net and rhone.ch), you need one cert-helper instance per zone until multi-zone support lands.When there’s no CDN in front of your domains, HTTP-01 is simpler: LE fetches http://<domain>/.well-known/acme-challenge/<token>, expects the key-authorization as the body.
Configure by omitting the [cloudflare] section and pointing [acme].http01_dir to a directory some HTTP server can serve at /.well-known/acme-challenge/. Two typical setups:
CGP’s HTTPU already serves /webskins/<skin>/* paths. You can add a static mapping in the CGP Webskin config so /.well-known/acme-challenge/* serves from cert-helper’s http01_dir. Requires a small skin edit.
Most reverse-proxy setups already do this:
location /.well-known/acme-challenge/ {
root /var/lib/cert-helper; # http01_dir’s parent
default_type text/plain;
}
Let’s Encrypt runs two directories:
| Environment | Directory URL | Cert accepted by browsers? | Rate limit |
|---|---|---|---|
| Staging | https://acme-staging-v02.api.letsencrypt.org/directory | No — issuer “(STAGING) Ersatz Emmer” | ~30k orders / 3h |
| Production | https://acme-v02.api.letsencrypt.org/directory | Yes | 50 certs / week / registered domain |
Always develop against staging. Rate limits there are generous enough that an accidental infinite loop won’t lock you out. When staging works end-to-end for at least one domain:
[acme].directory_url to production.sqlite3 /var/lib/cert-helper/state.sqlite "DELETE FROM config WHERE key='acme_account_credentials'". Restart. A fresh production account is created on the next renewal.| Symptom | Cause | Fix |
|---|---|---|
Dashboard says license: degraded | No license.key next to binary | Copy the key in, restart. Beta mode still works until 2026-12-31. |
[PROTECT] refusing to start [tampered] | Binary was modified after being stamped | Restore original binary; don’t re-run strip, don’t patch resources. |
| Discover returns 0 domains | Wrong admin_user / admin_pass, or CGP CLI blocked | Check cert-helper.log for the USER/PASS reply codes. |
Renew fails with NXDOMAIN … _acme-challenge.<domain> | Wrong CF token scope or wrong zone; propagation delay longer than 45s (rare) | Verify token with curl -H "Authorization: Bearer $TOKEN" https://api.cloudflare.com/client/v4/user/tokens/verify. Confirm zone_id matches. Retry manually. |
Renew fails with rate limit exceeded | Hit LE production rate limit | Switch back to staging until you understand the limit. Every failed order counts. |
| Cert installs but browsers show the old one | CGP caches active certs in memory | Restart CGP: sc restart CommuniGatePro (Windows) / systemctl restart CGPro (Linux). |
| Install returns 500 from CGP but fingerprint matches | Known CGP quirk — 500 on success | Non-issue. The read-back verify is authoritative. |
Row stuck in failed after 5 attempts | All backoff exhausted | Click Renew now on the row — resets counter and tries once more. |
| SMTP notify never arrives | [smtp] enabled = false, or no localhost relay | Flip enabled, check cert-helper.log for SMTP conversation lines. |
| Domain flipped from Active to Ineligible unexpectedly | DNS moved, or local_ips is stale | Re-check the domain’s A record; update local_ips; re-discover. |
sc.exe stop cert-helper
cd C:\CommuniGate\Helpers\cert-helper
.\cert-helper.exe --uninstall-service
Remove-Item -Recurse C:\CommuniGate\Helpers\cert-helper
sudo systemctl stop cert-helper
sudo systemctl disable cert-helper
sudo rm /etc/systemd/system/cert-helper.service
sudo systemctl daemon-reload
sudo rm -r /opt/cert-helper /var/lib/cert-helper /var/log/cert-helper
Yes for issuance (LE supports *.example.com via DNS-01 only), but the current release doesn’t explicitly configure them. Add the wildcard as a DomainAliases entry on the CGP domain and it becomes a SAN candidate. Future release will add a first-class wildcard toggle.
Yes if they share a common CGP admin over a network-reachable port 106, but the recommended pattern is one cert-helper per CGP host — state stays local, dashboard is loopback-only, and there’s no cross-host coordination in the DB schema.
Each issued cert’s private key is generated freshly per renewal, sent to CGP’s PrivateSecureKey field, and dropped from cert-helper memory. The state DB stores only the fingerprint and metadata, never the key material.
Next renewal for any domain in that zone fails with an HTTP 401 from Cloudflare’s API. The issuance_failed notification fires. Rotate the token, edit [cloudflare].api_token, restart. Backoff timers reset on the next manual Renew.
Any RFC 8555 ACME CA works — just point [acme].directory_url at their directory. Tested only against LE.
failed and ineligible?failed = we tried to renew and something went wrong (5 attempts exhausted). Row waits for operator action. ineligible = the eligibility filter said no (DNS, local-IP, or allow/deny gate). Row will be re-checked next discovery pass; if the underlying condition changes (new DNS, updated local_ips, adjusted allow-list), it’s promoted back to pending and picked up by the next scheduler tick.
An RSA-2048-signed JSON blob listing which helpers this box is licensed to run and until when. Sibling to notification-helper, dkim-signer, mailing-list-helper. cert-helper accepts any license whose Helpers array contains "cert-helper". Contact [email protected] to obtain one, or generate with the internal license-generator-v2.py.
| ACME | Automated Certificate Management Environment, RFC 8555 — the protocol Let’s Encrypt speaks. |
| SAN | Subject Alternative Name — DNS name a cert is valid for. Modern certs carry all names in SANs; the legacy Common Name field is ignored. |
| DNS-01 | ACME challenge type where the ACME server verifies control of a domain by looking up a TXT record at _acme-challenge.<domain>. |
| HTTP-01 | ACME challenge type where the ACME server verifies control by GET-ing http://<domain>/.well-known/acme-challenge/<token>. |
| PKCS#1 / PKCS#8 | Two different PEM encodings for RSA private keys. CGP requires PKCS#1 (“traditional RSA”); modern ACME clients emit PKCS#8. cert-helper converts. |
| CSR | Certificate Signing Request — a signed statement of intent submitted to the CA during finalize. |
| Fingerprint | SHA-256 hash over the certificate DER bytes. Used by the read-back verify to prove the cert CGP now serves is the one we just handed it. |