Skip to content
SteveGilvarryPublic

About

Rust Axum Based API for ZoneMinder

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

330 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸŽ₯ zm-api

A modern, fast, type-safe REST API for ZoneMinder

Rebuilding ZoneMinder's aging Perl/PHP API surface as a single, well-tested Rust service β€” with live streaming, fine-grained access control, and OpenAPI docs baked in.

Tests Coverage Rust Axum SeaORM License Status


πŸ“¦ Install

Install the prebuilt package β€” it starts in passive mode (REST API only), so it is safe to drop onto a live ZoneMinder box without changing how anything records:

sudo dpkg -i zm-api_*.deb        # Ubuntu 24.04 family (amd64, arm64)
sudo dnf install zm-api-*.rpm    # Fedora 41  (zypper on openSUSE Tumbleweed)

Those are the targets built and tested by CI. The packages may work on nearby releases β€” Debian bookworm, Ubuntu 22.04, RHEL-family β€” but nothing verifies it, so the dependency versions cargo-deb pins are not guaranteed to resolve. Build from source there, or ask for a target to be added.

Already running ZoneMinder? Migrate its database before the first start β€” zm-api-db bridge -u mysql://zmuser:zmpass@localhost/zm. Only a fresh, empty database should use zm-api-db up. See man 8 zm-api-db.

πŸ“– Full documentation β€” install, configuration, deployment architecture, and a browsable API reference generated from the OpenAPI spec.

Want to build from source or run a local dev setup instead? See Quick Start. Packaging internals and the release process: docs/deployment.md. What changed: CHANGELOG.md.


✨ Why zm-api?

ZoneMinder is a rock-solid surveillance platform, but its API grew organically across Perl, PHP, and CGI over two decades. zm-api replaces that surface with one cohesive service:

  • πŸ¦€ One binary, one language β€” no PHP-FPM, no CGI, no Perl runtime to babysit.
  • ⚑ Fast & async β€” built on Axum + Tokio; streaming endpoints don't block the API.
  • πŸ”’ Secure by default β€” JWT auth, per-feature RBAC, and row-level monitor ACLs.
  • πŸ“– Self-documenting β€” every endpoint is in an auto-generated OpenAPI spec + Swagger UI.
  • πŸ§ͺ Actually tested β€” 1,100+ unit + integration tests, with a coverage gate in CI.
  • πŸ—„οΈ Drop-in schema β€” talks directly to an existing ZoneMinder MySQL/MariaDB database.

πŸš€ Features

πŸ“Ή Monitors & Events

Full CRUD for monitors, events, frames, zones, and event metadata β€” paginated, filterable, and searchable. Monitor state & alarm control. Per-monitor snapshots straight from the live stream.

🎬 Live Streaming

Two delivery paths from one API:

  • HLS β€” fragmented-MP4 playlists for any HTML5 <video> element.
  • WebRTC β€” native peer connection with ICE/SDP signaling.

Plus recorded-event playback (video, byte-range seeking, thumbnails).

πŸ•ΉοΈ PTZ Control

Pan/tilt/zoom with native protocol drivers and a Perl-bridge fallback β€” ONVIF and vendor protocols, presets, and continuous control.

πŸ” Access Control

  • JWT access + refresh tokens.
  • Feature RBAC β€” ZoneMinder's 8 permission columns (Stream, Events, Control, Monitors, Groups, Devices, Snapshots, System) enforced on every route.
  • Row-level ACLs β€” Monitors_Permissions / Groups_Permissions resolved per request, so a user only ever sees the monitors they're granted (default-allow, fully backward compatible).

πŸ› οΈ Operations

Optional daemon supervision (zmc/zma) with a Unix-socket IPC shim for legacy zmdc.pl compatibility, storage & server management, configs, logs, montage layouts, and system control β€” all behind graceful SIGTERM/SIGINT shutdown. See Daemon Control for how supervision runs and how to turn it on.

🧱 Production hardening

TLS with optional ACME/Let's Encrypt, security headers, gzip/brotli compression (streaming routes excluded), request-body limits, and opt-in per-IP rate limiting.


πŸ—οΈ Architecture

