Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

zm-api

A modern, fast, type-safe REST API for ZoneMinder — rebuilding a twenty-year-old Perl, PHP, and CGI surface as one native service.

Rust + Axum OpenAPI 3.1 WebRTC + HLS AGPL-3.0

zm-api talks directly to an existing ZoneMinder MySQL/MariaDB database and ships in passive mode — it serves the REST API and leaves ZoneMinder’s own daemons running exactly as they were. Installing it changes nothing about how your cameras record, so it is safe to put on a live box and take back off again.

When you are ready, it takes over daemon supervision too, replacing zmdc.pl and zmwatch.pl with one native supervisor. Passive is the on-ramp; takeover is where it is meant to end up, and zm-api-takeover moves you either way in one command.

One binary, one language

No PHP-FPM, no CGI, no Perl runtime to babysit. A single native executable and a systemd unit.

Live streaming built in

WebRTC and HLS from zmc's stream socket, plus recorded-event playback with byte-range seeking.

Real access control

JWT auth with separate access and refresh keys, per-feature RBAC, and row-level monitor ACLs.

Self-documenting

Every endpoint is in a generated OpenAPI 3.1 document, served live and published with each release.

A better supervisor

Takeover replaces zmdc.pl and zmwatch.pl with one native process: exponential backoff, database reconciliation, and daemon control over REST.

Safe to adopt

Passive by default, so installing changes nothing. Switch to takeover when you choose, and back again with one command.

Actually tested

1,100+ unit and integration tests with a coverage gate, run against a real ZoneMinder schema in CI.

Where to start

If you have a running ZoneMinder and want zm-api alongside it, go to Install and then Upgrading an existing ZoneMinder — the database migration step is easy to miss and fails quietly.

If you are building a client against it, start with Authentication and Permissions, then browse the API reference.

If you are deciding how to deploy the pieces together, Architecture covers what has to share a host and how to serve a dashboard.

Status

zm-api is in active development at 3.0.0-alpha. It keeps the v3 major from ZoneMinder’s API lineage, so the URL shape is familiar, but it is not a drop-in replacement for the CakePHP API — the response envelopes, authentication, and error format are all different.

Install

zm-api installs as a systemd service in passive mode — it serves the REST API and leaves ZoneMinder’s daemons alone, so it is safe to put on a live box. That is the starting point; takeover is where it is meant to end up, whenever you choose to switch.

Packages

sudo dpkg -i zm-api_*.deb        # Debian / Ubuntu / Raspberry Pi OS
sudo dnf install zm-api-*.rpm    # Fedora / RHEL / Rocky / Alma
sudo zypper install zm-api-*.rpm # openSUSE

Arch users build from packaging/arch/PKGBUILD with makepkg.

Installing does three things beyond copying files: it creates the zoneminder service account if it does not exist, generates this install’s JWT signing keys into /var/lib/zm-api/keys, and registers the systemd unit. It does not start touching your cameras.

If the database already has ZoneMinder in it, do not start the service yet. Run the migration first — see Upgrading an existing ZoneMinder. zm-api only warns when startup migrations fail, so a database left in the wrong state gives you a service that looks healthy with features silently missing.

From source

For platforms without a package, or to test a local build.

You need current stable Rust, a MariaDB/MySQL server with a ZoneMinder schema, and the FFmpeg development libraries:

sudo apt install pkg-config libssl-dev \
  libavutil-dev libavcodec-dev libavformat-dev libavfilter-dev \
  libavdevice-dev libswscale-dev libswresample-dev

Then:

git clone https://github.com/SteveGilvarry/zm-api.git
cd zm-api
cargo build --release --bins
sudo ./packaging/install.sh

install.sh lays files out exactly where the packages put them, so a later package install upgrades cleanly instead of colliding.

What gets installed

PathWhat
/usr/bin/zm-apiThe server
/usr/bin/zm-api-dbDatabase migration tool
/usr/bin/zm-api-takeoverHands daemon supervision to zm-api, or back
/etc/zm-api/base.tomlPackaged defaults — replaced on upgrade, don’t edit
/etc/zm-api/prod.tomlYour configuration
/etc/zm-api/zm-api.envEnvironment overrides — wins over both TOML files
/var/lib/zm-api/keys/JWT signing keys, generated per install
/var/log/zm-api/Logs

Man pages: zm-api(8), zm-api.env(5), zm-api-takeover(8), zm-api-db(8).

Next

First run covers starting the service and checking it works.

First run

Start it

sudo systemctl enable --now zm-api
systemctl status zm-api
journalctl -u zm-api -f

Check it responds

curl -s localhost:8080/api/v3/server/health_check
curl -s localhost:8080/api/v3/host/getVersion

Both are public — no token — which makes them useful as reverse-proxy health checks. Then open http://localhost:8080/swagger-ui in a browser.

Get a token

curl -s -X POST localhost:8080/api/v3/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"…"}'

You get back an access token (10 minutes) and a refresh token (1 hour). Send Authorization: Bearer <access_token> on everything else. See Authentication.

Two things that commonly need setting

Database. zm-api reads /etc/zm/zm.conf automatically, so on a normal single-host install there is nothing to configure. Only set APP_DB__* in /etc/zm-api/zm-api.env if the database is somewhere else.

CORS. If you are serving a dashboard from a different origin than the API, set this or the browser blocks every request:

APP_SERVER__ALLOWED_ORIGINS=https://zm.example.com

The failure is quiet — the dashboard loads and each request fails, reported only in the browser console. zm-api logs its effective origin list at startup and warns when it fell back to the localhost-only default, so check there first. Not needed when both sit behind one hostname; see Serving a dashboard.

If live streaming does not work

zmc’s per-monitor stream sockets are mode 0660, owned by ZoneMinder’s ZM_STREAM_SOCKET_GROUP. If that group is not zoneminder, add it:

sudo systemctl edit zm-api
[Service]
SupplementaryGroups=<value of ZM_STREAM_SOCKET_GROUP>

The log says permission denied opening stream socket … when this is the problem. Do not add the group to the shipped unit file directly — naming a group that does not exist makes systemd refuse to start the service entirely.

Upgrading an existing ZoneMinder

This is the step most likely to be skipped, and it fails quietly.

zm-api runs pending migrations at startup, but if they fail it logs a warning and carries on. The result is a service that starts, answers requests, and has features silently missing — with one line in the journal to say why.

Run the migration explicitly, and read its output.

