Changelog
All notable changes to zm-api are recorded here. The format follows
Keep a Changelog, and versioning is
SemVer — carrying the v3 major from ZoneMinder’s API
lineage, so a client written against the legacy /api/v3 surface has a
recognisable path forward.
[Unreleased]
Added
-
Reports.CreatedByis read and written (#29). The column has existed since 1.37 but was never modelled, so it was neither stored nor returned. Attribution comes from the authenticated token rather than the request body — letting a client name the creator is forging authorship. (description, the other half of that issue, is not possible: there is no such column.) -
Native replacements for three Perl maintenance daemons —
zmstats.pl,zmaudit.pl(database side) andzmtelemetry.pl— each independently switchable under[maintenance]and all off by default, so an existing install keeps running the Perl until the operator moves over. Enable the Rust job and disable the matching daemon together; running both has them competing over the same rows.
Stats samples CPU and memory intoServer_Stats, evicts staleMonitor_Statusheartbeats, ages events out of theEvents_Hour/Day/Week/Monthwindows and resyncs the counters they feed, and prunesLogsandSessionsunder ZoneMinder’s own retention settings.
Audit removesFrames/Statsrows whose event is gone, deletes events that never recorded a frame, closes events left open by a capture daemon that died, and recomputesEvent_SummariesandStorage.DiskSpacefrom the rows they summarise.
Telemetry posts the same anonymous report on the same schedule.
Three deliberate differences from the Perl, each because the original is wrong rather than because this is simpler: archived events are genuinely skipped when deleting frameless events (zmaudit tests a column it never selects, so its guard never fires);dry_runwrites nothing at all (zmaudit’s--reportstill performs updates, log pruning and counter resyncs); and telemetry performs no geolocation lookup — zmtelemetry callsipinfo.ioon every collection, disclosing the server’s public IP to a third party under the heading of anonymous statistics. Those fields are still sent, always"Unknown".
Retention limits and the telemetry interval are read from ZoneMinder’sConfigtable but parsed rather than interpolated: the Perl splicesZM_LOG_DATABASE_LIMITstraight into SQL andevalsZM_TELEMETRY_INTERVALas code, so aConfigrow is an injection surface in both.
The filesystem half reconciles event directories againstEventsrows, and is off even when the audit is on. It deliberately does not work the wayzmaudit.pldoes: zmaudit computes a directory fromStartDateTimeandrm -rfs the result, so a timezone difference, corrupted timestamp, changedSchemeor wrongStorageIdmakes it remove an unrelated directory with no error. Here nothing destructive acts on a computed path — directories are found by walking, identified from evidence inside them, and only an enumerated path is ever moved. A directory nothing identifies is reported and left alone.
Orphans are moved to a quarantine directory rather than deleted (an atomic rename within the storage, swept afterquarantine_retention_days); a pass refuses if the storage is missing or has no monitor directories, so an unmounted volume cannot orphan the whole database; an orphan must be seen in two consecutive passes before anything happens; and a derivation canary compares computed against actual paths for events found by both routes, disabling the filesystem half on sustained disagreement. That last one turns the timezone class of bug from silent data loss into an alarm. -
zm-api can serve the zm-web browser UI itself (
[web] enabled = true,APP_WEB__ENABLED). One process instead of a reverse proxy in front of two: the UI and the API share an origin by construction, so CORS stops applying, and TLS is already handled by[server.tls]/[server.acme]. Includes the SPA fallback so a refresh on/events/123works,immutablecaching for hashed assets withno-cacheonindex.html, and a configurable Content-Security-Policy applied to UI responses only.
API paths keep their JSON 404 envelope — the SPA fallback never shadows/api/,/swagger-ui,/api-docsor/.well-known/, so a mistyped endpoint still fails loudly instead of returning an HTML page with status 200.
Off by default; enabling it with noindex.htmlpresent logs a warning and serves the API anyway rather than refusing to start. -
Still images are rotated to match the monitor’s
Orientation./events/{id}/thumbnailand/monitors/{id}/snapshotnow return an upright JPEG, as ZoneMinder’s own image view does — previously aROTATE_90camera produced sideways thumbnails, and the failure was silent because nothing errored. Stills only: rotating live video would mean re-encoding, and a client can do it in CSS for free.ROTATE_0keeps the existing zero-copy path. -
Man pages:
zm-api(8),zm-api.env(5),zm-api-takeover(8),zm-api-db(8). -
zm-api --help,--version, and--openapi(writes the OpenAPI spec to stdout, so the API surface can be diffed or fed to a client generator without running a server). All three work before configuration is loaded, so they still answer on a host whose config is broken. -
The migration tool ships as
/usr/bin/zm-api-dbin all three packages. It was previously built but packaged nowhere, leaving no upgrade path for an existing ZoneMinder database. -
server.allowed_origins(APP_SERVER__ALLOWED_ORIGINS) as a documented,APP_-prefixed setting, accepting a TOML array or a comma-separated string. -
A documentation site (mdBook) published to GitHub Pages, covering install, configuration, deployment architecture, TLS, passive/takeover mode, and permissions — plus a browsable API reference rendered from the OpenAPI spec, which CI exports from the freshly built binary so it cannot drift from the code.
docs/is now the contributor-facing plan tree only. -
The OpenAPI spec is exported in CI and attached to each release, alongside a
SHA256SUMSfile covering every artifact. -
Release notes are taken from this file’s entry for the tag, falling back to GitHub’s generated commit list only when there isn’t one.
-
scripts/check-version-consistency.sh, run in CI before any package is built, so a half-finished version bump fails fast. -
openapi.jsonis committed as a reviewed baseline, and CI fails a pull request whose spec change could break a deployed client — a removed endpoint, a replaced response shape, a response field no longer guaranteed, a dropped enum value, or a request that gained a required field. New endpoints and new optional fields pass with a notice. This is the guard that would have caught the/mechange above in the pull request that made it.
Fixed
- The rate limiter made the API unusable for any browser client (#70). A
burst of
0with the limiter enabled was clamped silently to1, so one request succeeded and everything after it returned 429 — no page in any single-page app could load, and nothing said why. A burst that small is never intentional, so it is now treated as the misconfiguration it is: the server substitutes a usable value and logs an error naming the setting.
The setting is renamedrate_limit_period_secs, becauserate_limit_per_secondread as a rate while meaning a period — setting it to4expecting four requests a second gave one request every four seconds. The old name is still accepted, so existing configuration keeps working.
The production defaults are retuned from one token per 25 seconds with a burst of 50 to one per second with a burst of 120. The old values let the first screen through and then throttled everything after it to one request every 25 seconds. Credential brute-forcing is handled separately and far more tightly by the auth limiter, which is why the global one can afford to be generous. - Six duplicate
operationIds (#32) — the five AI-model routes collided with the camera-model routes, and two unrelatedupdate_statehandlers with each other. A generator silently emits one method and drops the other. - Four routes that enforce authentication did not say so in the spec (#32):
/daemons,/daemons/{id},/system/statusand the WebRTC signalling socket. A generated client reads a missingsecuritykey as “no token needed”. NaiveDateTimeWrapperclaimedformat: date-time(#32) while emitting2025-04-24T12:34:56, which has no offset and is therefore not RFC 3339. Generators built an offset-aware parser that rejected every value the API sends. It is now described as what it is — local wall-clock time as ZoneMinder stores it.- Zone
Areawas never computed (#43). Every zone created through the API hadArea = 0hardcoded, and changing a zone’s coordinates never recomputed it. That is not cosmetic: when a zone’s units arePercent, the alarm thresholds are stored relative toArea, so those zones had thresholds that silently did not mean what they said. Area is now derived fromCoordson both create and update, matching ZoneMinder’s owngetPolyArea(plain shoelace — upstream keeps an inclusive-pixel variant but no longer calls it). Coordinates that do not describe a polygon are rejected with a 400 rather than stored with a zero. SaveJPEGsrejected its own default (#39). It is a two-bit mask whose column default is 3, but the bound was-1..=1, so the API refused the value ZoneMinder ships with — the same class of bug as #19. The two neighbouring fields had the same copy-pasted bound and were also wrong:VideoWriteris 0–2 (disabled / encode / camera passthrough) andRecordAudiois 0–1. All three confirmed against the upstream monitor form rather than inferred.- Storage created through the API could never be reclaimed (#44).
DoDeletewas hardcoded to 0 while the column defaults to 1, so neither the retention reaper norDELETE /events/{id}could remove media from a storage the API created — the disk fills and nothing says why. Now defaults to 1, matching the column, and is settable on create. - Over-long values return 400 instead of 500 (#55). Roughly forty request
fields write to fixed-width columns with no length rule of their own, and each
turned an over-long value into
DATABASE_ERRORwith no indication of what was wrong. A “data too long” error is now mapped toVALUE_TOO_LONG/ 400 naming the offending column. Only the column name is surfaced — the driver’s message can carry the rejected value and surrounding SQL, and that redaction is tested. Per-DTO rules remain better where they exist, since they reject before the round trip; this is the net under everything else. - Bridged installs kept a legacy collation (#40), failing upgrade-parity on
every pull request since #14. The bridge normalised
EncoderTemplatestoutf8mb4_unicode_ci, which is the value the legacy chain creates it with — converting toward the old collation guaranteed the mismatch against a fresh baseline instead of removing it. It now converges on the database’s own default, and any other table that drifts is logged by name.
Removed
-
Config blocks nothing implemented (#53).
[streaming.rtsp_proxy]declared a port and an RTP range that nothing bound, and[streaming.go2rtc]a base URL that nothing called — an operator could configure either, restart, and get no behaviour change and no warning. Both are gone, along with an unused request DTO.Monitors.Go2RTCEnabledstays: that is ZoneMinder’s own column and the response passes it through. Removing them is upgrade-safe — no config struct denies unknown fields, so a stale block in an existing file is ignored rather than refusing to start, and there is a test for that. -
Enums emitted Rust variant names instead of the values ZoneMinder stores.
#[sea_orm(string_value = …)]governs only the database mapping, so serde fell back to the variant name:/monitorsreportedRotate90where the column holdsROTATE_90, andCurlwhere it holdscURL. Nine enums were affected —Orientation,MonitorType,DefaultCodec,EventCloseMode,Rtsp2WebType,Decoding,OutputContainer,StorageType,SynopsisStatus. It was self-consistent, and therefore invisible: requests accepted the same wrong spelling responses emitted, so a client that only ever talked to this API round-tripped fine while anything that knows ZoneMinder’s real values silently mismatched.
Responses and the OpenAPI schema now carry the DB values. The previous spelling is still accepted on input via a serde alias, so clients keep working while they migrate. A test walksActiveEnum::values()for every affected enum, so a newly generated one is covered without anyone remembering. -
A configured TURN server had no effect.
AppStatebuilt the WebRTC engine from defaults rather than the loaded[streaming.webrtc]config, sostun_serversandturnwere parsed, validated, and discarded — viewers behind symmetric NAT could not connect, with nothing in the log to explain it. -
CORS was undiscoverable. The allowed-origin list came from a bare, un-prefixed
ALLOWED_ORIGINSvariable that appeared in no config file and no documentation, defaulting to localhost. A dashboard on any other origin was silently blocked with no string in the repo to grep for. The variable is still honoured (with a deprecation warning) so existing deployments keep working; the effective list and its source are now logged at startup. -
APP_DAEMON__SCRIPT_PATHshipped a value wrong for Debian and Ubuntu. One env file serves all three package formats, and no single value suits every distribution, so daemon paths are now resolved by searching the standard locations, with the setting as an override. -
Requires=mariadb.servicefailed the unit on hosts usingmysql.serviceor a remote database. The unit is still orderedAfter=both, andRestart=on-failurecovers a slow database. -
A stream socket the service user cannot open now reports that
ZM_STREAM_SOCKET_GROUPmembership is missing, rather than a bare “permission denied” naming nothing actionable. -
docs/tls.mdclaimed the systemd unit usesDynamicUser; it runs asUser=zoneminder.
Changed
-
BREAKING: six
operationIds renamed (#32). They were duplicated, which meant a generated client silently got one method and lost the other, so this had to change — but it renames methods for anyone already generating against the spec.list_models→list_ai_models,create_model→create_ai_model,get_model→get_ai_model,update_model→update_ai_model,delete_model→delete_ai_model(the AI registry; the camera-model routes keep the plain names).update_stateon/monitors/{id}/state→update_monitor_state, and on/states/{id}→update_state_preset.
The compatibility gate did not catch this on its first run — it compared paths, response shapes and schemas but not operation ids. It does now, which is how the list above was produced. -
BREAKING:
rate_limit_per_secondrenamed torate_limit_period_secs(#70). The old name read as a rate and meant a period. It is still accepted as an alias, so no configuration needs changing, but the old spelling is misleading enough that it should not be used in new files. -
BREAKING:
GET /api/v3/mereturns a wrapper, not a bare user. As of5ce04e5the response isMeResponse—{ user, issued_at, expires_at, token_type }— where it was previouslyUserResponsewith the eight permission columns at the top level. This shipped without a changelog entry and broke zm-web’s permission gating: reading the wrapper as a user finds no permission columns, and absent columns fail closed toNone, so the camera wall and every edit control disappeared.
Clients should readresponse.user. Accepting both shapes is worth it while older backends are still deployed. -
BREAKING: the project is named
zm-apithroughout, including on disk. The binary is/usr/bin/zm-api, config lives in/etc/zm-api/, state in/var/lib/zm-api/, logs in/var/log/zm-api/, and the unit iszm-api.service; the helpers arezm-api-dbandzm-api-takeover, and the man pages match. The distribution packages were already calledzm-api— only what they installed disagreed. Nothing has been released, so there is no upgrade path to migrate; a pre-release install should be removed and reinstalled. The Rust crate is still imported aszm_api, which is the normal Cargo mapping for a hyphenated package name. -
packaging/install.shrewritten for source installs: it now matches the package layout, installs the man pages andzm-api-db, and runssetup-instance.shto generate JWT keys. Previously it installed the unit to a different directory than the packages, never generated keys, and left a freshly “installed” service unable to sign a token. -
Removed the dead
[package.metadata.rpm]block fromCargo.toml; nothing invoked cargo-rpm, and it duplicatedpackaging/rpm/zm-api.spec.
[3.0.0-alpha.1]
First Rust release, replacing ZoneMinder’s Perl/PHP/CGI API surface with a single native service. It talks directly to an existing ZoneMinder MySQL/MariaDB database and ships in passive mode, serving only the REST API so it can be installed alongside a running ZoneMinder without touching its daemons.
Added
- REST API under
/api/v3for monitors, events, frames, zones, groups, users, storage, controls, PTZ presets, and configuration — with an auto-generated OpenAPI 3 spec at/api-docs/openapi.jsonand Swagger UI at/swagger-ui. - Live streaming over WebRTC and HLS, sourced from zmc’s per-monitor stream socket (video and audio on one connection with a HELLO codec handshake).
- Event playback — VOD, fragmented-MP4 streaming, thumbnails, and motion synopsis.
- Authentication and authorisation — RS256 JWTs with separate access and refresh key pairs, server-side revocation, feature-level RBAC, and row-level monitor ACLs.
- Daemon supervision (takeover mode) — one native supervisor replacing both
zmdc.plandzmwatch.pl, with exponential backoff, database reconciliation, REST daemon control, and azmdc.sockcompatibility shim.zm-api-takeoverswitches a host between modes in one command, either way. - Retention — automatic recording cleanup bounded by free-space floor, age, and per-storage quota, deleting media and database rows together.
- ONVIF device discovery, media profiles, PTZ, and event pull-point.
- Natural-language event search over MariaDB 11.8 native vectors.
- Object-detection registry — CRUD over ZoneMinder 1.39’s
AI_Datasets,AI_Models, andAI_Object_Classes, plus the per-monitor detection columns. - Packaging for Debian/Ubuntu (
.deb), Fedora/RHEL/openSUSE (.rpm), and Arch (PKGBUILD), with a systemd unit, per-install JWT key generation, and built-in TLS/ACME.
Notes
- Upgrading an existing ZoneMinder database requires
zm-api-db bridge -u mysql://...before first start. Only a fresh, empty database should usezm-api-db up. - Passive mode is the install-time default so the package cannot disturb a
running ZoneMinder. Takeover is the intended destination — one native
supervisor replacing
zmdc.plandzmwatch.pl— andzm-api-takeoverswitches either way in one command.