A clean, one-way layered flow β€” handlers never touch the database directly.

flowchart TD
    Client["🌐 Client / Browser / ZM UI"] -->|HTTPS · JWT| Stack

    subgraph Stack["Middleware stack"]
        direction LR
        T[trace] --> C[compression] --> H[security headers] --> R[rate limit] --> O[CORS]
    end

    Stack --> Auth{"auth Β· RBAC Β· monitor ACL"}
    Auth -->|allowed| Handlers["πŸ“₯ Handlers β€” extraction & validation"]
    Auth -->|denied| Reject["401 / 403 / 404"]

    Handlers --> Services["βš™οΈ Services β€” business logic"]
    Services --> Repos["πŸ—ƒοΈ Repositories β€” SeaORM queries"]
    Repos --> DB[("πŸ›’οΈ MariaDB / MySQL<br/>ZoneMinder schema")]

    Handlers -.live & playback.-> Streaming["🎬 HLS · WebRTC"]
    Streaming -.-> Cameras[("πŸ“· ZoneMinder capture daemons")]
Loading
Layer Path Responsibility
Routes src/routes/ Endpoint wiring & middleware layering
Handlers src/handlers/ Request extraction, validation, response mapping
Services src/service/ Business logic
Repositories src/repo/ Database queries
Entities src/entity/ SeaORM models (generated from the ZM schema)
DTOs src/dto/ Request/response types (OpenAPI schemas)

πŸ› οΈ Daemon Control

zm-api can supervise ZoneMinder's daemons itself, replacing zmdc.pl and zmwatch.pl. It is off by default β€” a starting point, not the destination. Takeover is where a zm-api host is meant to end up; passive exists so you pick the moment, with a one-command way back.

Passive vs. active

Passive (default) Active (takeover)
daemon.enabled false true
Supervises zmc/zma No β€” ZoneMinder does Yes
Binds zmdc.sock No Yes
zoneminder.service Keeps running Must be stopped & disabled

In passive mode no manager is constructed at all: nothing binds the socket, no orphan sweep runs, no process is spawned. zm-api is purely a REST layer over the database ZoneMinder is already using β€” which is why the packages are safe to drop onto a live box. The daemon-control endpoints stay registered but return 503 until you switch.

⚠️ Exactly one supervisor may run. Starting in takeover mode pkill -9s any zmc, zma and zmfilter.pl left behind before starting its own. Leaving zoneminder.service enabled means two supervisors killing and restarting each other's processes β€” daemons restarting in a loop, events recording erratically.

Switching is scripted, and sequences both services in the right order either way. Prefer it over editing APP_DAEMON__ENABLED by hand:

sudo zm-api-takeover              # hand supervision to zm-api
sudo zm-api-takeover --revert     # hand it back to ZoneMinder

What it supervises

Per-monitor capture and analysis, plus ZoneMinder's Perl singletons, started in priority order:

Priority Daemon Scope
5 / 6 zmc / zma one per monitor
10 zmfilter.pl --filter_id=N --daemon one per filter with Run in background set; none otherwise
20 zmaudit.pl --continuous singleton
30 zmtrigger.pl singleton
40 / 50 zmcontrol.pl / zmtrack.pl one per controllable / tracking monitor
70 – 90 zmstats.pl, zmtelemetry.pl, zmeventnotification.pl singleton

zmwatch.pl has no entry β€” watching capture daemons and restarting them is the supervisor's own health-check loop.