Which command

Two cases, two different commands. Getting this wrong matters.

Your databaseCommand
Already has ZoneMinder in it (1.26.0+)zm-api-db bridge -u mysql://…
Fresh and emptyzm-api-db up -u mysql://…

bridge walks the embedded zm_update chain to bring the schema up to what zm-api expects, converges triggers, stamps the baseline migration as already applied, then runs everything after it.

up assumes it is creating the schema. Never run it against a database that already has ZoneMinder tables — the baseline is not written to adopt an existing schema.

Doing it

Back up first. bridge rewrites schema across many ZoneMinder versions in one pass and there is no undo.

mysqldump --single-transaction --routines --triggers zm > zm-backup.sql

sudo systemctl stop zm-api
zm-api-db bridge -u mysql://zmuser:zmpass@localhost/zm
zm-api-db status -u mysql://zmuser:zmpass@localhost/zm   # confirm
sudo systemctl start zm-api

The connection URL can also come from DATABASE_URL instead of -u.

Checking it worked

zm-api-db status -u mysql://zmuser:zmpass@localhost/zm
journalctl -u zm-api -n 50 | grep -i migrat

status lists every migration and whether it has been applied. Nothing should be pending after a successful bridge.

Other subcommands

The underlying SeaORM CLI also accepts down, fresh, refresh, and reset. Those exist for development and will drop data. None is part of a supported upgrade.

Full details: man 8 zm-api-db.

How ZoneMinder, zm-api, and a dashboard fit together

Which process owns what, what has to share a host, and how a browser frontend reaches the API.

The three components

ComponentWhat it isOwns
ZoneMinderThe existing C++/Perl/PHP installCapture daemons (zmc, zma), the zm database schema, the events directory, the legacy web UI
zm-apiThis project — one native binaryThe REST API on port 8080, live streaming (WebRTC + HLS), event playback/VOD, retention
zm-webThe browser UI (separate project) — replaces ZoneMinder’s PHP web/Nothing server-side: static files calling this API

zm-api does not replace ZoneMinder’s capture pipeline. In the default configuration it does not manage ZoneMinder’s processes at all; it reads and writes the same database, the same events directory, and the same shared memory that ZoneMinder itself uses, and adds an HTTP surface over them.

What must share a host

This is the constraint that shapes every deployment: zm-api must run on the same machine as zmc.

