github NeySlim/ultimate-ca-manager v2.231-rc1

latest release: v2.231
pre-release5 hours ago
📜 Recent release history (last 2 versions)

[2.230] - 2026-09-15

Added

  • Certificate exports in PEM, PKCS#7, PKCS#12/PFX and JKS now omit the self-signed Root CA by default while retaining intermediate certificates, with an explicit Include Root CA option for packaging and import use cases. The option is also honored by account and user-certificate exports; deployment fullchains retain their previous complete-chain behavior. Issuer reconstruction now normalizes AKI/SKI identifiers and verifies signatures, so homonymous CAs and self-issued rollover certificates cannot produce the wrong chain

Security

  • A bundle protected by a one-character password was accepted. The export dialog has required eight since it was written, so the rule only held for someone using the screen; the authority route next to it required four, and was the only one with an upper bound at all. One rule now applies to all six routes and is published so the screen reads it rather than repeating it.
  • Filtering a certificate listing by a status spelled with a capital letter returned every certificate, revoked and valid alike: no branch matched, the condition list stayed empty and the filter was skipped. The same parameter on the user certificate listing returned nothing while reporting an unfiltered total. A status that is not recognised is now refused rather than answered with something else.
  • Importing users from a file silently rewrote an unknown role to the least privileged one, so accounts created for people whose job is to read the audit trail could not read it. The single-user route refuses an unknown role; the import now does too.
  • The machine-readable API description the server serves returned an error, and a second description checked in beside it had frozen two years of releases ago: it named 43 routes of about 370, seven of which no longer exist, and contradicted the code on roles, key algorithms and revocation reasons. The served one works again and is versioned from the release; the frozen one is removed rather than left to be handed to a client generator.
  • A button showing its loading state was not actually disabled: the property that blocks it was set and then overwritten by the caller's own properties, so it only looked blocked. It was still reachable by keyboard and by pressing Enter in the form, which meant a second import, a second deployment binding or a second key export on a double press.
  • Copying a value announced success whether or not anything was copied. The browser's clipboard is unavailable over plain HTTP, which an on-premises installation commonly uses, so the confirmation appeared over an empty clipboard; in one place the opposite happened and a freshly rotated secret, shown once, was reported as failed after it had been saved.
  • A certificate's name was used verbatim to name the file an export is downloaded as, including exports that carry a private key. It is now sanitised.
  • A warning about expiring certificates could appear over the login screen after signing out, and an error from a closed window after it had closed.
  • Two certificate authorities naming each other as parent made the server walk between them for ever. The parent link is a plain text column with nothing to forbid a loop, and ten places followed it, eight of them with no guard at all: exporting such an authority filled the disk with a temporary file, and the worker configuration read at startup could not finish, so the service would not come up. One bounded walk is used everywhere now.
  • A Common Name was treated as a host name whenever it contained a dot, so Example, Inc. was issued as a DNS name and an accented name answered a server error. The rule that decides whether a Common Name grants a DNS identity now requires it to look like a host name.
  • Six listings capped the number of rows from above and not from below, so a negative limit returned everything on SQLite and an error on PostgreSQL. One of them exports the audit trail.
  • A validity of true was accepted as one day rather than refused, since a boolean counts as a number. The enrolment paths already refused it.
  • The legacy OPNsense importer wrote private keys to the database unencrypted where the live route encrypts them.
  • A DNS provider's credentials came back in the response to a connection test, and in the application log. Some providers put their key and token in the URL, and a network error carries that URL into its message, which the test route returned verbatim; only 5 providers of 50 masked anything, and none masked it on the error-response path. Every provider now redacts and bounds what it reports, including tokens obtained at runtime.
  • Creating an ACME account accepted any key type without checking it. Asking for EC-P521 quietly produced a P-256 key, an unknown label produced RSA-2048, and an absurd RSA size was generated for real, which is a way to hang the server. The label is now refused unless it is one the installation supports, as the neighbouring settings route already did, and an RSA key above 8192 bits is refused everywhere.
  • A certificate signing request rejected for a weak hash said only that its signature was invalid on the ACME paths, while the enrolment protocols named the hash. The diagnosis is shared now, and what is parsed is bounded.
  • Exporting a user certificate handed over its private key to anyone allowed to see the certificate: the route asked for no private-key permission and included the key by default, so the operator role could take any user's key and skip the approval-gated key recovery flow. Taking somebody else's key now needs that permission; taking your own is unchanged.
  • Audit entries written outside the audit service were never sealed, and the integrity check stepped over an unsealed entry and restarted its chain there, so it reported a valid ledger across every gap. All eight writers now go through one place that seals the entry, and the check reports what it cannot attest.
  • An audit entry commits the session it is written on, so an entry written before the change it describes decided whether that change survived. Twenty-three places had it that way, in deployment, CRL generation, certificate and authority deletion, ACME, webhooks, single sign-on, Microsoft CA and the database reset; a test now holds the order. Three refusals of the same family remain open where the change is staged inside a called function, one of them reachable by a certificate client.
  • A single sign-on login for a disabled account no longer writes to that account on its way to being refused. The stable external identifier is bound once and decides whose account it is, and binding it while the account was off would have handed that account to whoever held the identifier when it was switched back on.
  • Cloud metadata endpoints were reachable through the guard every outbound request goes through: Oracle Cloud's and Scaleway's were missing from the deny-list, and five of the six ways to write an IPv4 address as IPv6 were not read at all. All six forms are now read, including every layout the NAT64 local-use prefix allows, and a reserved byte an attacker controls no longer switches the check off.
  • The two OPNsense import routes checked one address and connected to another. The port came from the request body unexamined, so 443@169.254.169.254 turned the checked host into a user name; the routes also followed redirects and resolved the name a second time at connection. They now check the address they will request, refuse a redirect, and hold the connection to the addresses that were checked.
  • Restoring a backup behaved differently depending on which page asked. The settings page skipped the migration lock and the pending backend switch, accepted a mode it did not honour, and answered an empty archive as a restore. Everything after the archive is read is now shared between the two pages: recording it, asking for the restart, and what is handed back.
  • Deleting a local ACME account left its authorizations and challenges behind on SQLite and failed outright on PostgreSQL: two of the three deletions compared a protocol identifier, which is text, to a numeric key. All three now match on the identifier, and an authorization is found whether it names the account or only its order.
  • A zone served by the built-in ACME server could be bound to an authority that cannot sign: the locally served mapping checked two of the six reasons an authority is unusable. Issuance then refused the order without failing it, so the client retried for ever. Both mapping tables now ask the same question.
  • Testing whether an OAuth2 authorization endpoint is reachable followed redirects and resolved the address a second time at connection, so the cloud-metadata check saw only the address that was typed. The test now connects to the addresses it checked and does not follow redirects; the status reported is the first answer rather than the end of a chain.
  • An administrator who was not the requester could not collect a recovered key, although the rule is "the requester or an administrator". Two functions of the same name take their arguments in opposite orders and this route called one the way the other expects, so the administrator half answered no to everyone.
  • Seven listing routes did not bound the page size they were asked for. Three had no ceiling, so one request for a million rows loaded the whole table; four had a ceiling and no floor, and a negative row limit means no limit at all on SQLite while PostgreSQL refuses the statement. All seven now clamp at both ends, and a test refuses the next route that does not.
  • Deleting a certificate could leave the row behind with its files already gone: the audit entry was written between detaching the references and committing the deletion, and its own rollback put them back after the disk had changed. The deletion is recorded once it has committed.
  • Changing a certificate authority's CRL schedule was announced as done when the audit entry describing it could not be written, because that entry is what committed the change. The same inversion is corrected in CRL generation, delta CRL generation, installing an externally signed CRL, two ACME paths, single sign-on role synchronisation, and a webhook refusal that persisted the address and signing secret it was refusing.
  • Creating or changing a deployment target was announced as done when the audit entry describing it could not be written, and the answer carried an identifier for a target that is not there. Every route of that page now writes the row first; deleting a target, deleting a binding and running a connection test had the same inversion.
  • The two routes that delete a signing request would delete an issued certificate just as readily: same table, same identifier counter, no check that the row was a request. They also ask for a permission the operator role holds while it does not hold the right to delete certificates, so a valid certificate could leave the instance unrevoked. Both now act only on requests.
  • Applying a managed certificate to HTTPS wrote the stored private key to disk without decrypting it, so on an installation with a key-encryption key the server received ciphertext over a backup of its working key and HTTPS did not come back after the restart. Both paths now read the key the same way, and a key that cannot be read refuses the change.
  • Eight routes announced a success for a write the database had refused: safe_commit answers a pair and these tested the pair itself, which is always true, so the error branch could never run. One of them returned a 201 with the one-time HMAC key of a credential that does not exist. A test now refuses the next call site written that way.
  • A restore could hand back an installation's own secrets in the clear. Five columns keep a secret without a property over them, so nothing re-encrypted what the archive carries: the key of an account at an external certificate authority, the SSH key a deployment authenticates with, a SCEP challenge password, an Intune client secret and a webhook signing secret each went into the database readable, on a server that had them encrypted the minute before it restored its own archive. The manifest now records, for every secret that is a plain column, whether the application reads it as it stands or keeps it encrypted and under which key, and the restore puts each one back the way its column holds it. A secret added later that says neither fails the test suite rather than picking a side on its own
  • The private key of the certificate UCM presents to a Microsoft certificate authority was archived as the source had encrypted it, so a restored connection held a key no key of the target could open and the authority could never be reached again. It travels like the two passwords beside it now, decrypted into the archive and encrypted again with the key of the server restoring it
  • Restoring an SSO provider, an SMTP configuration or a DNS provider wrote its credentials to the column underneath the property that encrypts them: the OAuth2 client secret, the LDAP bind password, the mail account password and the DNS API credentials landed in the database readable, so restoring an archive quietly removed the at-rest protection of the very installation that had taken it, and nothing said so. Every secret now goes back through the property the model declares, which re-encrypts it with the key of the server restoring it
  • Several secrets left an installation as the ciphertext of the installation that wrote them. The export decrypted the layer that holds most of them and not the one that holds the rest, so the upstream ACME account key, its EAB credentials, the SCEP challenge password and the deployment SSH key travelled as values only the source could ever open: the archive looked complete, and the restored integration had no usable credential. Both layers are read when the archive is written, and a secret that does not open at the source stops the backup instead of being carried as if it were the secret
  • A backup archive's header was not covered by its authentication tag: only the four magic bytes were, so the announced format version, the backup type, the UCM version, the creation date and the whole key-derivation profile could be rewritten in place without the archive being refused. Archives are now written in a new container version whose entire canonical header is the authenticated data, and any edit to it is reported as a wrong password or an invalid file. Archives in the previous format are still restored, under every bound described below
  • The key-derivation parameters were read from that unauthenticated header and handed to Argon2id or PBKDF2 unchecked, before anything had been authenticated: a file of a few hundred bytes could ask for terabytes of memory or billions of iterations, and take the worker down with it. A restore now accepts only the profiles UCM emits, each within a fixed range (memory, parallelism, time cost, iterations, output length), and refuses a salt or a nonce of the wrong size, before deriving any key
  • Reading a valid archive could still exhaust the server: its compressed payload was expanded in one go with no ceiling, and its JSON parsed with no limit on nesting or object count, so a hundred kilobytes could become tens of gigabytes. Decompression now runs in bounded chunks, capped both absolutely and relative to the compressed size, and the payload's nesting depth and object count are checked before it is parsed
  • Listing the encrypted backups, downloading one and reading the backup history required only read:settings, a permission the operator role holds, while creating and restoring an archive required admin:system. An archive carries every private key and every secret of the installation, and its password protects the file, not the right to obtain it: the three routes now require admin:system on both the system and the settings API, and the backup tab is hidden from the roles that no longer hold it. The automatic backup switch, its frequency and its retention were missing from the administrator-only settings as well, so an operator could shorten retention through the general settings and let the daily task delete the archives the dedicated route protects
  • A data migration checked whether the target database was empty by counting rows in three tables, one of which has not existed under that name for years, so a target holding certificates, templates, HSM keys or anything else was accepted and written over. Every table of the target is now inspected before anything is created: a single row anywhere, a table belonging to another application, or a UCM table missing columns refuses the migration and names what it found
  • The full database URI, password included, was recorded in the audit trail, in the application log and in the syslog stream on every backend switch and data migration, where every role holding read:audit could read it. The URI is now redacted wherever it is recorded or returned, in the authority as well as in a password= query parameter, and so are the driver errors that quote the URI they failed on
  • Client certificate authentication never engaged on a PostgreSQL installation: the worker read the setting straight from a SQLite file that does not exist there, so the listening socket asked for no client certificate whatever the interface showed. The same read decides the TLS cap Windows enrolment clients need, and both are now read on either backend
  • Turning the ACME server off in the settings turned nothing off: the key was written by the settings page, read back by that page and by the dashboard tile, and consulted by no endpoint under /acme. Every ACME endpoint now answers 503 while it is off, as EST and XCEP already did
  • Deleting a user certificate removed two rows and nothing else: its certificate and key files stayed on disk, and the approval requests, deployment bindings, ACME orders and revocation records kept pointing at a row that was gone, which PostgreSQL refuses outright. It now takes the same path as every other certificate deletion
  • Two bulk delete routes read their list of identifiers without checking it, so a JSON string was iterated character by character and a list of any length became that many transactions and audit entries. Both now use the cap of 100 the other three have always published