How supervision behaves

  • Health checks β€” every 10s; a zmc whose shared-memory capture heartbeat is 30s stale is restarted, as is any other daemon whose CPU time has not advanced for that long (watch_check_interval_seconds / watch_max_delay_seconds; ZoneMinder's own ZM_WATCH_MAX_DELAY default is 45s).
  • Restart backoff β€” exponential: min_backoff Γ— 2^attempt, so the first retry waits 10s, then 20s, capped at max_backoff (15min by default). A process that stayed up longer than the cap resets its counter.
  • Reconciliation β€” every 60s, after a 45s startup delay, the monitors in the database are diffed against what is actually running and the difference is corrected. That is what self-heals an external kill, a crash between a DB write and the daemon call, or a reboot.
  • Graceful shutdown β€” SIGTERM, then SIGKILL after shutdown_timeout_seconds. Supervised daemons are drained whether the server exited cleanly or not.
  • Orphan sweep β€” daemons left behind by a previous run are killed at startup, before anything new is spawned.

Three ways to drive it

  1. REST β€” /api/v3/daemons for per-daemon list/start/stop/restart/reload, and /api/v3/system/* for status, startup, shutdown, restart, logrot, and run state. The whole group is JWT-gated.
  2. Legacy socket β€” /run/zm/zmdc.sock speaks the zmdc.pl wire protocol, so existing scripts and tooling keep working unchanged.
  3. Run states β€” POST /api/v3/system/state applies a named row from the States table. The monitor changes and the state activation commit as one transaction; the daemon restart deliberately happens outside it.

Configuration

[daemon]
enabled = false            # passive; true = takeover
socket_path = "/run/zm"
socket_name = "zmdc.sock"
bin_path = "/usr/bin"      # zmc, zma
script_path = "/usr/bin"   # Perl daemons β€” searched per-distro if no match here
min_backoff_seconds = 5
max_backoff_seconds = 900
shutdown_timeout_seconds = 30
stats_update_interval_seconds = 60
enable_socket_ipc = true   # bind zmdc.sock
enable_rest_api = true     # expose the daemon routes

Every key takes an env override β€” APP_DAEMON__ENABLED=true is the one the takeover script writes.

Native replacements for the Perl maintenance daemons

Separately from supervision, several Perl daemons have Rust equivalents that run inside zm-api rather than as supervised processes. Each is independently switchable and all default off, so an existing install keeps running the Perl until you move over deliberately.

Config Replaces What it does
[maintenance.stats] zmstats.pl CPU/memory into Server_Stats, stale Monitor_Status eviction, event-window counters, expired session pruning
[maintenance.audit] zmaudit.pl (database side) orphaned Frames/Stats rows, events that never recorded a frame, events left open by a dead capture daemon, counter resync
[maintenance.audit.filesystem] zmaudit.pl (disk side) quarantines orphaned event directories β€” discovered by walking and identified from evidence inside them, never by deriving a path from a timestamp
[maintenance.telemetry] zmtelemetry.pl anonymous usage report, with no geolocation lookup

⚠️ Only one of a pair may run. In takeover mode the supervisor will not start the Perl counterpart of an enabled job; in passive mode ZoneMinder is still the supervisor, so disable the Perl daemon there yourself. The audit ships with dry_run = true; read a pass or two in the log before turning that off.

Going deeper: the takeover guide covers prerequisites, verification and rollback; the maintenance guide covers each Perl replacement in detail; and systemd units, permissions and the distro matrix are in docs/deployment.md.


🧰 Tech Stack

Language Rust (edition 2021)
Web framework Axum 0.8
Async runtime Tokio
ORM SeaORM 1.1 (MySQL/MariaDB)
API docs utoipa + Swagger UI
Auth JSON Web Tokens (jsonwebtoken)
Streaming webrtc, fMP4, HLS, retina (RTSP)
Media FFmpeg (ffmpeg-next) for H.264 β†’ JPEG

πŸš€ Quick Start

Install as a service (packages)

The fastest path. Packages install zm-api as a systemd service in passive mode β€” it serves the REST API alongside a running ZoneMinder without touching its daemons, so it's safe on an existing box. That's the on-ramp, not the end state.

sudo dpkg -i zm-api_*.deb        # Ubuntu 24.04 family (amd64, arm64)
sudo dnf install zm-api-*.rpm    # Fedora 41  (zypper on openSUSE Tumbleweed)

When you're ready, hand daemon supervision to zm-api β€” one native supervisor replacing zmdc.pl and zmwatch.pl, with restart backoff, database reconciliation, and daemon control over REST (this stops & disables zoneminder.service):

sudo zm-api-takeover             # --revert hands control back to ZoneMinder

What that changes, and everything the supervisor does once it is on: Daemon Control.

Build packages yourself with ./scripts/package.sh [deb|rpm|arch|all]. Full distro matrix, config, and TLS setup: docs/deployment.md.

Build from source

For development or platforms without a prebuilt package.

Prerequisites

  • Rust (current stable) β€” install via rustup
  • MariaDB / MySQL with a ZoneMinder schema
  • FFmpeg dev libraries β€” libavutil-dev libavcodec-dev libavformat-dev libavfilter-dev libavdevice-dev libswscale-dev libswresample-dev (Debian/Ubuntu) or brew install ffmpeg
# 1. Clone
git clone https://github.com/SteveGilvarry/zm-api.git
cd zm-api

# 2. Spin up a local test database (Docker)
./scripts/db-manager.sh start
./scripts/db-manager.sh mysql      # load the ZoneMinder schema

# 3. Build & run
cargo run                          # uses settings/base.toml
APP_PROFILE=prod cargo run         # production profile

The API comes up on the address/port from your active profile (see settings/).

Explore the API

Once running, open the interactive docs:

🧭 Swagger UI http://<host>:<port>/swagger-ui
πŸ“„ OpenAPI spec http://<host>:<port>/api-docs/openapi.json

βš™οΈ Configuration

Settings are layered, last one wins:

  1. settings/base.toml β€” defaults
  2. settings/{APP_PROFILE}.toml β€” dev Β· test Β· test-db Β· prod
  3. Environment variables β€” prefix APP_, nested keys use __
APP_PROFILE=prod
APP_DB__HOST=10.0.0.5            # overrides db.host
APP_CONFIG_DIR=/etc/zm-api       # alternate config directory

πŸ’‘ Local profiles like settings/dev.toml are gitignored β€” keep secrets out of version control.


πŸ§ͺ Testing

# Unit + non-DB tests
cargo test --all-features

# Full integration suite (needs the test database)
./scripts/db-manager.sh start && ./scripts/db-manager.sh mysql
APP_PROFILE=test-db cargo test --test '*' -- --include-ignored

# Coverage report
cargo llvm-cov --all-features --ignore-filename-regex '/(entity|migration)/' \
  -- --include-ignored

CI runs the suite on every push and gates line coverage β€” it can't regress below the floor. Currently ~62% and climbing (the CI floor is 60%), across 1,100+ unit and per-domain integration tests β€” roughly 850 run without a database, the rest need one.


πŸ“ Project Layout

src/
β”œβ”€β”€ routes/      Axum routers & middleware wiring
β”œβ”€β”€ handlers/    HTTP handlers
β”œβ”€β”€ service/     Business logic
β”œβ”€β”€ repo/        Database query layer
β”œβ”€β”€ entity/      SeaORM entities (generated from the ZM schema)
β”œβ”€β”€ dto/         Request/response DTOs
β”œβ”€β”€ streaming/   HLS / WebRTC pipelines
β”œβ”€β”€ ptz/         PTZ control drivers
β”œβ”€β”€ daemon/      ZoneMinder daemon supervision
β”œβ”€β”€ configure/   Config loading
└── error/       AppError + HTTP mapping
scripts/         DB management, JWT key generation, CI helpers
settings/        Layered TOML configuration
docs/            Deployment, TLS, PTZ & streaming design notes

🀝 Contributing

Before opening a PR, make sure the local quality gates pass:

cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features

Work tests-first, keep changes focused, and treat src/entity/ as generated artifacts. See CLAUDE.md for the full development workflow and conventions.


πŸ“„ License

zm-api is dual-licensed:

  • πŸ†“ Open source β€” AGPL-3.0. Free to use, modify, and self-host. If you run a modified version as a network service, the AGPL requires you to publish your changes.
  • πŸ’Ό Commercial license. For embedding zm-api in a closed-source product, or running a modified version as a hosted service without the AGPL's source-sharing obligation, a commercial license is available. Contact the maintainer to enquire.

Contributions are accepted under a Contributor License Agreement so the project can be offered under both licenses β€” see CONTRIBUTING.md.

The db/*.sql schema files are from ZoneMinder and remain under its GPL-2.0 license.


Built with πŸ¦€ for the ZoneMinder community.

About

Rust Axum Based API for ZoneMinder

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages