Skip to content

Latest commit

 

History

History
744 lines (687 loc) · 56.9 KB

File metadata and controls

744 lines (687 loc) · 56.9 KB

Changelog

All notable changes to Hermes Secure Email Gateway are documented in this file.

The format is based on Keep a Changelog. Hermes uses calendar versioning: release tags are vYYMMDD, where the digits are a planning label named for a target date — not necessarily the ship date. The date shown beside each release below is the actual release date.

Unreleased

Added

  • Distribution lists, as aliases with more than one destination (#311). An alias can now deliver to several addresses. No new page and no new concept: a list is an alias with more than one destination, so Email Relay → Virtual Recipients and Email Server → Aliases gained the capability instead of a third page appearing beside them. Both group by address, so twenty destinations render as one row with twenty chips rather than twenty near-identical lines, each chip editable and removable on its own. Relay domains could always express this, since that table never stopped an address appearing on several rows and Postfix concatenates the rows it gets back into one recipient list; lists built by hand that way are already in the right shape and simply display grouped.

  • Reachable By, controlling who may send to an address, which is a different question from where it delivers (#311). Mailbox domains only: a relay domain exists so the internet can send to it, and its own users never traverse the gateway for same-domain mail because their server is authoritative and resolves it locally, so restricting a relay address to internal senders would reject the only traffic that reaches it while permitting traffic that never arrives. Enforced by a new check_recipient_access MySQL map, following the shape of the discard-recipients map already in that chain. permit_mynetworks short-circuits ahead of it, so anything reaching the map arrived from outside and a plain REJECT is correct; no sender-domain test is used, since trusting an unauthenticated claim to be from your own domain would be worse than useless on a gateway. This is what makes external destinations defensible: without it an alias fanning out to twenty external addresses is reachable by anyone on the internet.

    Registering that restriction takes a seed row, not a template edit. main.cf.HERMES is only the file a fresh install starts from; on a running system generate_postfix_configuration.cfm rebuilds smtpd_recipient_restrictions from the parameters table, so the directive is assembled purely from child = '1' rows ordered by order1. Adding the map to the template alone would have been silently undone by the first Postfix settings save. The release notes previously told operators to perform that save; the upgrade's own post-upgrade phase regenerates the directive, so it is armed without them. Caught on the test box, where the map was rendered with real credentials and the console stored the setting while postconf -n showed no sign of it. The row sits at order1 1.150, after permit_mynetworks, the discard map and permit_sasl_authenticated, so internal and authenticated senders short-circuit before it and anything reaching the map came from outside.

  • Nine more quotes in the console's rotation, bringing it to 46. Seeded in both places, so a fresh install and an upgraded one end up with the same set: hermes_install.sql as ids 38 to 46, and the release's schema step guarded row by row with NOT EXISTS, since quotes has no unique key on the text for INSERT IGNORE to work against.

Changed

  • External destinations are permitted on mailbox domains, and flagged wherever they appear on both pages (#311). Previously every destination had to be an existing local mailbox, while relay domains allowed external freely, which was an inconsistency rather than a policy. Forwarded mail leaves with the original sender's address, so SPF fails at the receiving end, and content modification such as an External Banner invalidates the original DKIM signature, which together can mean rejection for senders publishing a strict DMARC policy. Badged rather than warned about once, because the person auditing in six months is not the person who created it.
  • Send-As is granted per mailbox rather than per alias, under Mailboxes → Actions → Send As. A single toggle on an alias worked while an alias had one destination and stopped making sense the moment it could have twenty, since it would have granted send-as to every member of a list at once. Membership and send-as are now independent. Existing grants are unchanged. Editing an alias's destination no longer moves the grant with it, which is deliberate: the grant means "this mailbox may send from this address" and has nothing to do with delivery. Documented alongside it: the grant alone changes nothing the user can see, because no mail protocol exposes send-as permissions to a client. The address must also be added as an identity in whatever they read mail with, which is Mail > Account settings > Aliases > Add alias in Nextcloud webmail and Manage Identities in Thunderbird. That step being undocumented made a working grant look broken, which is the first thing anyone reports.
  • Add creates, Edit manages members. On both pages, adding an address that already exists is now refused outright and points at that row's Edit button, instead of quietly merging into it. Routing both operations through Add gave two ways to do one thing, and it produced an answer nobody could act on: submitting an existing address whose destinations were all already stored could only report that nothing had changed, which reads as a failure for what was arguably a no-op. Edit already owned the whole destination set as chips with a diff save, so it was already the better route; it is now the only one.
  • Virtual Recipients is one address per submission, matching Aliases. The Add card was a newline-delimited textarea creating many addresses at once, which is what forced every outcome to be an accumulating per-row tally across four separate callouts, and what made it impossible to refuse an address that already existed rather than merely report it. One address means one verdict, so those four callouts collapse to plain error messages. Fan-out in the useful direction, one address to many destinations, is unaffected. Creating several addresses now takes several submissions; if a bulk path is wanted again it should be a real import with its own report rather than a shared text field whose failure mode is a tally.
  • Virtual Recipients now matches the Aliases page surface. Add moved from an always-visible card into a modal behind an Add button; the Delivers To field became the same chip picker used everywhere else on both pages, having been the last plain comma-separated text input among the four such fields, including its own Edit modal; the Actions column moved to the left; and the select-all checkbox column with its bulk Delete Selected button was replaced by one Delete per row. The checkboxes existed because the pre-grouping table rendered one row per destination, so an address with a large destination set filled the screen and needed sweeping. Grouping by address removed the reason, and the row still carries every underlying row id, so one Delete removes the address and all of its destinations together.

Fixed

  • Saving Send As destroyed the mailbox's ability to send as itself (#311). Every mailbox owns a self-row in sender_login_maps, written when the mailbox is created, and it is what reject_sender_login_mismatch consults on the submission port to let a user send as their own address. The handler replaced the grant set with a delete keyed on login_user alone, which took the self-row with it, and nothing put it back because the picker only ever offers aliases. Opening the modal and saving, with nothing selected and nothing changed, was enough. Receiving kept working, so it surfaced as an unexplained client-side send failure with nothing pointing at the page that caused it. The delete is now scoped sender <> login_user, and a self-row re-assert follows it, normally a no-op, so a mailbox already damaged by this repairs itself on the next save rather than needing an operator who knows what to look for.

  • Nextcloud sharing now stays inside your own domain by default (#316). Two things at once. First, isolation: a gateway can host unrelated organisations side by side, and Nextcloud groups are named after domains, so restricting shares to members of your own groups stops a user sharing a file into another customer's organisation. Second, it closes a disclosure: Mail's recipient autocomplete suggested every group on the instance, unfiltered by the requesting user's membership, so a user at one customer could type part of another customer's domain and have it suggested by name, and the LDAP and Authelia infrastructure groups were visible to every mailbox user. The installer already set shareapi_restrict_user_enumeration_to_group, but that governs users; groups take a separate path, so the protection had a hole its setting name gave no hint of. shareapi_only_share_with_group_members was chosen over disabling group sharing outright, because the latter would also have removed sharing with a whole group within a domain, which is useful and unrelated to the problem. A default rather than a policy: an operator running one organisation across several domains turns it off under Administration settings, Sharing. Hermes adds no setting of its own, since Nextcloud already exposes one.

  • virtual_recipients had no index on the column Postfix looks it up by, so every message caused a full table scan of that table. Unnoticed at a handful of rows, and about to matter considerably more now that operators can build twenty-member lists there.

  • A validator would have rejected a destination list outright. The Virtual Recipients page checked forwards_1 as a single email address before its handler ever ran, so a comma-separated list would have failed as "not a valid email". It now validates each entry, failing on the first bad one rather than silently dropping it and reporting partial success. Also added the missing guard for an empty destination list, which previously produced a success page that had changed nothing.

  • Removing the Send-As control would have silently zeroed the column on every alias edit, because the handler still read a form field that no longer existed and cfparam defaulted it to 0. Caught before release; the column is now left exactly as found.

  • Changing the console address to an FQDN could lock you out of the console for a year. console.hsts, console.ssl_stapling and console.ssl_stapling_verify were seeded enable in the baseline while the installer renders all three commented out in the generated Nginx config, its own comment reading "admin enables via UI". The database and the live config therefore disagreed on every fresh install, and the first Console Settings save of any kind regenerated Nginx from the database and silently switched all three on. Changing the console address to an FQDN, which is the documented next step after install, is exactly such a save. With the self-signed bootstrap certificate still bound, HSTS then pins every browser that has visited the address to refuse an untrusted certificate there for a year, with no click-through, so the operator loses the console mid-configuration and can only recover by clearing the HSTS entry in each browser individually. All three now seed disable, matching what the installer actually writes, so nothing turns on without the admin choosing it. Console Settings also drops the unqualified "(Recommended)" from HSTS and warns explicitly whenever the bound certificate is self-signed. Existing installs are deliberately left alone: silently disabling HSTS on a gateway that is using it correctly would be a security downgrade applied without consent, and the new warning covers the affected case the moment the page is opened.

  • The console could die on a fresh install depending on which page you opened first. Thirty three admin pages read /opt/hermes/keys/hermes.key, the AES-256 key used to encrypt credentials at rest, and exactly one page created it: the dashboard, which self-heals it on first visit. Reaching any other consumer first produced source file [/opt/hermes/keys/hermes.key] is not a file and an unusable page. Console Settings is one of the thirty three, and is a plausible first click on a new install. This is the same defect ed9b9013 (#179) fixed in May for the dashboard alone, resurfacing elsewhere because that fix covered the reported page rather than the class. The installer now generates the key alongside every other secret, so no page depends on visit order. Byte identical to what the CFML produced, and guarded so an existing key is never overwritten, since replacing it would orphan every credential already encrypted with it. The dashboard self-heal stays as a fallback for a deleted file or a partial restore.

  • Virtual Recipients claimed to bypass all content checking. It never did. The page carried a callout stating mail through a virtual recipient was delivered "while bypassing ALL content checking (spam, virus, banned files)", and the admin documentation had a section explaining the mechanism behind it. Neither described this system. content_filter is set globally in main.cf with no per-recipient exception, and the only bypass lane, the BYPASSALLCHECKS policy bank on :10030, is reachable solely through a FILTER action keyed on the sender. The documentation additionally had the pipeline backwards, stating that Postfix rewrites the recipient before Amavis sees it: receive_override_options = no_address_mappings defers expansion until the :10026 reinjection listener, so what Amavis filters is the original virtual address. And a domain-scoped policy always applies anyway, because adding a domain seeds an @domain row in recipients carrying the default policy. The wording traced back to build-220203 and had survived every rewrite since. An operator reading it would reasonably have avoided virtual recipients for anything sensitive, or assumed a hole that was not there.

  • Pickers looked like plain text inputs until you guessed to type in them (#310). Reported from testing, with the TLS certificate field named as the example. Two families of the same complaint, fixed separately.

    Destination pickers. Clicking Delivers To opened the list on the Add Alias modal but did nothing on the other three, which are the same control. On Virtual Recipients the options are fetched remotely and the loader returned early on an empty query, so focus fetched nothing; it now fetches on focus, with the shared endpoint ordered and capped at 50 rows since an empty search would otherwise match every relay recipient. On both Edit modals the destination chips are added programmatically, which left a query behind in the control so the next focus offered a filtered list; the query is now reset after populating. The Virtual Recipients edit modal was additionally calling clearOptions(), discarding every option the search had already fetched.

    Certificate and timezone pickers. The Console Certificate, SMTP TLS Certificate, Dovecot Certificate and Timezone fields were plain text inputs with a jQuery UI autocomplete bound on keydown, so the widget did not exist until the first keystroke and that keystroke was spent constructing it rather than searching. They render as TomSelect controls now, with a chevron, and open on click. The four near-identical copies of the handler are replaced by one shared include, inc/remote_picker_js.cfm, driven by data-target-* attributes declaring which detail field each response key fills, because four copies of the same forty lines is how they drifted apart in the first place. The certificate id the server actually reads is still written by the same two-request endpoint contract, unchanged.

  • Selecting a timezone threw a JavaScript exception every time. The handler derived an index by splitting the field id on _, but that field is called timezone with no underscore, so the index was undefined and it wrote into timezoneid_undefined, an element that has never existed on that page. Saving appeared to work only because the visible field was set on the line before the throw. Found while converting the picker above; the element it wanted is gone entirely, since edit_system_settings.cfm validates the timezone by name.

  • Deleting a certificate always failed, after deleting it. delete_system_certificate.cfm ran two statements: it removed the row from system_certificates, then removed the matching SAN rows from mailbox_domains_sans. No such table has ever existed in this schema, in the baseline or in any release's schema updates, so the second statement threw every time. Because it threw only after the first had committed, the certificate really was deleted, its acme files really were removed, and the admin was shown a Lucee stack trace instead of a success message, with any SAN rows for it orphaned. The real table is mailbox_sans and its foreign key is certificate, which is what the Ofelia-scheduled acme_validate_ip.cfm has always used, so the table and the column were both wrong. The two statements also now run child before parent, so a future failure leaves the certificate intact and the operation retryable rather than half applied. Because every certificate deleted on every install to date stranded its SAN rows this way, the schema update for this release also removes SAN rows whose certificate no longer exists; rows not yet attached to a certificate are a legitimate state and are left alone.

  • A missing per-domain certificate map stopped inbound mail. tls_server_sni_maps was enabled from a row count taken before the map was built, while the map itself was only written for certificates whose files were actually on disk and deleted otherwise. When the two disagreed Postfix was pointed at a map that did not exist, every TLS handshake offering SNI failed with SSL_accept error, and inbound delivery stopped. It failed closed and announced itself only as a log warning. The directive is now decided by whether sni_maps.db exists, which is the file Postfix actually opens, so the two cannot diverge; being wrong now leaves SNI disabled and mail flowing on the default certificate instead of refusing mail. A map left on disk from an earlier run is deleted before that test rather than after, so the last validated SAN going away cannot leave a leftover file re-enabling the directive for names nothing validates. The reason a certificate was skipped is logged rather than discarded, which it had been.

    Three situations produced the mismatch, all observed: a deleted certificate, one still Pending because it was requested but never issued, and one imported without its chain so no bundle existed. The first is the certificate-delete bug fixed above, and it is worse than it looks, because system_certificates is InnoDB and its auto-increment counter is recalculated as MAX(id)+1 after a restart, so re-issuing can hand the replacement the same id the deleted one had. The stale rows then point at a valid id again, which is why the SQL cleanup cannot reach them: they are stale by history, not by foreign key. A phase script reconciles them against the filesystem instead, clearing the validated flag rather than deleting the rows so removing a dangling directive from the live Postfix config with a single postconf -X rather than regenerating main.cf unattended. The stale rows are deleted rather than merely marked unvalidated, because mailbox_sans is derived state: sync_mailbox_sans.cfm rebuilds it from additional_sans, which is where the operator's intent actually lives, so a row whose certificate is gone has nothing left to say.

  • A SAN could be marked validated against a certificate that did not contain it, which then blocked its certificate from ever being requested. acme_validate_ip.cfm hashes the SAN names and stores that hash per certificate; on a later run, a matching hash was taken as proof the certificate already covered them. It proves only that the requested set has not changed, and says nothing about the certificate's contents. A mailbox domain whose mailbox_certificate was the bootstrap certificate, whose SANs are localhost and hermes-bootstrap.local, ended up with two unrelated names marked "verified against existing certificate" against it. Coverage is now read from the certificate's actual SAN list, accepting a wildcard one label up, and the check fails open so an unreadable certificate changes nothing.

    Compounding it, the hash was written before the certificate request was attempted, so a request that failed still left the hash on the record and every later run read it back as proof the work was done. One failed attempt made the failure permanent: the certificate stayed Pending, its SANs stayed marked validated, and nothing ever retried. The hash is now written only on success. The upgrade also clears the stored hash for any certificate found not to cover its SANs, without which the cleanup would be undone by the next scheduled run.

  • Dovecot served an imported certificate without its chain, so IMAP and POP clients could not verify it while the console looked perfectly fine. Importing a third-party certificate writes four files: the leaf as _hermes.pem, the CA chain as _hermes.chain.pem, the two concatenated as _hermes.bundle.pem, and the key. Nginx has always read the bundle; Dovecot read the bare leaf. A client then had no path from the leaf to a trust anchor and refused the connection, which browsers hide by fetching the missing intermediate over AIA and mail clients generally do not. Nothing was wrong with the import: it verifies the leaf against the supplied chain before accepting it, so a bad chain would have been rejected outright. Dovecot now reads the bundle, falling back to the leaf only when no bundle exists. Certificates issued through ACME were never affected, since fullchain.pem already contains the intermediates.

  • SMTP TLS served the leaf without its chain, on 25, 465 and 587. The same defect as the Dovecot one above and found the same way: openssl s_client -showcerts against 465 returned exactly one certificate. smtpd_tls_cert_file was pointed at the bare leaf, _hermes.pem for an imported certificate and cert.pem for an ACME one, with the intermediates passed separately as smtpd_tls_CAfile. That setting exists to verify remote client certificates and is not a dependable route for the server's own chain; Postfix sends what is in smtpd_tls_cert_file. Any peer without the intermediate already cached could not build a path to a trust anchor, which for a sending MTA can mean refusing TLS or falling back to plaintext. Both branches now point at the chain-bearing file, _hermes.bundle.pem and fullchain.pem, matching Nginx and Dovecot. smtpd_tls_CAfile is unchanged and still correct for its actual purpose.

    The three services differ in a way that decides who needs repairing. Nginx and Dovecot recompute their certificate path on every config generation, so both correct themselves as the upgrade runs. Postfix stores its path in the parameters table and generate_postfix_configuration.cfm only emits what is stored, so the stale leaf path would have survived every regeneration until somebody happened to re-save that page. A phase script rewrites it instead, and only where the chain file is genuinely present: a leaf without its chain is degraded, whereas naming a file that does not exist stops Postfix serving TLS altogether, and the repair must not turn one into the other.

  • Binding SMTP TLS to a certificate that was never issued stopped mail being accepted. The selection checked that the certificate's database row existed, not that its files did, and a record stuck in Pending appears in the picker like any other. Nginx and Dovecot fall back to the bootstrap certificate when their files are missing; Postfix has no fallback for smtpd_tls_cert_file and simply fails TLS, which on port 25 means inbound delivery stops. The save is now refused with the missing path named, before anything is written, so the previously bound certificate stays in use. Failing by declining to save is recoverable; failing by pointing Postfix at a file that is not there is silent until mail stops. The upgrade also reports, without changing, any of the console, SMTP or mail certificate bindings already pointing at absent files: which certificate to use is an operator decision, not something to reassign automatically.

  • Mailbox certificates could never be issued on a fresh install. The automated SAN request omitted certbot's --agree-tos, --non-interactive and contact-address flags, which the manual single-domain path has always passed. certbot registers an ACME account on its first run against an endpoint; without those flags it stopped to ask whether you accept the Terms of Service, found no terminal to ask through, and exited on EOFError before attempting a challenge. The automated path could therefore only ever succeed on a gateway where someone had already requested a certificate by hand. Adding a mailbox domain is normally the first ACME action a new install performs, so on a fresh install it failed every time. admin_email ships as a placeholder and only the console ever writes it, so the address is passed only when it is a real one; otherwise the account is registered without a contact address rather than bound to one that does not exist, since an ACME account's address is awkward to correct afterwards.

  • Every ACME failure was invisible, and one unverifiable name blocked issuance for the whole gateway. Two faults compounding. certbot writes its failures to stderr, and the request ran the container directly with nowhere for stderr to go, so any failure surfaced as a Lucee exception rather than as output: the handler mailed the administrator and aborted, leaving nothing on the record and nothing on the page. A certbot success was the only outcome the code could observe. Separately, all three failure paths in the SAN verification loop aborted the entire request instead of the single row, and the issuance loop runs after that loop, so one name that could not be verified stopped certificate issuance for every certificate on the gateway, silently, because the scheduler discards output. Failures now run through a temporary script with stderr folded in, are recorded against the SAN row, and appear on the Certificates page; a verification failure skips its row and lets the run continue, without clearing a validation that previously succeeded. Recording them also required binding the message rather than interpolating it, since certbot output routinely contains an apostrophe and the column holds 255 characters.

Removed

  • Two unreachable duplicate pages, admin/2/add_virtual_recipients.cfm and admin/2/edit_virtual_recipient.cfm. Both were standalone full-page copies of flows that live as cards and modals on view_virtual_recipients.cfm, and nothing in the repository linked to either. Both had also drifted: the add page still validated its destination field as a single email address, the bug fixed on the live page earlier in this release, so multi-destination had never worked there, and the edit page still used the superseded one-row-per-destination model. Dead pages that duplicate a live flow are worse than absent ones, because they get found later and mistaken for the real thing.
  • Two dead scheduler files, schedule/test.cfm and schedule/acme_validate_ip_copy.cfm. Neither was referenced anywhere or run by Ofelia, which schedules acme_validate_ip.cfm, and both queried the same non-existent mailbox_domains_sans table as the certificate-delete bug above, so either would have thrown the moment anything reached it.

[v260814] — 2026-08-14

Fixed

  • Nextcloud got one install attempt and could never recover from failing it (#313). The nextcloud:apache entrypoint runs occ maintenance:install exactly once, on first boot. If MariaDB had not finished creating the Nextcloud database user by then, that attempt failed and left a partial config.php carrying dbname and dbhost but no dbuser, no dbpassword and no installed key. Every later boot read that file, concluded Nextcloud was already configured, and never retried, so the container sat up and healthy and uninstalled indefinitely. Not a timeout: the installer's advice to re-run --init-db could not work, because the re-run reached the same already-configured short circuit, and widening the two-minute poll would not help because nothing was still running to wait for. The installer now drives the install itself when it finds installed: false, preserving the partial config.php under a date-stamped name, confirming the database user can open the database, then running maintenance:install with the credentials it already generated and applying NEXTCLOUD_TRUSTED_DOMAINS afterwards, which the entrypoint would otherwise have done and whose absence is the #292 untrusted-domain defect. The poll is kept as the fast path, so a healthy install is unchanged. Present since the Docker edition's root commit.
  • A wrong host clock took DNS down and the installer blamed the forwarders (#314). Unbound validates DNSSEC locally, against the host clock, on every answer it receives; forwarding does not delegate that to the upstream resolver. With the clock skewed, every signature reads as invalid, and because the failing signature is on the root zone, Unbound cannot establish trust for anything beneath it, so all name resolution stops rather than only signed zones. Unbound then caches the invalid key, so correcting the clock alone does not restore service, and NTP hostnames no longer resolve so the clock cannot fix itself. The DNS preflight reported this as a forwarder problem, which is the one thing it is not. It now inspects Unbound's log for signature before inception, signature expired and key for validation ... marked as invalid, and separately checks whether the host clock is behind the commit being installed, which proves the clock is wrong without needing any network. Diagnosis only: no new check gates a healthy install and nothing new runs unless DNS has already failed. Present since the Docker edition's root commit.
  • A failed disk probe could take down the whole admin console. The dashboard's five storage rings each ran a probe inside a cftry whose cfcatch rendered an error page and aborted, so a missing probe script or an unmounted path killed index.cfm outright instead of losing one ring. Reachable on any gateway installed before the archive tier was added in #260, since those have no disk_space_usage_archive.sh. The catch now leaves the ring's default in place and returns. Completes the fix begun in v260807, where the probes gained defaults so an empty result could not leave the variable undefined.
  • The release workflow could not run its own drift check. scripts/check_ofelia_seed_drift.sh was committed non-executable, so the release-images workflow failed at "Verify generated artifacts" with permission denied and skipped the retag job. Same class as the pre-push hook in #296: a script that only ever runs from a fresh checkout, where the git file mode is the only mode that counts. No effect on v260807, whose images were promoted by hand from the tested artifacts.

[v260807] — 2026-08-13

Fixed

  • OpenDMARC rejected a domain's own authenticated users (#300). submission and smtps did not override smtpd_milters, so they inherited the global chain including OpenDMARC, which then evaluated authenticated outbound mail as though it were inbound. That fails by construction: the client address is never in the sending domain's SPF record, and the OpenDKIM instance on that path signs rather than verifies, so there is no DKIM result to consume either. With the shipped RejectFailures true, a domain publishing p=reject had its own users rejected at DATA with 550 5.7.1 rejected by DMARC policy. Both listeners now carry an explicit chain that keeps OpenDKIM and body_milter and drops OpenDMARC, using the same per-service override pattern :10026 and :10027 already use. Port 25 is unchanged, which is where DMARC belongs. Invisible before this release only because submission itself was never bound (#292).

  • The URLhaus malware feed silently stopped updating (#302). malware_feeds_config seeded urlhaus with max_size = 2MB and the feed has grown past 3MB, so fangfrisch refused the download on every run while still exiting 0. Ofelia recorded the job as successful and nothing surfaced, so the ClamAV third-party URLhaus signatures were never refreshed. Raised to 10MB in the baseline, the shipped configuration and an idempotent upgrade statement guarded on the old value, so a tuned setting is preserved.

  • Ofelia could never deliver its failure notifications (#303). hermes_ofelia was the only service with no networks: block, so Compose attached it to an auto-created default bridge outside the Docker subnet. Its [global] smtp-host could not resolve hermes_postfix_dkim, :10026 would have rejected it on mynetworks regardless, and its configured resolver was unreachable. Jobs ran correctly because they dispatch over the docker socket, so the only broken path was alerting, which mail-only-on-error means is exercised only when something has already gone wrong. Given a static address on hermes_net_ext.

  • Adding a Local DNS Record took DNS down for the whole gateway (#304). generate_unbound_local_conf.cfm emitted bare local-zone: and local-data: lines, but unbound.conf includes forward.conf first, and when forwarding is enabled (the default install mode) its forward-zone: clause closes the server: clause. The generated entries then landed inside forward-zone:, where neither is valid, and unbound refused to start and crash-looped. Every container resolves through it, and the console needs DNS, so the admin UI could not undo it. The file now declares its own server: clause. Two further defects on the same page: the DNS lookup tool rejected underscore labels, excluding _dmarc, _domainkey and the _submission._tcp SRV records this product instructs admins to publish; and the generator could not express any TXT value containing a space or semicolon, so SPF, DKIM and DMARC records were silently truncated at the first semicolon. Hostname and value are now stripped of CR/LF before rendering, since both are admin-supplied and land in a config file.

  • CipherMail and OpenLDAP ran on UTC and stamped it into mail (#305). Both images are built FROM ubuntu:24.04 with --no-install-recommends, which never pulls tzdata. Without a zone database, glibc cannot resolve TZ=America/New_York as a zone path, falls back to parsing it as a POSIX string, takes America as the abbreviation and defaults the offset to zero. The result was UTC labelled America, four hours from every other container, written into the Received: header of every message CipherMail handled and into the slapd events that reach the Syslog database. tzdata is now installed explicitly in both, and hermes_ldap, which had no TZ at all, now receives one.

  • Amavis trusted a private range that was not the Docker subnet (#297). The shipped mynetworks file was a stale snapshot carrying a development LAN, so a fresh install granted originating treatment to an unrelated private range until an administrator saved a Postfix settings page and the file was re-rendered from the database. It now matches the seeded rows.

  • Four administrative pages rendered a raw error instead of the error page. create_new.cfm, deletedomain.cfm, edit_smtp_tls_settings.cfm and send_smime_certificate.cfm included ./inc/error.cfm from inside inc/, resolving to inc/inc/error.cfm, which does not exist.

  • system_rehost.sh pointed the rehosted console at an IP instead of its hostname (#295). CONSOLE_HOST defaulted to the new IP when --to-console was not given, even though the script had already detected the FQDN and was using it as trusted_domains[0]. That value is written to parameters2.console.host, which is the console's identity: every generator reads it for the Nginx server_name, the auth.conf portal URL, Authelia's session cookie domain, Nextcloud's trusted domains, and the hostname autoconfig/autodiscover hand to mail clients as their IMAP and SMTP server. So a rehost broke Nextcloud OIDC (discovery fetched from the IP while Authelia's issuer is the FQDN, against a certificate covering neither), handed mail clients an IP no certificate covers, and left changing the console host as the operator's first task after a rehost. It now adopts the detected FQDN only when DNS confirms that name points at the new host, and otherwise falls back to the IP exactly as before, saying why. DNS decides rather than a warning, because migrate_legacy_to_docker.sh and system_restore.sh both invoke the script with --force and without --to-console, the migration script never sets HERMES_HOSTNAME, and .env.template ships a placeholder: trusting the hostname blindly there would have moved the console from "reachable at the IP" to unreachable. An explicit --to-console is always honoured and only warned about.

  • Changing the console host locked the administrator out of the console (#294). This affected every new install, because install_hermes_docker.sh sets console.host to the host IP on purpose (no DNS yet) and expects the administrator to change it afterwards, so the broken operation was step one of every deployment. Saving a new console address restarted Authelia, whose session cookie is scoped to a single domain, so it immediately had no session configuration for the address the browser was still on. The save then handed the browser a redirect to preload_restart_nginx.cfm to perform the Nginx restart, but that page and the inc/restart_nginx_post.cfm its JavaScript calls both live under /admin/2/ and are auth-protected. Neither could load, so the one thing that triggers the Nginx restart sat behind the auth flow the hostname change had just invalidated. Nginx kept serving the previous portal URL indefinitely, the browser was redirected to the old address with "unable to determine user state", and the only way back in was restarting hermes_nginx by hand from the Docker host. A host change now fires the restart from the save request itself, the last one that is still authenticated, and renders a page that waits out the restart before moving the operator to the new address, so their next sign-in happens after Nginx is back rather than during the restart. Certificate, HSTS, OCSP and DH-parameter changes leave the address and the session intact and keep the existing restart path.

  • DNSBL lookups could be silently dead, and the diagnostics said they were healthy (#293). Found while verifying #292 on a test gateway whose Unbound forwards to a LAN router: ordinary DNS resolved normally while zen.spamhaus.org returned nothing at all for a test point that is guaranteed listed. Reputation scoring, both postscreen weights and the SpamAssassin RCVD_IN_* rules, contributes nothing in that state, and allowlists such as list.dnswl.org stop applying too.

    • Four block lists counted refusals as listings. bl.spamcop.net, bl.suomispam.net, bl.spameatingmonkey.net and backscatter.spameatingmonkey.net shipped with no =returncode filter, so postscreen counted any answer in 127.0.0.0/8 as a listing, including the 127.255.255.0/24 codes lists use for "refused" and "over quota". Each carries a weight of 2 against a threshold of 3, so two of them answering with error codes reject legitimate mail. All four now filter on 127.0.0.[2..11], and existing installs are corrected with their weights preserved.
    • The RBL Test button never made a DNS query at all, and reported every list as healthy. It shelled out to docker exec hermes_postfix_dkim dig, but the postfix-dkim image has never contained dnsutils, so every probe failed with an OCI "executable file not found" error. Neither probe captured stderr, so Lucee folded docker's error message into the output variable, and the SOA fallback only tested that the output was non-empty. An error message is not empty, so it read as a successful SOA lookup and every entry displayed green "Zone active (SOA)". The probe now runs dig locally in hermes_commandbox, which has dnsutils and is pointed at hermes_unbound by compose, so it goes through the same resolver postscreen uses. Every cfexecute captures stderr separately, no probe treats stderr as data, the SOA response is validated as an actual SOA record by its five timers rather than by being non-empty, and a missing dig is reported as its own verdict instead of being laundered into a DNS result. The same flaw was present in the new install-time DNSBL preflight and is fixed there too.
    • System > RBL Configuration had no Apply button. inc/rbl_apply_settings.cfm had existed since the page was written but nothing ever invoked it, so the only way to push a block list change into main.cf was to edit an entry and save it without changing anything. It is now wired to an Apply button, which confirms first because it regenerates the Postfix configuration from the database and reloads Postfix. The success message it sets also had no handler on the page, so even a successful apply would have reported nothing.
    • The RBL Test button also mis-read the answers it did get. It accepted any answer beginning 12 as success, so 127.255.255.254 displayed as live data, and its SOA fallback reported green for a zone that returned no data at all. It now runs a two-point probe and reports data returned, zone present but silent, or refused and wildcarded, which a single probe cannot distinguish from a healthy list. The wildcard test compares the two points as sets and requires them to be identical, rather than treating any answer at 1.0.0.127. as a wildcard: reputation services return a verdict for every query, not only for listed senders, so hostkarma.junkemailfilter.com answers both points with different code sets and an "any answer" test marked a healthy service as broken. What makes a wildcarded or hijacked zone dangerous is that it returns the same answer to everything, so every connecting IP scores identically; a zone that discriminates is doing its job. A zone that answers the never-listed point while the always-listed point stays silent is reported as inverted rather than as either healthy or wildcarded.
    • The Postfix template and the database shipped different block lists. A fresh install filtered on the 27 entries hardcoded in main.cf.HERMES, including six retired dnsbl.sorbs.net entries at weights up to 8, while the admin console showed the 17 seeded in the database. The template only converged on the database when someone saved a settings page. Both lists now match exactly. This is the same root pattern as #292: generated config written before or independently of the database, with the file winning until an admin happens to save.
    • b.barracudacentral.org is no longer seeded. It answers only once the querying IP is registered with Barracuda, so on a stock gateway it returned nothing while carrying a weight of 7, above the rejection threshold of 3 on its own. Existing entries are left in place, since an operator may have registered.
    • The installer now tests block list reachability. Forward mode remains the install default for bootstrap reliability, and switching to recursive remains the operator's call, but the cost of forward mode was invisible: ordinary DNS resolving normally says nothing about whether reputation answers survive the trip. Routers commonly strip 127.0.0.0/8 replies as DNS rebinding protection, returning NOERROR with an empty ANSWER and empty AUTHORITY, and public resolvers are refused by the lists outright. The new preflight distinguishes reachable, refused, stripped, synthesised, and off-zone answers, and reports the remedy. It warns rather than aborting, since mail still flows in that state, it just filters badly. The DNS forwarder prompt now states the consequence instead of framing recursive mode as a privacy preference.
  • Razor could not work even once registration landed in the right place (#292 follow-up). -home=/etc/razor corrected where razor-admin writes, but docker exec runs as root, so the identity and server discovery files were created root-owned while SpamAssassin runs as amavis. Razor writes its home directory at scan time, refreshing servers.*.lst and the per-server configs, so root ownership left it mute. The ownership pass in the installer, the post-upgrade repair, and the Initialize Razor handler all now include /etc/razor, and registering removes the stale /root/.razor identity that earlier attempts left behind.

  • First-run provisioning defects on fresh installs (#292). Reported by an outside user on a clean install. Each has been present since the Docker edition shipped and is invisible on any gateway where an administrator saved the relevant settings page, because the application layer silently corrects the installer's output.

    • Every message received a 5 point spam-score discount. SpamAssassin's RCVD_IN_VALIDITY_CERTIFIED and RCVD_IN_VALIDITY_SAFE rules are allowlists carrying -3 and -2. Validity refuses queries from unregistered resolvers and answers 127.255.255.255, which SpamAssassin matches as a hit, so both fired on every message. Hermes resolves through its own recursive Unbound instance, so the query always originates from an unregistered address and no stock install escaped it. Configured thresholds therefore behaved five points higher than they read. All three Validity rules now score 0; operators registered with Validity can restore them through Score Overrides.
    • SMTP submission was never enabled. master.cf shipped with the submission (587) and smtps (465) listeners commented out in every variant, while Docker published both ports and the mailbox domain page advertised them over SRV. On a mailbox or hybrid install no user could send mail from any client. Receiving and webmail were unaffected, because Nextcloud Mail reaches Postfix on port 25 over the Docker network, which is why the gap went unnoticed. Both listeners are now enabled with Dovecot SASL and reject_sender_login_mismatch.
    • The update orchestrator restarted only hermes_commandbox, so a release that changed master.cf or main.cf without also rebuilding images would not have applied it: Postfix reads both at startup, and Compose recreates a container when its definition changes, not when a bind-mounted file's contents change. Phase 4 now restarts hermes_postfix_dkim as well.
    • Amavis quarantine subdirectories (clean, virus, spam, banned, bad_header) were never created, so Amavis could reject mail outright. The pre-Docker installer created all five and the Docker rewrite dropped the step, along with the ownership pass that let amavis write the quarantine tier and the Bayes corpus.
    • The installer enabled mailbox encryption against empty placeholder keys regardless of the database setting, which defaults to off, breaking IMAP and SMTP authentication.
    • SpamAssassin's local.cf was copied rather than rendered, so nine placeholders reached SpamAssassin verbatim. It discarded them and fell back to built-in defaults, which enable the collaborative network checks and automatic Bayes learning.
    • NEXTCLOUD_TRUSTED_DOMAINS was written comma-separated where a space-separated list is expected, so Nextcloud rejected the console address the installer configures before DNS exists.
    • Enabling Nextcloud on an existing mailbox never provisioned the Nextcloud Mail profile, and the failure was silently discarded. It was built from the account's login password, which Dovecot cannot accept: IMAP and SMTP authenticate only against app passwords.

Changed

  • system_update_docker.sh --remote now selects code, images and tag from one source (#298). Previously it chose only where the code came from, while images resolved from IMAGE_REGISTRY and HERMES_DOCKER_IMG_VERSION in .env, which the orchestrator never read or wrote. The upgrade path could therefore never advance the image version, so image-level fixes could not reach an existing install, and testing a release candidate meant hand-editing .env first. The remote now maps to a registry and the resolved tag is written before docker compose pull, guarded by a docker manifest inspect probe so the per-release tag is pinned only when the registry actually has it. An upgrade that works today cannot start failing, and the production path begins pinning automatically once per-release tags exist (#289). --image-registry= and --image-tag= override the mapping; .env is backed up before either key changes, and --skip-compose leaves it untouched.

  • Collaborative spam checks now ship disabled (#292). Razor, Pyzor and DCC each transmit a digest of every scanned message to a third-party network, so new installs leave that decision to the operator. Existing installs keep their current settings. Razor had never been registered on any install and so returned no result regardless.

  • Bayes ships untrained, and automatic learning is off by default (#292). Hermes had been shipping a pre-trained corpus built from unrelated mail; upgrading clears it once so each gateway learns from its own traffic. Auto-learning trains on the rule set's own verdicts, reinforcing its mistakes as readily as its successes.

  • DCC is no longer in the published mail filter image (#292). Its licence is free only to organisations that do not sell filtering devices or services except to their own users, and does not permit redistributing binaries. Most self-hosted operators qualify; Deeztek does not. docker-compose.yml carries a commented build block for operators who want it, fetching DCC from Rhyolite directly.

Documentation

  • The console FQDN and a real certificate are now documented as a hard prerequisite (#299). Nextcloud login cannot work on a fresh install, because OIDC requires the Nextcloud container to make a server-side HTTPS call back to the console address, and the bootstrap certificate is self-signed with the common name localhost. Importing it into the container's trust store does not help, since trust and name are separate checks. Get Started gains a Step 2 covering both parts, and the mail-client half: the Console Certificate and the SMTP TLS certificate are separate bindings, and setting only the first leaves the console perfect in a browser while every mail client is offered CN=localhost, because autoconfig hands clients the console host as their SMTP server. The Self-signed cert dashboard nudge previously described this as producing only a client TLS warning, which understated it.
  • The CipherMail console's stock admin / admin credentials are now stated explicitly (#306), rather than referred to obliquely as "its default administrator password".
  • master.cf has exactly one copy in the repository. A duplicate under conf_files/ drifted for three months onto the pre-#232 configuration that caused the :10026 outage, because commit 9e90bc9a fixed the real file and not the copy, and nothing read the copy so nothing detected it. Both it and an equally stale master.cf.postscreen are removed, and docs/general/email-flow.md records why a second copy must not be reintroduced.

[v260628] — 2026-06-28

Changed

  • Let's Encrypt / ACME certificate management is now available in all editions (#282). The console-certificate Request ACME Certificate button and the mailbox-domain Auto-managed (Let's Encrypt) SAN certificate mode — automated issuance, SAN validation, and auto-renewal — are no longer restricted to Pro Edition. Existing Pro installations are unaffected.

v260612 — 2026-06-20

Initial public Docker release. The first tagged public release of Hermes SEG as a Docker product, replacing the legacy bare-metal Ubuntu installer with a 19-container Docker Compose stack. Early-adopter release — feature-complete and validated on our DEV/Test infrastructure; run your own acceptance tests in parallel before cutting a production gateway over. Fresh-install only (legacy-to-Docker migration tooling is still skeletal).

Added

  • Full Docker stack: 19 containers via a single docker compose up -d; no host-level mail services.
  • Link Guard (Pro Edition) — time-of-click "safe links": inbound links are rewritten through a Hermes redirect and reputation-checked at the moment the user clicks. Dedicated hermes_linkguard container; layered verdicts (heuristics + URLhaus / OpenPhish feeds + optional Google Safe Browsing / VirusTotal), open-redirect detection, admin-configurable per-tier actions, and outbound link restoration.
  • Five-tier storage topology (Config / Data / Archive / Vmail / Nextcloud), each independently mountable so each tier can live on the right kind of disk.
  • Single-command update orchestrator (scripts/system_update_docker.sh): a 5-phase pipeline that auto-resolves the latest tag via the GitHub Releases API and runs occ upgrade on Nextcloud version bumps.
  • Authelia SSO with unified MFA (TOTP / WebAuthn / Duo Push) across the admin console, user portal, and Nextcloud (via OIDC).
  • Nextcloud integrated: webmail, file sync, calendars (CalDAV), and contacts (CardDAV) — pre-provisioned on first OIDC login.
  • Docker-aware backup, cross-host disaster recovery, and re-host tooling (hot backups, storage-topology remap, credential reconciliation).
  • GitHub-based release pipeline: container images published to ghcr.io/deeztek/hermes-<service>:<tag>.

Full release notes: updates/v260612/README.md.

[v260609] — 2026-06-11

Backup, disaster-recovery, and upgrade-tooling release on top of the v260119 baseline.

Added

  • Docker-aware backup/restore (system_backup.sh, system_restore.sh) with hot (zero-downtime) backups, scoped storage tiers, and a directory-style format (#219).
  • Cross-host disaster recovery plus system_rehost.sh, with storage-topology auto-remap and a version-match gate (#220).
  • Update orchestrator: pre-container pre-scripts/ hook + self-re-exec (#221).
  • Authelia DB credentials relocated keys/ → creds/ (migrated automatically on upgrade).

Fixed

  • #266 — repo .sh scripts shipped non-executable, breaking CFML panels that shell out (e.g. the dashboard disk-usage panel); now chmod +x at build time and re-applied on install/restore.
  • #267 — hermes_smoke_test.sh hardcoded build_no=v260119, so the post-install smoke test falsely failed on every later release; now version-agnostic.

Upgrade note: the v260119 → v260609 hop requires a one-time bridge (updates/v260609/upgrade-to-v260609.sh), not the normal updater. See RELEASE-NOTES.md.

[v260119] — 2026-05-30

The first Docker-era release (labeled for January 2026, actually tagged 2026-05-30 after ~6 months of development). Established the Dockerized rewrite as a coherent shipping product and completed the docs/admin/ operator-documentation buildout.

Legacy releases (pre-Docker)

Before the Docker era, Hermes shipped as a bare-metal Ubuntu install under the build-YYMMDD tag line: build-240815 (Aug 2024) back through build-211207 and build-211019 (2021). This repository was created 2017-12-21 and the project has been public on GitHub since. Those releases predate the Dockerized rewrite and the move to GitHub Releases, and are not itemized here.

The full pre-Docker history, 186 commits spanning 2017 to 2025, is preserved on the legacy branch, along with its seven build-* releases.

legacy is a separate codebase from main, not an earlier stage of it. The Docker Edition is a ground-up rewrite of the bare-metal monolith rather than a continuation, which is why main carries none of those commits. See Project history in the README.