Fixed

  • Eight relations were restored as the number they had on the installation the archive came from. The authority that signs a locally served ACME zone, the group owning an SSH certificate, the certificate an ACME order renews and the one it reuses a key from, the role a custom role inherits, the certificate and the request behind a Microsoft CA record, and the account that created an HSM provider each landed on whichever row happened to hold that number on the target: a zone signed by another authority, a certificate owned by another team. Where the column is required, PostgreSQL refused the row and the whole restore with it. All eight carry the identity of the row they name now, and a numeric foreign key that the manifest neither resolves nor explains fails the test suite
  • A required relation the target cannot place ended the restore on a constraint violation naming a table and nothing else. It is now refused before anything is written, naming the section, the column and the row the archive points at but does not carry
  • Two accounts at the same certificate authority under the same mailbox, which UCM allows on purpose, were one row to a restore: they were told apart by nothing but the address and the authority, so the second overwrote the first and any order pointing at the one that disappeared was attached to the one that remained. The name the operator gave each account is part of what identifies it now
  • Restoring as a replacement removed the ACME accounts before the orders that point at them, which PostgreSQL refuses outright. The order the rows are removed in is read from the tables themselves rather than from the relations the archive resolves, which are fewer on purpose
  • The seven notification settings were declared as though the table held a single row, so a restore through the generic path would have applied all seven onto one of them and left the six others as the target had them. They are identified by the kind of notification they configure, which is what the table already requires to be unique
  • Any value identifying a row was read back as a date if it could pass for one, so a template named 20260914 and one named 2026-09-14 were the same row to a restore, as were two spellings of the same serial number: one of the two was lost and the archive's row was applied over the other. Only columns that hold a moment in time are read that way now
  • Nine sections of an archive were put back by hand-written restorers that set a chosen few columns, and only onto rows they were creating. An SSH authority, a Microsoft connector, an HSM provider or key, a scan profile, an issuance policy, an ACME domain or an SSO provider this installation already held was counted as restored and left exactly as it was: it took the archive's name and rules while keeping the authority, the template, the approval group or the DNS provider the target happened to point at, and the WinRM password of a Microsoft connection was on no list at all and was lost at every restore. What those lists did write into a new row, they wrote with the source's own numeric keys, leaving the repair at the end of the restore to correct them: SQLite enforces no foreign key so the repair arrived in time and nobody was any the wiser, while PostgreSQL refused the insert as it happened and took the whole restore with it. All of them now go through the manifest, applied to a row already here exactly as to a new one, with each reference resolved against the rows the same restore has just created
  • An order placed with an external certificate authority came back pointing at the wrong account. The link to the account at that authority is a number, and it was restored as the number the source held, so it landed on whichever account happened to carry it here, or on nothing: the renewal was then attempted against another provider entirely. It travels with the identity of the account it names, like every other relation
  • A configuration an installation holds one of, such as the Active Directory connector, was added beside the one already there instead of replacing it. Its identity is its primary key, which means nothing outside the installation that issued it, so the restore found no row to update; an instance restored twice ended up with two connectors and used whichever the query returned first. The single row is now recognised for what it is
  • The Intune client secret of a SCEP profile read back as no secret at all whenever it was not stored encrypted, which is the case on an installation running without at-rest encryption and on every profile a restore puts back, since the archive carries secrets in the clear so they survive a change of database key. Enrolment through Intune stopped working without a word. The value is now read for what it is, encrypted or not, as the challenge password beside it already was
  • A restore put back six columns of an account and dropped the rest. The manifest declares far more and the export has carried them since the archive became manifest-driven, so an account came back without its MFA secret, without its backup codes, without the SSO identity that binds it to its provider and without the custom role that grants it anything beyond its base rights: the operator signed in again, if they could, to an installation that had quietly forgotten how to verify them. Every column the archive carries is now applied, and a secret goes back where the application reads it from, which for the MFA secret is the column itself
  • Restoring onto an installation that did not already hold the rows an archive points at was impossible. References were resolved against an index of the target taken before the first write, which is the point of building the plan first, but a section can point at a row the same restore creates a few sections earlier: six of them (role permissions, group memberships, WebAuthn credentials, deployment bindings, template pins, key recovery requests) resolved to nothing, and since those columns are required the whole restore died after having written everything before it. The index of the sections a section points at is now rebuilt just before its rows are applied
  • The pass that makes a restore a replacement deleted the rows the restore had just created. It compared the identity the archive carries with the identity the rows have here, and for a section identified by what it points at (a template pin is the pair authority/template, a deployment binding the pair target/certificate, an approval request the pair certificate/date) those are the source's numbers on one side and this installation's on the other: the row was written and removed again, and the section came out of a restore empty without a word. The archived identity is translated through the plan before being compared
  • A section identified partly by a timestamp was restored twice and then emptied: the archive carries a timestamp as an ISO string with a T in it and the database hands back a value whose text form uses a space, so the two never matched, no existing row was ever found to update, and the duplicate was then pruned as a row the archive did not hold
  • Five sections were named as restored and nothing wrote them: the audit log, the discovery results, the Microsoft CA request history, the scan runs and the SCEP request history were exported, counted among the sections applied and never put back. An operator who asks for the history in their archive is asking to get it back
  • The mirror of a private key was written in place, truncating the file before writing it, so a failure partway (a full disk, a read-only mount) destroyed the key that was there while the caller reported a success. It is now written to a temporary file in the same directory, fsynced, and renamed over the mirror. The certificate and CSR files a restore materialises are staged with the rest and published once the transaction has committed, instead of being written in place where a later failure left them holding the archive's content while the rows had gone back
  • A scheduled backup that succeeded could be recorded and audited as a failure. Writing down what the run did was guarded around its commit alone, so a failure in the read or the insert before it escaped to the caller, which reported a backup that is on disk and readable as a failed one, and then recorded that second failure in place of the first. The report is a report: losing it no longer changes what the run actually did
  • A malformed archive was answered differently depending on which restore endpoint it reached, and neither answer said what was wrong with it. The service names the section it refused, the route replaced that with "Invalid restore parameters", and the same file came back as 400 on the system API and as 500 "Restore failed" on the settings API. Both now answer with the refusal the service wrote, and a file too small to be an archive, an archive carrying a number no column can hold, or one whose section is not the shape it should be, are each named for what they are instead of arriving as a server error
  • A row whose identity the archive did not carry was announced as restorable and then broke the insert it could never have satisfied, so the transaction died on a constraint violation with nothing naming the row that did it. Where the column requires a value, such a row is now refused while the plan is being built, before the first write, naming the section and the position; where the column tolerates one, the row is still restored as a new row with the warning it always had, so an installation carrying rows older than the column that identifies them stays restorable
  • A value the column could not hold was restored anyway on SQLite and refused by PostgreSQL, so the same archive succeeded or failed depending on the backend underneath. Integer and numeric columns are now coerced and range-checked like dates and booleans, and a value outside what any backend stores is named with its column
  • An archive carrying nothing but its own metadata was announced as "restored successfully", and the restore then revoked every session and asked for a restart over a file that changed not one row. The answer now says the archive carried no data, and nothing is revoked
  • A restore said nothing about the records it knew could not sign. A private key that is not its certificate's is recorded when the archive is written, deliberately rather than refused, because the state an administrator needs to repair has to be saveable; the restore had that list and reported a clean success anyway, leaving an authority that signs certificates nobody can verify. The list now travels into the result, the log and the answer the operator reads, and archives written before it was carried in the metadata are read from the rows instead
  • Registering both spellings of a zone was refused for locally served domains and accepted for the ones mapped to a DNS provider, where only one of the two could ever be reached and the screen showed both. The DNS-mapped table is guarded the same way now, and a domain the format check would refuse comes back as a bad request rather than as a conflict (#352, reported by @gb-123-git)
  • A domain registered with its wildcard spelling matched nothing at all. Both mapping tables accept *.custom on the way in, and neither could find it again: the lookup removed the wildcard from the domain it was asked about, then compared the result to the stored text, so the entry never matched, not even itself. Orders for that zone were signed by the default CA, validated against the default DNS provider, and challenged despite an auto-approval, while the mapping on screen said otherwise. Both spellings now name the same zone at every level of the hierarchy, the most specific entry decides, and registering the second spelling of a zone that already has one is refused instead of creating an entry nothing would ever reach (#352, reported by @gb-123-git)
  • A data migration between SQLite and PostgreSQL could report success while losing data. A table or a column the target did not have was logged and skipped, and the migration still finished as a success; whole tables were read into memory before a single row was written; the tables were read one after another while the instance kept serving requests, so a certificate issued between two of them arrived without its issuer; the snapshot of the source reported "the dump failed" and "there was nothing to dump" the same way, and the caller checked neither. The migration is now a sequence where every step can stop it: the target must be empty, the source must satisfy the foreign keys the models declare, a snapshot must be taken and read back, the schema must be created and checked, every table and column must be copied, and six checks must pass on the result. Anything that cannot be copied refuses the migration instead of being skipped
  • Nothing verified a migration once it was done. Row counts are now compared table by table against what was read under the snapshot, foreign keys are checked with an anti-join on both backends (the bulk load runs with enforcement off, so nothing was checked as the rows arrived), unique constraints and indexes are checked for values they would have refused, PostgreSQL sequences must not be left behind the data they were reset for, the schema must hold every table and column of the models, and a bounded sample of every encrypted column must still decrypt. Any one of them refuses the switch, and what could not be checked, such as a partial unique index, is named rather than counted as verified
  • The source was read table by table from a live database. It is now read through a single consistent view: a verified snapshot on SQLite, a REPEATABLE READ transaction on PostgreSQL, with rows streamed and inserted in batches so memory does not grow with the size of the installation. Rows written to the instance after that view was fixed cannot be on the target, so the migration counts them and says so instead of reporting a clean success
  • A migration ran with nothing to fall back on. A snapshot of the source is now required and read back before the target is touched: a SQLite copy is taken through SQLite's own backup API and reopened read only, a PostgreSQL dump is listed back with pg_restore, and a snapshot that fails either check is removed rather than left looking like a restore point. Snapshot names carry a UUID and are created exclusively, so two migrations starting in the same second cannot overwrite each other's, and the refusal now carries the reason, a full disk or a pg_dump older than the server it is dumping, instead of a line in a log nobody had reason to read
  • Auth, RBAC, SSO and MFA were copied to a new backend one table per transaction, and the switch was reported as a success as long as the users table alone had made it: an administrator could land on a backend holding their account but none of their group memberships, their custom role or their SSO provider. The copy is now one transaction whose tables are ordered by the target's own foreign keys, so a PostgreSQL role that cannot disable constraints no longer fails on the first child row, and any table that cannot be copied aborts the switch
  • Nothing serialised two backend switches. They now take one lock for the whole operation, and a switch that has written the configuration but whose restart has not happened yet refuses the next one until the service comes back. The lock also covers a restore, which rewrites the entire database a migration would be reading through
  • The configuration file was rewritten in place with a truncating write, so an interruption destroyed the variables the change had nothing to do with, the encryption key among them. It is now written to a temporary file in the same directory, fsynced, given the original mode and owner, renamed into place and read back, with every other line keeping its text and its position; a re-read that does not match, or a restart that cannot be requested, puts the previous content back and refuses the switch. Rotating the session secret goes through the same writer and takes the same lock, since it edits the same file. A database URL containing a character the configuration reader would expand is refused before it is written rather than after the service fails to start with it
  • Under Docker the migration copied the data and returned an instruction. It now returns the same proof the native path acts on before restarting itself: the snapshot, the row counts, the six checks, the columns knowingly left behind with how many values each one held, and the rows that reached the source after the snapshot was taken
  • Backup retention pruned by age alone, which fails in both directions: a fortnight of failed backups with a seven-day retention left nothing to restore from, and daily backups of a growing instance filled the filesystem without ever tripping a date. Three limits now bound it: the most recent archive that still matches what was recorded when it was written is always kept, a minimum number of archives is kept whatever their age (two by default), and an optional ceiling on the room they may take removes the oldest first. None of them protects an archive proven to differ from what was written, and none is enforced to the point of removing the last restore point
  • The backup schedule reported when the task last ran, which said nothing about whether an archive came out of it: a schedule could look healthy with no backup anywhere. It now also reports the outcome of the last unattended run with its reason, and the age of the most recent archive that can be proven whole, both kept across restarts so any worker gives the same answer. The cadence is stated for what it is, an interval since the last run rather than a time of day, in the API and next to the setting
  • A restore wrote as it read: entities were committed before the sections depending on them had even been parsed, files were written halfway through, and a failure in a late section left the instance neither in its previous state nor in the archive's. A restore is now one transaction, decided before it writes: the archive is validated whole, each archived row is matched to the row it is here by a stable identity (a username, a reference, a fingerprint) rather than by the source's numeric id, and only then is anything applied. Files are staged while the transaction runs and published atomically once it has committed, restoring their previous contents if a publication fails partway
  • A restore is a replacement again, as the documentation has always described it: the rows a section held on the source are what it holds afterwards, so restoring after a compromise removes the account, the API key or the client certificate added after the snapshot. Only the sections the archive says it describes are replaced, so an archive of the authorities alone leaves the users and the settings untouched, and an archive carrying no users is refused rather than emptying the account table. A merge mode is available under that name for adding one archive's rows to a live instance
  • Settings kept encrypted at rest, the scheduled-backup password among them, were either skipped by the export or carried as ciphertext bound to the source's database key: a restored installation lost its unattended backups without a word. They now travel in the clear inside the archive, like every other secret, and are re-encrypted with the key of the server restoring them
  • The sections the export learned to carry are now restored too: webhook endpoints, deployment targets and bindings, SCEP profiles, the Active Directory connector, ACME client accounts, role permissions, group memberships, WebAuthn credentials, template pins and key-recovery requests. Their secrets are re-encrypted with the key of the server restoring them, so an archive from another installation arrives usable. An archive from a newer UCM carrying a section this version does not know is reported rather than passed over
  • A restore left the sessions opened before it valid, and the caches holding the certificate authorities, the OCSP responses and the SSO discovery it had just replaced: the previous state kept answering under identities that no longer existed. Restoring now revokes every session, in the database and on disk, clears ten caches that hold identity or PKI state, and asks for the restart the workers need
  • An offline authority's revocation list is signed next to its key and uploaded. The distribution point regenerated it locally on every request instead, taking the generation lock and logging a failure each time; it now serves the list it holds
  • Three pages offered actions the API refuses. Discovery scans and scan profiles require an administrator scope no role carries outside the wildcard, and pinning a template to an authority requires two permissions where the page checked one, so an operator saw buttons that could only answer 403. Re-keying a request as a certificate was offered to principals who cannot write certificates, and led to a page where nothing happened
  • A restore listing the sections an archive holds that this version does not apply joined every one of them into its message, and wrote "sections" for a single one. The list is bounded like the one beside it now
  • One confirmation on the groups page was written in English whatever the interface language
  • Four places still computed "days until expiry" with their own rounding, one day short of the value the rest of the application publishes. The expiry pass was one of them, and it gates its webhook on a positive count, so the last day before a certificate expired emitted nothing at all
  • The browser recomputed the same count in thirteen places, and its shared helper rounded the opposite way from the server, which moved a certificate between the 7-day and 30-day bands depending on which screen showed it
  • Tables did not refresh when somebody else made the change: the toast arrived over a list that still showed the old state until a manual reload. A server event now refreshes the pages that display it, and a socket that reconnects after an outage reloads them rather than staying on what it held
  • Twenty loads that failed were swallowed whole: the panel stayed empty and the console said nothing, so the only way to find out was the network tab. They now leave a trace, without a wall of toasts on a page that fires fourteen loads at once
  • Timestamping, certificate download, SSH host setup and the Windows enrolment endpoints shared one rate-limit bucket with the whole admin API, so a machine client and the interface spent the same quota. Each now has its own, at the rate the other protocol endpoints use
  • Four more pages reload when the change was made elsewhere: signing requests, revocation lists, users and groups. They showed the state they had loaded until somebody pressed refresh
  • A redirect was where a validated outbound request stopped being one: the first host was resolved, checked against the cloud-metadata deny-list and pinned, then the upstream answered 302 and the redirect was followed to an address nobody had looked at. Every hop now goes through the same check, a loop is cut, and the OAuth2 token exchange of a custom mail provider takes that path
  • Following a redirect handed the operator's credentials to whoever answered it: a webhook's bearer token, a mail provider's OAuth2 secret in the body, an integration's key under whatever name it uses. A hop that changes origin is now a bare GET: body, query and every header but a small harmless set are left behind
  • Client certificate authentication also stayed off when the database was briefly unreachable at startup. The last working configuration is kept instead, and the read no longer waits forever on a server that does not answer
  • Reports and exports still counted the days their own way, so a certificate expiring within the day was missing from the PDF and from the expiry pass. Six more places now publish the same number as the rest of the application
  • Revoking, renewing, exporting and signing in bulk had no ceiling on the list of identifiers, where deleting did. All of them cap at a hundred per request, and the screens split a larger selection instead of having it refused
  • Deleting a user certificate withdrew its access to UCM and nothing more: it stayed valid for every other service trusting the same authority, with nothing on the revocation list to say otherwise. It is now revoked on the way out, and a certificate that cannot be revoked is not deleted
  • A backup was written by twenty-seven hand-written exporters, so whatever nobody remembered to add was simply absent from every archive: webhook endpoints, deployment targets and their bindings, SCEP profiles and their Intune settings, the Active Directory connector, ACME client accounts with their upstream key and EAB credentials, the permissions of custom roles, the pins between an authority and its templates, WebAuthn credentials, and, inside sections that were exported, a user's MFA secret, backup codes and SSO identity, an authority's name constraints, policy constraints, path length, CRL cadence, SIA and HSM link, a certificate's subject key and authority key identifiers, UPN name and renewal history, and every OAuth2 credential of the SMTP configuration. What a backup carries is now declared in one manifest, every column of every model is exported unless the manifest records why it is not, and the test suite fails when a model gains a table or a column nobody decided about. On a working installation this recovered seventy-seven webhook endpoints, sixty-one template pins and three ACME client accounts that no previous archive contained. The export is ahead of the restore for the sections that are new, so a restore now names what it carries but does not apply, in its answer and in the log, instead of reporting a complete success
  • Secrets left an installation as ciphertext bound to its database key, so an archive restored elsewhere produced integrations with credentials nobody could read. Each secret is now decrypted as the archive is written, protected by the archive's own encryption, and a secret that cannot be decrypted at the source stops the backup instead of travelling as an unusable value. Relations are exported with the identity of the row they point at (a username, a name, a reference) beside the numeric key that only means something on the source
  • A backup now describes what it holds: the schema version it was written with, the database dialect the source was actually running (the metadata claimed SQLite whatever the server used), the sections it carries with their row counts and a digest each, the sections excluded on purpose, and any record whose stored private key is not its certificate's (recorded rather than refused: a backup has to stay possible precisely when something is wrong). A restore refuses an archive whose schema is newer than the one this server reads, and one whose sections are short of the counts the archive announces, in both cases before writing anything, and says which of the two it was: a refusal used to come back as Invalid restore parameters, which left no way to tell an archive from a newer UCM from a corrupted one. A corrupted archive is now named as such too
  • A backup could be announced as successful while missing what it was meant to hold: any exporter failure, a table that could not be read, an unexpected column or a private key that could not be decrypted, was logged and replaced with an empty section, so the archive silently lost users, certificate authorities or secrets and only said so on the day it was restored. Such a failure now aborts the backup and names the section that failed. A private key that cannot be decrypted is no longer archived in place of the key, where the restore treated it as the key itself and produced an authority that could no longer sign
  • A scheduled backup reported success whatever happened. A creation error, a missing password and a schedule timestamp that could not be saved were all recorded as a completed run, leaving the scheduler view green with no archive anywhere, and a backup password that no longer decrypted was used as the password itself, producing archives that the configured password cannot open. Each run now reports what it did, the scheduler view separates a failed run from one skipped on purpose, and a backup whose timestamp cannot be saved fails instead of repeating every minute until the disk fills
  • Backup retention could delete the last usable archive: it ran on a timer of its own and pruned by age alone, so a long export outage, a lost encryption key or an unset password, ended with no restore point left. Every archive is now read back after it is written and recorded with its size and digest, and retention keeps the most recent archive whose bytes still match that record, whatever its age; archives written before these records existed are kept in the two most recent, since nothing proves them either way. A backup that does not read back as it was written is reported as a failed backup instead of being listed as one more restore point. All backup creation endpoints now honor the configured backup directory rather than forcing the default location
  • The gunicorn access and error logs were never rotated on a Debian or RPM install and grew until the filesystem filled, the access log first on a server answering ACME and SCEP polling. Both packages now install a rotation configuration at /etc/logrotate.d/ucm, daily over fourteen generations, all compressed but the most recent, which asks gunicorn to reopen its files rather than reload it: a reload restarts the worker and drops every open WebSocket. The application log keeps rotating itself as before, on every kind of install, and a container is unaffected since it writes to standard output. The documentation described this file, a retention policy and a database optimiser log that no package had ever shipped (#350, reported by @JoseGoncalves)

[2.229] - 2026-09-12

Fixed

  • The service logged two errors at every start, Control server error: [Errno 30] Read-only file system then Control server error: no running event loop, without any other effect. The bundled gunicorn opens a control socket in the working directory by default, and the unit keeps that directory read-only. UCM never uses that interface, so it is now disabled in the gunicorn configuration shared by the Debian package, the RPM and the container image (#349, reported by @JoseGoncalves)
  • The container image carried the whole frontend source tree, its build-time dependencies included, close to 340 MB the server never reads: it serves the built interface only, which is what the image now holds. The image also created its data directories from a brace pattern the shell of its base image does not expand, leaving one oddly named directory that the entrypoint worked around at every start. A package or an image built from a working copy no longer carries what that copy holds outside version control either, the database, the sessions and the private keys under the backend data directory among them

Full history: CHANGELOG.md


Installation

Docker (Recommended)

# From Docker Hub
docker pull neyslim/ultimate-ca-manager:2.231-rc1

# Or from GitHub Container Registry
docker pull ghcr.io/neyslim/ultimate-ca-manager:2.231-rc1

# Run
docker run -d -p 8443:8443 \
  -e SECRET_KEY=$(openssl rand -hex 32) \
  --name ucm neyslim/ultimate-ca-manager:2.231-rc1

Debian/Ubuntu

wget https://github.com/NeySlim/ultimate-ca-manager/releases/download/v2.231-rc1/ucm_2.231.rc1_all.deb
sudo dpkg -i ucm_2.231.rc1_all.deb
sudo apt-get install -f

Fedora/RHEL

wget https://github.com/NeySlim/ultimate-ca-manager/releases/download/v2.231-rc1/ucm-2.231.rc1-1.fc43.noarch.rpm
sudo dnf install ./ucm-2.231.rc1-1.fc43.noarch.rpm

Silent/Automated Install

# Skip firewall prompts for CI/automation
sudo UCM_PORT=8443 UCM_FIREWALL=no dpkg -i ucm_2.231.rc1_all.deb

Default Credentials

  • Username: admin
  • Password: changeme123

Change the password immediately after first login!

Documentation

Don't miss a new ultimate-ca-manager release

NewReleases is sending notifications on new releases.