ResourcePathAccessSame host?
Databasemysql://…read-write, including DDL at startupNo — may be remote
zm.conf/etc/zm/zm.conf, /etc/zm/conf.d/*.confread-onlySame host, or copy the file
Events directory/var/lib/zoneminder/events (per-event path from the Storage table)read for playback, delete for retentionSame host, or a shared mount
Stream sockets/run/zm/stream_{id}.sockconnect + readYes, mandatory
Shared memory/dev/shm/zm.mmap.{id}read-writeYes, mandatory
PTZ sockets/run/zm/zmcontrol-{id}.sockconnect + writeYes (for the Perl bridge)

Only the database is genuinely detachable. Live streaming reads zmc’s unix sockets and alarm control writes ZoneMinder’s shared memory, neither of which crosses a machine boundary.

Two consequences worth stating plainly. The stream sockets are mode 0660 owned by ZoneMinder’s ZM_STREAM_SOCKET_GROUP, so the zm-api service user must be a member of that group or every live stream fails with a permission error. And zm-api runs schema migrations against the shared ZoneMinder database at startup — see Upgrading an existing ZoneMinder before first run on an existing install.

Passive and takeover mode

zm-api ships passive (daemon.enabled = false) so installing it cannot disturb a running ZoneMinder. That is the on-ramp, not the destination — takeover is where zm-api is meant to end up, and passive exists so the switch happens on your schedule rather than at install time.

Passive. zm-api serves the REST API. zoneminder.service keeps supervising zmdc.pl, zmc, zmfilter and the rest, exactly as before zm-api was installed. Installing the package changes nothing about how ZoneMinder records. The daemon-control endpoints (/api/v3/daemons*, /api/v3/system/*) are still registered but return 503.

Takeover (daemon.enabled = true). zm-api supervises the ZoneMinder daemons itself, replacing both zmdc.pl and zmwatch.pl with one native supervisor: exponential backoff that resets once a daemon stays up, a reconciliation loop that keeps running daemons in step with the Monitors table, daemon control over the REST API, and supervision events in the journal rather than in ZoneMinder’s own logs. The legacy zmdc.sock IPC shim stays bound, so tooling that talks to zmdc.pl keeps working. See Passive and takeover mode.

Exactly one supervisor may run. On startup in takeover mode zm-api runs kill_orphan_daemons(), which pkill -9s zmc, zma, zmfilter.pl and friends before starting its own — so leaving zoneminder.service enabled means two supervisors killing and restarting each other’s processes. Use zm-api-takeover, which sequences both services correctly, rather than editing the flag by hand. See man 8 zm-api-takeover.

Serving a dashboard

zm-api can serve zm-web’s built dist/ itself ([web] enabled = true), which removes the reverse proxy entirely — one process, one port, one certificate, and no CORS because the UI and API share an origin. It is off by default. A proxy or CDN in front works too. See Serving a dashboard.

Ports

PortWhatWhen
8080The entire HTTP API, HLS, and WebRTC signallingAlways (server.port)
80ACME HTTP-01 challenge onlyOnly with acme.challenge = "http-01"
ephemeral UDPWebRTC mediaWhenever WebRTC is used

One TCP listener, and it is HTTP or HTTPS, never both: enabling server.tls or server.acme switches that same port to TLS. Enabling both is a startup error. The default ACME challenge is tls-alpn-01, which opens no second port.

The WebRTC UDP ports are OS-assigned ephemeral, with no configurable range — worth knowing before putting the media path through a firewall or NAT.

API surface

Everything lives under /api/v3, plus /swagger-ui and /api-docs/openapi.json.

Authentication is a bearer JWT and no cookies are involved anywhere:

  • POST /api/v3/auth/login{ token_type, access_token, refresh_token, expire_in }
  • POST /api/v3/auth/refresh with { "token": "<refresh_token>" }
  • GET /api/v3/auth/logout — revokes all of that user’s outstanding tokens server-side
  • GET /api/v3/me — returns the user plus issued_at/expires_at, so a client never has to decode the JWT

Send Authorization: Bearer <token>. Access tokens last 10 minutes and refresh tokens 1 hour, signed with separate RSA key pairs — a leaked access key cannot mint refresh tokens.

The one exception to the header rule is media: the snapshot route also accepts ?token=<JWT>, because <img> and <video> elements cannot set headers.

GET /api/v3/server/health_check and GET /api/v3/host/getVersion are public and unauthenticated — useful as proxy health checks.

Two things a frontend author should know up front.

Rate limits. The authentication endpoints have their own limiter, on by default at roughly one request per two seconds with a burst of 10 — a login retry loop will start getting 429s. The prod profile additionally enables a global per-IP limiter that is off in base.toml, so a dashboard that fans out many parallel requests on page load can behave differently in production than in development. Behind a reverse proxy, set APP_SERVER__MIDDLEWARE__TRUST_PROXY_HEADERS=true or every client shares the proxy’s single bucket.

Authorisation. Every non-auth route is behind feature-level RBAC, with reads requiring View and writes requiring Edit on the relevant feature; daemon, system, and permission-management routes require the admin-tier System feature. On top of that, monitor and group rows are filtered per user, so two accounts can get different results from the same endpoint.

Startup order

Nothing enforces ordering between the two services, and nothing needs to: zm-api retries, and systemd restarts it on failure. The unit is ordered After= mariadb/mysql but deliberately does not Requires= them, so a host using a remote database or mysql.service still starts.

Serving a dashboard

There are three ways to do this. The first needs no reverse proxy at all.

Note that APP_STATIC_DIR, despite its name, is unrelated: it locates JWT keys and a couple of image constants, and is never served over HTTP.

Let zm-api serve it (simplest)

zm-api can serve zm-web’s built dist/ itself:

[web]
enabled = true
root = "/usr/share/zm-web"

or in /etc/zm-api/zm-api.env:

APP_WEB__ENABLED=true
APP_WEB__ROOT=/usr/share/zm-web

One process, one port, one certificate. The UI and the API share an origin by construction, so CORS does not apply and allowed_origins needs nothing. TLS is already handled by [server.tls] / [server.acme].

What you get:

  • SPA fallback/events/123 serves index.html, so a browser refresh on a client-side route works.
  • API paths are never shadowed. /api/, /swagger-ui, /api-docs and /.well-known/ keep their JSON 404 envelope. A mistyped endpoint still fails loudly instead of quietly returning an HTML page with status 200.
  • Cache headers that match how the UI is built — hashed assets are immutable for a year, index.html is always no-cache because it names the current asset hashes.
  • A Content-Security-Policy on UI responses only, configurable via web.content_security_policy (empty disables it).

Off by default: a deployment fronted by a CDN, or one already running a proxy, should keep serving the files there. If web.enabled is true but the directory has no index.html, zm-api logs a warning and serves the API anyway rather than refusing to start.

Same origin behind a reverse proxy

Use this when something else already terminates TLS, or when you want a CDN, caching, or other sites on the same host.

server {
    listen 443 ssl;
    server_name zm.example.com;

    # Dashboard: static build output. The SPA fallback lives here, because
    # zm-api has none — see above.
    root /var/www/zm-web;
    location / {
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebRTC signalling (/api/v3/live/{id}/webrtc/ws) is a WebSocket.
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        # Media must stream, not accumulate: buffering stalls HLS and
        # fragmented-MP4 playback.
        proxy_buffering off;
        proxy_read_timeout 3600s;
    }

    location /swagger-ui { proxy_pass http://127.0.0.1:8080; }
    location /api-docs/  { proxy_pass http://127.0.0.1:8080; }
}

# Required for the Upgrade header above.
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

The browser makes no cross-origin request here either, so CORS still does not apply.

Since the proxy is trusted, also set APP_SERVER__MIDDLEWARE__TRUST_PROXY_HEADERS=true so rate limits key on the real client IP rather than the proxy’s — otherwise every client shares one bucket. Leave it false on any host where zm-api is reachable directly: the headers are attacker-controlled there, and trusting them lets a client mint a fresh bucket per request.

Separate origins

The dashboard on https://dash.example.com, the API on https://api.example.com. Then CORS is in play and you must set:

APP_SERVER__ALLOWED_ORIGINS=https://dash.example.com

Unset, zm-api allows localhost only. The failure mode is quiet — the dashboard loads and every request fails, reported only in the browser console — so this is the single most likely day-one problem. zm-api logs its effective origin list at startup and warns when it fell back to the default; check there first.

A bare * is not accepted. The API sends credentials, and the CORS spec forbids that pairing. Entries are exact origins, or scheme://host:* to match any port on a host (intended for development).

See also Permissions for what a client can discover about its own account, and Architecture for the ports involved.

Configuration

Sources apply in order, each overriding the previous:

  1. ZoneMinder’s /etc/zm/zm.conf and /etc/zm/conf.d/*.conf — database settings only. This is what lets a packaged install work against an existing ZoneMinder without being told anything.
  2. /etc/zm-api/base.toml — the packaged defaults layer. Replaced on upgrade; don’t edit it.
  3. /etc/zm-api/prod.toml — your configuration.
  4. APP_* environment variables, from /etc/zm-api/zm-api.env.

The environment file wins over everything. That makes it the right place for host-specific settings, and a trap: a stale value there silently overrides a corrected default shipped in a later package upgrade.

Naming

APP_, then the TOML path in upper case with __ between levels:

TOMLEnvironment
db.hostAPP_DB__HOST
server.allowed_originsAPP_SERVER__ALLOWED_ORIGINS
server.middleware.body_limit_bytesAPP_SERVER__MIDDLEWARE__BODY_LIMIT_BYTES

List values are indexed: APP_SERVER__ACME__DOMAINS__0=api.example.com.

Restart the service after editing.

The settings you are most likely to need

VariableNotes
APP_SERVER__ALLOWED_ORIGINSCORS. Required for a cross-origin dashboard — see Serving a dashboard
APP_DB__HOST etc.Only if the database isn’t where zm.conf says
APP_DAEMON__ENABLEDfalse = passive (install default), true = zm-api supervises the daemons. Prefer zm-api-takeover over setting this by hand
APP_SERVER__PORTDefault 8080
RUST_LOGinfo, or per-module: info,zm-api::streaming=debug
APP_SERVER__MIDDLEWARE__TRUST_PROXY_HEADERStrue only behind a trusted proxy

man 5 zm-api.env documents every variable.

Profiles

APP_PROFILE selects which TOML loads alongside base.toml: dev, test, test-db, or prod. Packaged installs use prod.

prod differs from the defaults in ways worth knowing: it enables the global per-IP rate limiter (off in base.toml) and trusts proxy headers.

Secrets

JWT signing keys are generated per install into /var/lib/zm-api/keys and are never packaged. To regenerate:

sudo /usr/share/zm-api/setup-instance.sh   # idempotent; won't overwrite

Existing tokens stop working if you delete the old keys first.

TLS and ACME

The API server can terminate TLS directly. The secret.*_key files are used for JWT signing, not for TLS certificates.

Enable TLS in the API

  1. Set server.port to 443 (or another HTTPS port).
  2. Enable TLS in config:
    • server.tls.enabled = true
    • server.tls.cert_path = "/etc/letsencrypt/live/example.com/fullchain.pem"
    • server.tls.key_path = "/etc/letsencrypt/live/example.com/privkey.pem"
  3. Restart the service.

Environment variable equivalents:

  • APP_SERVER__TLS__ENABLED=true
  • APP_SERVER__TLS__CERT_PATH=/etc/letsencrypt/live/example.com/fullchain.pem
  • APP_SERVER__TLS__KEY_PATH=/etc/letsencrypt/live/example.com/privkey.pem

Built-in ACMEv2 (rustls-acme)

rustls-acme (built on instant-acme) can request and renew certs without external tooling.

Example config:

  • server.acme.enabled = true
  • server.acme.domains = ["api.example.com"]
  • server.acme.contact_emails = ["ops@example.com"]
  • server.acme.cache_dir = "/var/lib/zm-api/acme"
  • server.acme.production = true
  • server.acme.challenge = "tls-alpn-01" (default)

If you need HTTP-01 (behind a TLS-terminating proxy), set:

  • server.acme.challenge = "http-01"
  • server.acme.http_port = 80

Environment variable equivalents:

  • APP_SERVER__ACME__ENABLED=true
  • APP_SERVER__ACME__DOMAINS__0=api.example.com
  • APP_SERVER__ACME__CONTACT_EMAILS__0=ops@example.com
  • APP_SERVER__ACME__CACHE_DIR=/var/lib/zm-api/acme
  • APP_SERVER__ACME__PRODUCTION=true
  • APP_SERVER__ACME__CHALLENGE=tls-alpn-01

External ACMEv2 automation (Let’s Encrypt)

certbot (standalone)

Use a pre/post hook so certbot can bind port 80/443 during issuance:

certbot certonly --standalone -d api.example.com \
  --agree-tos -m ops@example.com \
  --pre-hook "systemctl stop zm-api" \
  --post-hook "systemctl start zm-api"

Renewals are handled by the certbot timer:

systemctl enable --now certbot.timer

lego (standalone)

lego --email ops@example.com --domains api.example.com \
  --path /etc/letsencrypt --accept-tos run

On renew, restart the API so it reloads the new certs:

systemctl restart zm-api

Notes

  • Ensure ports 80/443 are reachable from the internet.
  • Static TLS (server.tls.enabled) loads certs at startup; restart after renewals.
  • Built-in ACME keeps certs fresh without restarts.
  • server.acme.enabled and server.tls.enabled are mutually exclusive.
  • HTTP-01 mode starts a separate listener on server.acme.http_port for challenges.
  • server.acme.cache_dir must be writable by the service user.
  • The systemd unit runs as User=zoneminder / Group=zoneminder with SupplementaryGroups=video ssl-cert. Ensure the cert/key are readable by the ssl-cert group — for example setfacl -m g:ssl-cert:rx /etc/letsencrypt/{live,archive} — or chown them to zoneminder if you prefer fixed ownership.

Passive and takeover mode

zm-api ships passive so that installing it cannot disturb a running ZoneMinder. That is a starting point, not a destination: takeover is where zm-api is meant to end up. Passive exists so you choose when to switch, on your own schedule, with a one-command way back.

Passive

daemon.enabled = false. zm-api serves the REST API. zoneminder.service keeps supervising zmdc.pl, zmc, zmfilter and the rest, exactly as before zm-api was installed. Installing the package changes nothing about how ZoneMinder records, which is what makes it safe to drop onto a live box.

The daemon-control endpoints (/api/v3/daemons*, /api/v3/system/*) are still registered but return 503.

Takeover

daemon.enabled = true. zm-api supervises the ZoneMinder daemons itself, replacing both zmdc.pl and zmwatch.pl with one native supervisor.

This is an upgrade, not a lateral move. What you gain:

  • One supervisor instead of two processes. ZoneMinder runs zmdc.pl to start daemons and zmwatch.pl to poll whether the capture daemons are still healthy. zm-api does both in its own health-check loop, so there is no second Perl daemon whose own death goes unnoticed.
  • Exponential backoff with recovery. A daemon that crashes repeatedly backs off between restarts rather than being restarted at a fixed interval forever, and the backoff resets once it has stayed up. A camera that is genuinely unreachable stops generating restart churn.
  • Reconciliation against the database. A loop syncs running daemons with what the Monitors table says should be running, so enabling or disabling a monitor through the API takes effect without a separate restart cycle.
  • Daemon control over the REST API. Start, stop, and query daemons through /api/v3/daemons* instead of shelling out to zmdc.pl.
  • Structured logging. Supervision events go to the journal alongside everything else zm-api logs, rather than into ZoneMinder’s own log tables and files.

The legacy zmdc.sock IPC shim is still bound, so tooling that talks to zmdc.pl keeps working.

Exactly one supervisor may run. On startup in takeover mode zm-api runs kill_orphan_daemons(), which pkill -9s zmc, zma, zmfilter.pl and friends before starting its own. Leaving zoneminder.service enabled means two supervisors killing and restarting each other’s processes: daemons that restart in a loop and events that record erratically. zm-api-takeover handles the ordering for you — that is the whole reason it exists.

Switching

sudo zm-api-takeover            # take over
sudo zm-api-takeover --revert   # hand back

The script sequences both services correctly — disable and stop zoneminder.service, flip the flag, restart zm-api; and the reverse on the way back. Prefer it over editing APP_DAEMON__ENABLED by hand.

Before you take over

  • zm-api must already be working in passive mode. Takeover is not a way to fix a broken install: if the API cannot reach the database, taking over the capture daemons as well means nothing records.
  • The service user needs write access to the events directory, and membership of ZM_STREAM_SOCKET_GROUP for live streaming.
  • Nothing else may be starting the ZoneMinder daemons — no cron entry, no zmpkg.pl invocation, no second host sharing the database with the same server ID.

Verifying

systemctl is-active zm-api          # active
systemctl is-enabled zoneminder     # disabled (or not-found)
ps -o ppid=,cmd= -C zmc             # one zmc per enabled monitor,
                                    # parented by the zm-api process
journalctl -u zm-api -n 50

Then confirm recording actually continues — watch the event count for an active monitor rise over a few minutes. A clean systemctl status proves the supervisor started, not that video is being captured.

Reverting

sudo zm-api-takeover --revert is the first thing to try if capture misbehaves. It restores the arrangement the host had before, and the REST API keeps working throughout — passive is a fully supported mode, not a broken state.

Reverting is cheap and reversible in both directions, which is the point: you can take over on a Tuesday afternoon, watch it for a day, and go back with one command if anything looks wrong. Treat it as a rollback, not a defeat.

Full details: man 8 zm-api-takeover.

Recording retention

Automatic cleanup that deletes whole events — media and database rows — oldest first, per Storage, when a limit is breached. It replaces ZoneMinder’s PurgeWhenFull filter.

Off by default. Enable it in prod.toml:

[retention]
enabled = true

What it will not delete

  • Archived events
  • Events still being recorded
  • The newest event for each monitor

That last one means a monitor never ends up with no footage at all, however tight the limits.

Limits

Configured per Storage. Any breach triggers reaping:

LimitMeaning
Free-space floorKeep at least this much free on the volume
AgeDelete events older than this
QuotaCap total bytes for this Storage

Media deletion

The reaper and the DELETE /api/v3/events/{id} endpoint share one code path, so both remove the same things: the event’s directory on disk plus its Frames, Events_Hour/Day/Week/Month, and Events_Archived rows, transactionally. No foreign key cascade covers those, which is why it is centralised.

On-disk paths honour the per-event Storage row and ZoneMinder’s Deep, Medium, and Shallow layout schemes.

Watching it

journalctl -u zm-api -f | grep -i reap

Every deletion is logged with the event id and the limit that triggered it. Start with generous limits and read the log for a cycle or two before tightening them — deletion is not reversible.

Replacing the Perl maintenance daemons

ZoneMinder runs three periodic housekeeping daemons that zm-api can take over: zmstats.pl, zmaudit.pl and zmtelemetry.pl. Each is independently switchable and all three default to off, so an existing install keeps running the Perl until you move over deliberately.

Enable the Rust job and disable the matching Perl daemon together. Running both has them competing over the same rows. In takeover mode zm-api supervises the Perl daemons, so removing one from ZoneMinder’s set is the other half of the switch.

Stats — replaces zmstats.pl

[maintenance.stats]
enabled = true
interval_seconds = 300

Six jobs on one timer, none of which touch event media or Events rows:

  • samples CPU and memory into Server_Stats, and trims it to a day
  • mirrors the same sample onto this host’s Servers row (multi-server only)
  • evicts Monitor_Status rows whose heartbeat has stopped
  • ages events out of the Events_Hour/Day/Week/Month windows and resyncs the counters they feed
  • prunes Logs under ZM_LOG_DATABASE_LIMIT and ZM_LOG_AUDIT_DATABASE_LIMIT
  • prunes expired Sessions under ZM_COOKIE_LIFETIME

It reads those retention settings from ZoneMinder’s own Config table, so the values you already set still apply. One difference: ZoneMinder splices those values straight into its SQL, whereas here they are parsed first — a limit that is neither a row count nor a recognised interval disables that pruning and logs why, rather than producing a broken statement.

Audit — replaces zmaudit.pl

[maintenance.audit]
enabled = true
dry_run = true      # leave this on for a pass or two first
min_age_seconds = 3600

Four checks:

  • Orphaned Frames and Stats whose event is gone. Pure garbage; nothing can reach it and Frames is usually the largest table in the database.
  • Empty events that never recorded a frame and are older than min_age_seconds.
  • Unclosed events left by a capture daemon that died mid-recording. End time, length, frame count and score totals are recomputed from the frames that did land, and the event is marked Recovered. so the repair is visible. An update, never a delete.
  • Counter drift in Event_Summaries and Storage.DiskSpace, recomputed from the rows they summarise.

Two deliberate differences from zmaudit.pl

Archived events are genuinely skipped. zmaudit intends to skip them when deleting frameless events, but the column it tests is not in its SELECT list, so the guard never fires and it deletes them. Archiving an event is a user saying keep this.

dry_run means dry. zmaudit’s --report suppresses its deletes but still performs row updates, empty-directory removal, stray-image unlinking, log pruning and counter resyncs — so “just report” is not what it does. Here nothing is written at all.

The filesystem half

[maintenance.audit.filesystem]
enabled = true

Reconciles event directories against Events rows. Off even when the audit is on, because it is the only part that touches the filesystem.

It does not work the way zmaudit.pl does, deliberately. zmaudit computes an event’s directory from its StartDateTime and rm -rfs the result. That computation can be wrong — from a timezone difference between the recording daemon and the auditor, a corrupted timestamp, a Scheme changed after the event was recorded, or a wrong StorageId — and when it is wrong, the thing removed is an unrelated directory. Nothing reports an error.

Here no destructive action ever acts on a computed path. Directories are found by walking, identified from evidence inside them (a {id}-video.mp4, a .{id} marker, frame stills, or a numeric leaf name where the scheme allows it), and only a path that was actually enumerated is ever moved. A directory nothing identifies is reported and left alone — zmaudit reconstructs a timestamp from the path and deletes it.

Four further safeguards:

Orphans are moved, not deleted. They go to .zm-api-quarantine/{stamp}/ in the same storage, which is an atomic rename within one filesystem. You have quarantine_retention_days (7 by default) to look at what was taken before a later sweep removes it.

Preconditions. The storage path must exist, be a directory, and contain at least one monitor directory. A volume that failed to mount presents an empty directory, which otherwise looks exactly like every event being orphaned — that pass refuses and says so.

Two passes. An orphan must be seen in confirmations_required consecutive passes before anything happens, so a row committed after a walk began is never mistaken for one.

A derivation canary. For events found by both routes, the computed path is compared against the real one. Sustained disagreement disables the filesystem half and reports why — this is what turns the timezone class of bug from an invisible data-loss event into an alarm.

Deleting Events rows whose media is missing is separate and off by default (remove_rows_without_media), because the evidence there is an absence rather than a presence.

Telemetry — replaces zmtelemetry.pl

[maintenance.telemetry]
enabled = true
interval_seconds = 1209600  # 14 days

Off unless explicitly enabled, and it stays off.

No geolocation lookup. zmtelemetry.pl calls ipinfo.io on every collection — including when you only asked to preview the payload — and reports city, region, country and latitude/longitude. That discloses the server’s public IP to a third party under the heading of anonymous statistics. Those fields are still sent so the receiving end sees the shape it expects, but they are always Unknown.

Camera paths are scrubbed before they leave the machine: credentials and hostname are replaced, keeping only the scheme and path shape. A remote path that is not a parseable URL is dropped entirely rather than forwarded.

The interval comes from ZM_TELEMETRY_INTERVAL, which ZoneMinder ships as the Perl expression 14*24*60*60 and evaluates as code. Here it is parsed as a number or a product of numbers; anything else falls back to the configured value and logs a warning.

Watching it

journalctl -u zm-api -f | grep -iE 'audit|stats|telemetry'

The audit logs a line per pass summarising what it found, and says explicitly when it is in dry-run mode.

Authentication

zm-api uses bearer JWTs. No cookies are involved anywhere.

Getting tokens

POST /api/v3/auth/login
Content-Type: application/json

{ "username": "admin", "password": "…" }
{
  "token_type": "Bearer",
  "access_token": "eyJ…",
  "refresh_token": "eyJ…",
  "expire_in": 600
}

Send the access token on every other request:

Authorization: Bearer eyJ…

Lifetimes and refresh

Access tokens last 10 minutes, refresh tokens 1 hour. They are signed with separate RSA key pairs, deliberately — a leaked access key cannot mint refresh tokens. Tokens also carry a typ claim, so a refresh token cannot be presented as an access token or vice versa.

POST /api/v3/auth/refresh
Content-Type: application/json

{ "token": "<refresh_token>" }

Knowing when a token expires

Don’t decode the JWT client-side. GET /api/v3/me returns the user plus issued_at, expires_at, and token_type:

{
  "user": { "username": "operator", "system": "None", "monitors": "Edit", … },
  "token_type": "access",
  "issued_at": 1755820000,
  "expires_at": 1755820600
}

Logging out

GET /api/v3/auth/logout
Authorization: Bearer <access_token>

This is a real server-side revocation, not a client-side token discard: it raises the user’s TokenMinExpiry floor, so every outstanding token for that account stops working immediately, including ones issued to other devices.

Media URLs

<img> and <video> elements cannot set headers, so the snapshot route also accepts the token as a query parameter:

GET /api/v3/monitors/1/snapshot?token=<JWT>

This is the only place that is accepted. It puts a credential in a URL, where it can land in proxy logs and browser history — prefer the header wherever the client controls the request.

Rate limiting

The authentication endpoints have their own limiter, on by default at roughly one request per two seconds with a burst of 10. A login retry loop will start getting 429s. The prod profile additionally enables a global per-IP limiter that is off in base.toml, so a client that fans out many parallel requests on page load can behave differently in production than in development.

Behind a reverse proxy, set APP_SERVER__MIDDLEWARE__TRUST_PROXY_HEADERS=true or every client shares the proxy’s single bucket. Leave it false anywhere zm-api is reachable directly — the headers are attacker-controlled there.

Permissions

Two independent layers apply to every request: feature-level RBAC decides whether you may touch a kind of resource at all, and row-level ACLs decide which specific monitors and groups you see.

Feature-level RBAC

ZoneMinder accounts carry eight permission columns. zm-api enforces all of them, deriving the required level from the HTTP method — reads need View, writes need Edit.

FeatureGates
StreamLive video (WebRTC, HLS, snapshots)
EventsEvent list, playback, frames, filters, tags, search
ControlPTZ, control presets, X10 triggers
MonitorsMonitors, zones, monitor presets, ONVIF discovery
GroupsGroups and group membership
DevicesDevices, manufacturers, models
SnapshotsSnapshots
SystemConfig, logs, storage, users, servers, reports, daemon control, AI registry

Stream has no Edit tier in ZoneMinder — View is the maximum.

Granting permissions is deliberately System-tier, not the tier of the thing being granted, so a Groups:Edit user cannot grant themselves more.

Discovering your own permissions

GET /api/v3/me returns all eight columns and is not feature-gated, so it works for any authenticated account including System: None.

This matters. The CakePHP API had no way to ask “what am I allowed to do” — a client had to fetch /users.json and find its own row, which was itself gated on System != 'None'. An ordinary operator got a 401 and could infer only that one column. System: None with Monitors: Edit is a perfectly legal ZoneMinder account, and such a user could not discover its own monitor permissions at all.

So gate your UI on /me:

{
  "user": {
    "username": "operator",
    "system": "None",
    "stream": "View",
    "events": "View",
    "control": "None",
    "monitors": "Edit",
    "groups": "View",
    "devices": "None",
    "snapshots": "View"
  }
}

Row-level ACLs

On top of the feature check, Monitors_Permissions and Groups_Permissions filter individual rows per user. Two accounts with identical feature permissions can get different results from the same endpoint.

This is default-allow for backward compatibility: a user with no explicit row-level entries sees everything their feature permissions allow.

For routes naming a monitor in the path — PTZ, live streaming — the row-level guard runs inside the feature check, so a caller without the feature is refused before any database query happens.

What a denial looks like

StatusMeaning
401No token, expired token, or a revoked one
403Authenticated, but the account lacks the feature or level
404May exist, but row-level ACLs hide it from this account

The 404 is deliberate — a 403 would confirm the resource exists.

Live streaming

Two delivery paths from one API, both sourced from zmc’s per-monitor stream socket — a single unix socket carrying video and audio with a HELLO codec handshake.

Everything under /api/v3/live/{monitor_id} requires Stream: View and passes the row-level monitor ACL.

HLS

Works in any HTML5 <video> element, including Safari natively.

GET /api/v3/live/{monitor_id}/hls/master.m3u8
GET /api/v3/live/{monitor_id}/hls/live.m3u8
GET /api/v3/live/{monitor_id}/hls/init.mp4
GET /api/v3/live/{monitor_id}/hls/{segment}

Fragmented MP4, with low-latency HLS available. Higher latency than WebRTC but far easier to proxy and cache.

WebRTC

Lower latency, at the cost of a signalling handshake and UDP.

GET /api/v3/live/{monitor_id}/webrtc/ws     (WebSocket)

The media itself travels over OS-assigned ephemeral UDP ports — there is no configurable range, which matters before putting the media path through a firewall or NAT.

For viewers behind symmetric NAT, STUN is not enough and you need a TURN server:

[streaming.webrtc.turn]
enabled = true
server = "turn:turn.example.com:3478"
username = "zm"
password = "…"

Media is relayed through TURN, so size it accordingly.

Session control and snapshots

POST   /api/v3/live/{monitor_id}/start
DELETE /api/v3/live/{monitor_id}/stop
GET    /api/v3/live/{monitor_id}/stats
GET    /api/v3/live/sessions
GET    /api/v3/live/sources
GET    /api/v3/monitors/{monitor_id}/snapshot

The snapshot route accepts ?token=<JWT> because <img> cannot set headers.

Still images are rotated for you

/events/{id}/thumbnail and /monitors/{id}/snapshot apply the monitor’s Orientation before returning the JPEG, matching what ZoneMinder’s own image view serves. A client can size and render the result directly.

This is deliberately stills only. Rotating live video would mean re-encoding the stream; a client can do it in CSS for nothing, so WebRTC and HLS hand back the camera’s own frames.

ROTATE_0 — almost every camera — still streams the bytes straight off disk with no decode.

Recorded playback

GET /api/v3/events/{id}/video
GET /api/v3/events/{id}/stream/video.mp4
GET /api/v3/events/{id}/stream/playlist.m3u8
GET /api/v3/events/{id}/thumbnail

These need Events: View, not Stream.

Behind a reverse proxy

Streaming routes are deliberately excluded from zm-api’s own compression layer, and they must not be buffered either. With proxy_buffering on an HLS or MP4 route stalls. See Serving a dashboard for a working nginx config.

When streams fail

permission denied opening stream socket … in the log means the service user is not in ZoneMinder’s ZM_STREAM_SOCKET_GROUP. The sockets are mode 0660. Fix with a systemd drop-in — see Troubleshooting.

API reference

Every endpoint is generated from the running server’s own OpenAPI 3.1 document, so it cannot drift from the code. The same document is served live at /api-docs/openapi.json on any zm-api instance and is attached to every GitHub release.

Open the full reference ↗ Download openapi.json

The explorer below is the same page, embedded. It reads better full-screen.

Using the spec directly

openapi.json is a plain OpenAPI 3.1 document — feed it to any generator:

# From a running instance
curl -s localhost:8080/api-docs/openapi.json > openapi.json

# Or from the binary, without starting a server
zm-api --openapi > openapi.json

Because --openapi needs no database and no configuration, it is also the way to diff the API surface between two releases:

diff <(zm-api-3.0.0-alpha.1 --openapi | jq -S .) \
     <(zm-api-3.0.0-alpha.2 --openapi | jq -S .)

Conventions

Details are on their own pages, but in short:

  • Everything lives under /api/v3.
  • Authentication is Authorization: Bearer <jwt>; see Authentication.
  • Reads need View on the relevant feature, writes need Edit; see Permissions.
  • List endpoints take page and page_size and return { items, total, per_page, current_page, last_page }.
  • Errors return { kind, error_message, code, details }kind is a stable string such as INVALID_INPUT_ERROR or NOT_FOUND_ERROR, and details names the offending fields.

Command-line tools

Three binaries, each with a man page.

zm-api

The server. It takes no arguments beyond these — everything else is configuration.

OptionWhat
-h, --helpUsage summary
-V, --versionVersion
--openapiWrite the OpenAPI 3.1 spec to stdout

All three are handled before configuration loads, so they still answer on a host whose config is broken.

zm-api --openapi > openapi.json          # diff the API between releases
zm-api --openapi | jq '.paths | keys'    # list every route

man 8 zm-api

zm-api-db

Database migrations. Built by cargo as migrator; installed as zm-api-db.

CommandWhen
bridge -u <url>Existing ZoneMinder database (1.26.0+)
up -u <url>Fresh, empty database only
status -u <url>List migrations and whether each applied. Read-only

The URL can come from DATABASE_URL instead of -u. See Upgrading — picking the wrong one of bridge/up matters.

man 8 zm-api-db

zm-api-takeover

Switches the host between passive and takeover mode, sequencing zoneminder.service and zm-api.service so they are never both supervising.

OptionWhat
(none)Take over
--revert, --passiveHand control back to ZoneMinder
--yes, -ySkip the confirmation prompt

Requires root. See Passive and takeover mode.

man 8 zm-api-takeover

Configuration reference

man 5 zm-api.env documents every APP_* variable.

Troubleshooting

The dashboard loads but every request fails

CORS. The browser reports it in its own console and the API logs nothing unusual, which is what makes it hard to spot.

Set the dashboard’s origin:

APP_SERVER__ALLOWED_ORIGINS=https://zm.example.com

zm-api logs its effective origin list at startup and warns when it fell back to the localhost-only default — check there first:

journalctl -u zm-api | grep -i cors

A bare * is not accepted. The API sends credentials and the CORS spec forbids that pairing. Not needed at all when the dashboard and API share a hostname — see Serving a dashboard.

permission denied opening stream socket

The service user is not in ZoneMinder’s ZM_STREAM_SOCKET_GROUP. The per-monitor sockets are mode 0660.

sudo systemctl edit zm-api
[Service]
SupplementaryGroups=<value of ZM_STREAM_SOCKET_GROUP>

Do not add it to the shipped unit file. A group that does not exist makes systemd refuse to start the service entirely (status=216/GROUP).

The service starts but features are missing

Migrations probably failed. zm-api only warns on migration failure at startup, so the service looks healthy.

journalctl -u zm-api | grep -i migrat
zm-api-db status -u mysql://zmuser:zmpass@localhost/zm

See Upgrading.

The unit won’t start at all

systemctl status zm-api
journalctl -u zm-api -n 100 --no-pager

Common causes:

SymptomCause
status=216/GROUPA SupplementaryGroups= entry names a group that doesn’t exist
Cannot connect to databaseAPP_DB__* set to something wrong, overriding the working zm.conf fallback
Missing JWT keys/var/lib/zm-api/keys not provisioned — run /usr/share/zm-api/setup-instance.sh
Port in useSomething else on 8080; set APP_SERVER__PORT

Streams stall behind a reverse proxy

proxy_buffering off; on the live and playback routes, and the Upgrade/Connection headers with proxy_http_version 1.1 for the WebRTC signalling WebSocket. Full config in Serving a dashboard.

WebRTC connects then shows nothing

Usually NAT. STUN cannot traverse symmetric NAT; configure a TURN server. Note the media ports are OS-assigned ephemeral UDP with no configurable range. See Live streaming.

Getting 429s

Rate limiting. The auth endpoints allow roughly one request per two seconds with a burst of 10; prod also enables a global per-IP limiter.

Behind a proxy, set APP_SERVER__MIDDLEWARE__TRUST_PROXY_HEADERS=true — without it every client shares the proxy’s single bucket.

Turning up the logs

sudo systemctl edit zm-api      # [Service] Environment=RUST_LOG=debug
# or per-module:
#   RUST_LOG=info,zm-api::streaming=debug
#   RUST_LOG=info,zm-api::daemon=debug
sudo systemctl restart zm-api
journalctl -u zm-api -f

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.CreatedBy is 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 daemonszmstats.pl, zmaudit.pl (database side) and zmtelemetry.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 into Server_Stats, evicts stale Monitor_Status heartbeats, ages events out of the Events_Hour/Day/Week/Month windows and resyncs the counters they feed, and prunes Logs and Sessions under ZoneMinder’s own retention settings.
    Audit removes Frames/Stats rows whose event is gone, deletes events that never recorded a frame, closes events left open by a capture daemon that died, and recomputes Event_Summaries and Storage.DiskSpace from 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_run writes nothing at all (zmaudit’s --report still performs updates, log pruning and counter resyncs); and telemetry performs no geolocation lookup — zmtelemetry calls ipinfo.io on 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’s Config table but parsed rather than interpolated: the Perl splices ZM_LOG_DATABASE_LIMIT straight into SQL and evals ZM_TELEMETRY_INTERVAL as code, so a Config row is an injection surface in both.
    The filesystem half reconciles event directories against Events rows, and is off even when the audit is on. It deliberately does not work the way zmaudit.pl does: zmaudit computes a directory from StartDateTime and rm -rfs the result, so a timezone difference, corrupted timestamp, changed Scheme or wrong StorageId makes 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 after quarantine_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/123 works, immutable caching for hashed assets with no-cache on index.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-docs or /.well-known/, so a mistyped endpoint still fails loudly instead of returning an HTML page with status 200.
    Off by default; enabling it with no index.html present 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}/thumbnail and /monitors/{id}/snapshot now return an upright JPEG, as ZoneMinder’s own image view does — previously a ROTATE_90 camera 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_0 keeps 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-db in 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 SHA256SUMS file 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.json is 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 /me change above in the pull request that made it.

Fixed

  • The rate limiter made the API unusable for any browser client (#70). A burst of 0 with the limiter enabled was clamped silently to 1, 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 renamed rate_limit_period_secs, because rate_limit_per_second read as a rate while meaning a period — setting it to 4 expecting 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 unrelated update_state handlers 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/status and the WebRTC signalling socket. A generated client reads a missing security key as “no token needed”.
  • NaiveDateTimeWrapper claimed format: date-time (#32) while emitting 2025-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 Area was never computed (#43). Every zone created through the API had Area = 0 hardcoded, and changing a zone’s coordinates never recomputed it. That is not cosmetic: when a zone’s units are Percent, the alarm thresholds are stored relative to Area, so those zones had thresholds that silently did not mean what they said. Area is now derived from Coords on both create and update, matching ZoneMinder’s own getPolyArea (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.
  • SaveJPEGs rejected 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: VideoWriter is 0–2 (disabled / encode / camera passthrough) and RecordAudio is 0–1. All three confirmed against the upstream monitor form rather than inferred.
  • Storage created through the API could never be reclaimed (#44). DoDelete was hardcoded to 0 while the column defaults to 1, so neither the retention reaper nor DELETE /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_ERROR with no indication of what was wrong. A “data too long” error is now mapped to VALUE_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 EncoderTemplates to utf8mb4_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.Go2RTCEnabled stays: 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: /monitors reported Rotate90 where the column holds ROTATE_90, and Curl where it holds cURL. 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 walks ActiveEnum::values() for every affected enum, so a newly generated one is covered without anyone remembering.

  • A configured TURN server had no effect. AppState built the WebRTC engine from defaults rather than the loaded [streaming.webrtc] config, so stun_servers and turn were 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_ORIGINS variable 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_PATH shipped 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.service failed the unit on hosts using mysql.service or a remote database. The unit is still ordered After= both, and Restart=on-failure covers a slow database.

  • A stream socket the service user cannot open now reports that ZM_STREAM_SOCKET_GROUP membership is missing, rather than a bare “permission denied” naming nothing actionable.

  • docs/tls.md claimed the systemd unit uses DynamicUser; it runs as User=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_modelslist_ai_models, create_modelcreate_ai_model, get_modelget_ai_model, update_modelupdate_ai_model, delete_modeldelete_ai_model (the AI registry; the camera-model routes keep the plain names). update_state on /monitors/{id}/stateupdate_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_second renamed to rate_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/me returns a wrapper, not a bare user. As of 5ce04e5 the response is MeResponse{ user, issued_at, expires_at, token_type } — where it was previously UserResponse with 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 to None, so the camera wall and every edit control disappeared.
    Clients should read response.user. Accepting both shapes is worth it while older backends are still deployed.

  • BREAKING: the project is named zm-api throughout, 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 is zm-api.service; the helpers are zm-api-db and zm-api-takeover, and the man pages match. The distribution packages were already called zm-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 as zm_api, which is the normal Cargo mapping for a hyphenated package name.

  • packaging/install.sh rewritten for source installs: it now matches the package layout, installs the man pages and zm-api-db, and runs setup-instance.sh to 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 from Cargo.toml; nothing invoked cargo-rpm, and it duplicated packaging/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/v3 for monitors, events, frames, zones, groups, users, storage, controls, PTZ presets, and configuration — with an auto-generated OpenAPI 3 spec at /api-docs/openapi.json and 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.pl and zmwatch.pl, with exponential backoff, database reconciliation, REST daemon control, and a zmdc.sock compatibility shim. zm-api-takeover switches 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, and AI_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 use zm-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.pl and zmwatch.pl — and zm-api-takeover switches either way in one command.