Commit Graph
3223 Commits
Author SHA1 Message Date
Isaac ConnorandClaude Opus 5.5 a3c9d5cf70 fix: start zmdc's daemons with zmdc's signal mask and log their signals correctly
start() blocks SIGCHLD (and since #5199 TERM, INT and ABRT) across the fork.
The child unblocks the stop signals, but SIGCHLD stayed blocked through exec,
so every daemon zmdc started ran with it blocked (SigBlk 0x10000 in
/proc/<pid>/status for zmc, zmfilter, zmstats and zmwatch). Nothing depends
on it today, but a daemon that waits for SIGCHLD would never get it. Restore
the pre-fork mask just before exec.

The reaper took the signal from a wait status as $status & 0xfe, dropping its
low bit, so TERM was logged as signal 14, KILL as 8 and SEGV as 10, and the
core dump flag as $status & 0x01. Use 0x7f and 0x80.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:39:00 -04:00
nicandClaude Opus 5.5 86b593dded fix: keep zmwatch from restarting a starting zmc and stop zmdc losing the stop (#5199)
* fix: give a monitor's new zmc a grace period in zmwatch before restarting it

zmwatch's startup grace counted from zmwatch's own start, so a zmc started
later (a monitor added or saved through the web UI or API, a zmdc restart)
had none. When a zmwatch pass landed in the seconds between that zmc being
forked and it mapping its shared memory, zmwatch logged "shared data not
valid" and asked zmdc to restart it, replacing a zmc that was about to come
up.

Count ZM_WATCH_MAX_DELAY per monitor from the pass that first found its
shared data missing or invalid, and forget it once the data is valid again
or after the restart. The zmwatch-start grace and the heartbeat checks are
unchanged, so a zmc that hangs after mapping its memory is restarted as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: don't lose a stop that zmdc sends to a daemon before it has exec'd

zmdc forks each daemon and, in the child, connects to the database, closes
every descriptor up to _SC_OPEN_MAX and only then resets TERM, INT and ABRT
to their defaults before exec. Until then the child runs zmdc's own
shutdown handler, which only sets $zm_terminate in the child, so a TERM sent
in that window is lost. The daemon then starts as if nothing happened, zmdc
waits KILL_DELAY (30 s) and KILLs it, and an event that zmc opened in the
meantime is left with no EndDateTime.

zmwatch's restart of a zmc that hasn't mapped its shared memory yet lands
in this window. Closing descriptors one by one takes about 1.7 s with a
1048576 open-file limit (Docker's default), which makes the window wide.

Block TERM, INT and ABRT across the fork along with SIGCHLD, and in the
child reset them to their defaults and unblock them first thing, so a stop
sent before exec ends the child and the reaper restarts or forgets it as
asked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf: close only open descriptors in zmdc's child before exec

The child closed every descriptor number from 3 up to _SC_OPEN_MAX one by
one. With a 1048576 open-file limit (Docker's default) that is about 1.7 s
of close() calls before each daemon starts, and it is the window in which
zmwatch can find a just-started zmc without shared memory.

Close the descriptors /proc/self/fd lists instead, and keep the old loop
where /proc isn't available.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:38:14 -04:00
nicandClaude Opus 5.5 51b04e94a4 fix: validate alarm packets in zmalarm-server.py (#5211)
* fix: validate alarm packets in zmalarm-server.py

The alarm server accepted any packet from any host and mapped it to a
monitor by the Address field inside the packet, so a host on the network
could raise an alarm or a zmtrigger event on any configured camera.
Every malformed packet (short header, non-JSON body, unknown address)
raised an uncaught exception that ended the process, and a client that
connected without sending blocked the accept loop indefinitely.

- only accept an alarm whose Address matches the address it came from
  and a monitor that uses that address
- read the header and body with a 5 s timeout, cap the body at 64 KiB,
  log and drop bad packets instead of exiting
- resolve camera hostnames in monitor Paths and skip monitors without a
  camera address instead of failing at startup
- pass the alarm description to the zmtrigger thread instead of reading
  a global, and talk to zmtrigger over a plain socket (telnetlib was
  removed in Python 3.13)
- honour the bare port number zmpkg.pl passes from ZM_OPT_ALS_PORT and
  add --bind= to choose the listening address (default unchanged)
- move the pyzm imports into main() so the parser can be tested; add
  tests/python/test_zmalarm_server.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: accept alarms from cameras behind NAT in zmalarm-server.py

The source check required the packet's Address field to equal the address
the packet came from. A camera behind NAT reports its LAN address there
while its packets arrive from the NAT address, so its alarms were dropped.

The Address field is no credential, so the monitor is now chosen by the
sender: an alarm is accepted only from an address that belongs to a
monitor, the camera address in its Path or one listed for it under
[alarmserver] in secrets.ini, and it goes to one of that address's
monitors. The Address field only picks among several monitors on one
address, for cameras behind a shared NAT address. A host that belongs to no
monitor still cannot raise an alarm, and a camera cannot raise one on
another camera's monitor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: cancel the forced alarms zmalarm-server.py raised when it stops

An alarm packet armed the monitor, slept 60 s in a thread and disarmed it.
When zmdc stopped or restarted the server inside that window (SIGTERM, which
Python's default handler ends at once) the disarm never ran and the monitor
stayed in forced alarm, recording, until someone cancelled it by hand.

Keep the forced alarms the server raised in one place, one per monitor: a
new alarm for a monitor in forced alarm arms it again, as every packet did,
and moves its end instead of starting a second timer that would cut it
short. On the way out, SIGTERM included, the server cancels every alarm
still running and exits 0, so zmdc no longer logs the stop as abnormal. An
arm that fails is logged instead of ending the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:08:59 -04:00
Isaac ConnorandClaude Opus 5 6c0f976c22 refactor: move the presentation settings to the Display tab
The skin and css, the language and locale, the date, time and datetime
formats, the default bandwidth and the snapshots feature switch all
decide how the interface looks or what it offers, which is what that tab
is for, but they sat under System among the shutdown command, the shm
key and the audit intervals. System drops from 42 entries to 33 and
Display goes from 32 to 41.

The snapshots switch is read only by the skin: it gates a button in
montage and montage review, a menu item, and the snapshot views and
their permission checkboxes. Nothing in the daemons or the perl scripts
looks at it.

The category key is still 'web'. That is the tab now labelled Display;
the tab whose key is 'display' holds the per-session cookie settings and
stores no configuration at all, so a stored default does not belong
there. The hint setup for the moved entries -- the installed skin list,
the css list for the current skin, the bandwidth options, and the
language and locale lists -- moves to the same branch.

ZM_TIMEZONE stays under System. It reads like a presentation setting,
but Filter.pm uses it for date arithmetic when evaluating filters, so it
changes behaviour and not just what is displayed.

A migration, 1.39.40, is needed as well as the ConfigData change, because a
version upgrade does not rewrite Config: zmupdate only reloads it from
ConfigData under --freshen, which is mutually exclusive with an upgrade
run. Fresh installs take the new category from the generated
zm_create.sql.

Verified both paths converge on 33 system and 41 display entries with
identical membership: a fresh install of this branch, and a copy of a
real pre-change Config table with the migration applied. On the running
install the bandwidth, language and locale entries still render as their
pickers on the new tab, css as its list of choices, and the timezone
picker is still on System with its full list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
2026-10-10 09:32:17 -04:00
Isaac ConnorandClaude Opus 5 faa8d2f20c fix: make zmcamtool --export use the configured database and keep every value
Two problems, both in exportsql.

The mysqldump command ended with a hardcoded database name, so --export
always read from a database called zm whatever ZM_DB_NAME said. It now
uses the configured name.

The primary keys were then blanked with a regex that replaced everything
between VALUES ( and the first quoted string:

  s/VALUES \((.*?),'/VALUES \(NULL,'/ig

That holds only if the id is immediately followed by a quoted value. A
MonitorPresets row starts id,ModelId and both are unquoted, so the
pattern swallowed the pair into a single NULL and left the row one value
short of its columns. On the shipped data that corrupted all 75 preset
rows of a 126 row export; the 51 control rows came through intact, which
is presumably why it went unnoticed.

Replaced with the same parse the import now uses: split the values on
top level commas, find the id by column name, and blank just that one,
re-emitting the row in the form it arrived in. A row whose value count
does not match its table is left alone with a warning rather than
rewritten on a guess.

Verified against a real dump of the seed data: 126 rows, every one with
its id blanked, and not one value-count change -- against 75 corrupted
by the old pattern. Round-tripped the export back through --import into
an empty database: 51 controls and 74 presets, no broken names, no
unresolved control references, and every column identical to the source
apart from the one preset row whose name is a duplicate of another,
which the import has always collapsed because it keys on Name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
2026-10-10 09:32:17 -04:00
Isaac ConnorandClaude Opus 5 c0e908f5d7 fix: make zmcamtool --import find and parse the seed rows correctly
The import read zm_create.sql line by line and pulled the Controls and
MonitorPresets rows out with a regex. Two things were wrong with that.

It found no controls at all. The seed rows moved to db/controls.sql,
which zm_create.sql only pulls in with a `source` directive, and the
loop did not follow it, so --import silently stopped importing ptz
controls entirely.

The regex also mis-parsed the rows it did find. It assumed every INSERT
was positional with the id first and the name straight after, then
replaced everything before the first ,' with NULL. Ten of the shipped
controls are written with an explicit column list, where the first value
is the name, so it overwrote the name with NULL and keyed the row on the
protocol instead:

  IN : INSERT INTO `Controls` (`Name`,`Protocol`,`CanReset`)
                       VALUES ('Amcrest ASH42-B RPC','Dahua_RPC',1);
  OUT: INSERT INTO `Controls` (`Name`,`Protocol`,`CanReset`)
                       VALUES (NULL,'Dahua_RPC',1);

It also happened to work for MonitorPresets only because the two columns
before the name are numeric and unquoted.

Replaces it with a parser that reads the column list when there is one
and asks the database for the column order when there is not, so nothing
depends on the name sitting at a fixed offset -- it is the second column
of Controls but the third of MonitorPresets. Values are split on top
level commas only, leaving quoted strings and parenthesised subqueries
intact, which matters now that a name may contain a comma and a preset
resolves its control with a SELECT. The id is blanked wherever it
appears so the row still gets a fresh one, and a row whose value count
does not match the table is skipped with a warning rather than inserted
crookedly.

`source` is followed, relative to the including file, so the controls
come back. A file that cannot be read warns instead of dying, since
zm_create.sql sources several that are optional.

Verified against the real seed data: 51 controls parsed where it
previously found none, 74 presets, no row imported with a null or empty
name, the ten explicit-column controls landing with their name and
protocol intact, every preset's control subquery resolving, and names
containing commas preserved. Exercised the splitter directly on a
positional row, an explicit-column row, a row with both a comma in the
name and a subquery, and a doubled-quote escape. Round-tripped a
mysqldump export back through the import. Generated script passes
perl -Tc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
2026-10-10 09:32:17 -04:00
Isaac ConnorandClaude Opus 5 1043273495 refactor: drop the unused Type column from Controls
Controls.Type looks like the monitor type a control applies to, and has
been kept in step with the Monitors enum since 1.27, but nothing has
ever read it. The control dropdown on the monitor page lists every row
by Name, no query filters on it, and no code compares it to the
monitor's own Type. It was a field the edit form asked for and the
control list displayed back.

The column appears to be MonitorPresets.Type copied by analogy, where
the idea is real: that one decides what a preset creates. That also
explains why every migration altering one altered the other.

Removes the column from the schema, adds the 1.39.39 migration, the edit form row
and its type list, the control list column, the defaults in Control.php
and Control.pm, and the value from all 53 shipped rows in controls.sql.
This supersedes the 1.39.38 retyping of three libvlc controls to Ffmpeg:
the column they were being retyped into is gone.

zmcamtool needs no change -- its import regex rewrites the Id and
captures the Name, so it is not positional past that, and its export is
a mysqldump that follows the live schema.

Dropping Type left the two shipped Pelco rows byte-identical to their
twins, which existed only as a Local and an Ffmpeg copy of the same
control, so the redundant copies are removed: 53 shipped rows become
51, with both Pelco-D and Pelco-P still present once.

Existing installs are deliberately left with their duplicates. The same
pair collapses there too, but deleting rows a monitor's ControlId may
point at, to tidy a repeated name in a dropdown, is not a trade a
migration should make silently.

Verified by loading the old and rewritten controls.sql into scratch
databases and diffing every remaining column across all 53 rows: no
value shifted, which is the failure mode when a positional INSERT loses
a field. The file mixes positional and explicit-column INSERTs, so both
forms were handled. Fresh-install and migrated Controls schemas compare
equal, the shipped data loads into both, and Monitors and MonitorPresets
keep their Type. Build clean, 147 test cases pass, php -l and perl -c
clean on the edited files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
2026-10-10 09:32:17 -04:00
Isaac ConnorandClaude Opus 5 bf8a73e9fb feat: remove Libvlc from the web ui, api and config help
Follows the daemons: no monitor can be type Libvlc any more, so the ui
should not offer it or carry text describing it.

Drops the source-type option and its deprecation notice, the Libvlc arm
of the Source Path and console source-column branches, the entry in the
control-capability type list, and the value from the api's Type
validation list. The OPTIONS_LIBVLC help block goes from all 25 language
files: monitor.php builds that key as OPTIONS_ plus the monitor type, so
it became unreachable the moment the type did. The Libvlc source-type
label goes from the 22 files that carried it.

SourceTypeDeprecated and Deprecated lose their last caller and are
removed. RemoteRtspDeprecated and Removed stay -- the rtsp protocol
option is still shown to monitors already set to it.

The WEB_FILTER_SOURCE help listed the affected source types; corrected
in ConfigData.pm.in and in the one translation that had localised it.

All 25 language files and the four edited php files parse clean under
php -l, and the config module parses under perl -c. No javascript
changed, so eslint has nothing to say here -- it cannot run in this
worktree anyway, which has no node_modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
2026-10-10 09:32:17 -04:00
Isaac ConnorandClaude Opus 5 af0d6ddc32 refactor: remove RTSPDescribe, which lost its reader with the rtsp client
RTSPDescribe chose whether to take the media url from the DESCRIBE
response rather than the configured path. That was a quirk of
ZoneMinder's own rtsp implementation; ffmpeg decides it itself. Removing
that client left the setting with nothing behind it -- the monitor form
still drew the checkbox and the save path still wrote the column, but no
code read it, so the only thing it could do was mislead whoever ticked
it.

Drops the form row and the Remote-only block that existed to hold it,
the javascript that showed and hid it as the protocol changed, the field
in the php and perl monitor models, the default in the save action, the
column from the schema, and the label and help text from all the
language files carrying them.

The 1.39.37 migration drops the column behind an information_schema
guard scoped to DATABASE(), so the file stays re-runnable.

Verified by building a pre-upgrade database from upstream/master's own
schema and applying the migration: the column goes, and Monitors matches a fresh install of this
branch column for column. Migration applied three times with no error.
php -l clean on the four edited files and all 28 language files, perl -c
clean, build clean, 154 test cases pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
2026-10-10 09:32:17 -04:00
Isaac ConnorandClaude Opus 5.5 0e9f82513c fix: bind the FOSCAMR2C preset and require Control permission for PTZ refs GHSA-qcm7-vq92-f86f
FOSCAMR2C presetGoto and presetSet interpolated the preset into
"SELECT Label FROM ControlPresets WHERE Preset = $preset" and sent the selected
Label to the camera as name=. For presetGoto the preset came straight from the
web request, so any user who could see the monitor could run a SELECT against
the ZoneMinder database and have the value (the auth hash secret, password
hashes) delivered to the camera link, or use it as a boolean oracle.

- FOSCAMR2C: bind the preset in every statement, refuse a preset that is not a
  number, and look ControlPresets up by MonitorId as Dahua and PSIA do.
- buildControlCommand(): refuse a non-numeric presetGoto preset, and a control
  name that is not a plain method name. zmcontrol calls the name as a method and
  the options are split back out of the command string on spaces.
- zmcontrol.pl: refuse a command that is not a plain method name, so a qualified
  Package::sub name can't call a sub outside the control module.
- canView()/canEdit() with a monitor id returned only the monitor check and
  ignored the area, so canView('Control', $mid) passed for anyone who could see
  the monitor. Require the area permission as well; Monitors stays decided by
  visibleMonitor()/editableMonitor().

tests/php/test_control_command.php covers buildControlCommand() and the
monitor-scoped canView()/canEdit(); tests/perl/test_foscamr2c_preset_sql.pl
checks the statements FOSCAMR2C prepares and what it sends to the camera.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:21:32 -04:00
Isaac ConnorandClaude Opus 5.5 a41eac5d95 fix: do not apply migrations newer than the installed ZoneMinder
zmupdate.pl applied every zm_update-*.sql at or above the database's
version, with no upper bound. Migration files from a newer release left
in the update directory, after a downgrade or with a newer build's files
installed over an older one, were applied to a schema they were not
written for. Installing a 1.38 build over a 1.39 one ran the 1.39.0 to
1.39.5 migrations against the 1.38 database before one failed.

Skip, and say so, any migration newer than ZM_VERSION.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:34:50 -04:00
Isaac ConnorandClaude Opus 5.5 0d6ebcd42c fix: accept ZM_GO2RTC_PATH with or without its /api suffix
go2rtc serves its API under /api, and its websocket at /api/ws. zmc
appends /streams to ZM_GO2RTC_PATH and the browser appends /ws, so the
setting only worked if it ended in /api. The natural value to enter is
the server address, http://host:1984, and with that zmc reached
/streams, which is go2rtc's RTSPtoWeb compatibility endpoint, so no
stream was ever registered, and the browser's /ws was a 404. Where
another player was available the failure went unnoticed.

Normalise the setting where URLs are built from it: a trailing slash is
dropped and /api is added unless the path already ends in it, which also
covers go2rtc behind a reverse proxy at a sub-path.

- Go2RTCApiEndpoint() in zm_utils, used by zmc's Go2RTCManager.
- go2rtcApiUrl() in skin.js, used by MonitorStream and the console
  thumbnail stream. It assigns the path once, since stripping the slash
  from a bare host's "/" and appending gave "//api", as the old
  pathname += '/ws' gave "//ws".
- The option's help says the suffix is optional.

Catch2 and node tests cover bare hosts, trailing slashes, an existing
/api, a proxy sub-path and credentials in the URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:34:04 -04:00
Isaac ConnorandClaude Opus 5 24b4b389cd fix: record the patch's own version in ZM_DYN_DB_VERSION
The patch loop writes ZM_DYN_DB_VERSION after each file so that a run
that dies partway leaves a record of how far it got. It wrote $version,
the version the database started at, which the loop never changes, so
the write was a no-op: a failed upgrade left the original version in
place and the rerun started again from the beginning, re-running every
patch that had already applied.

Write $v, the version of the patch that was just applied. A run that
completes still ends up at ZM_VERSION from the cascade write below.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLYswrguKc6bBYL8JZbpmZ
2026-10-05 17:57:06 -04:00
Isaac Connor 106e72fb56 Merge branch '5164-frames-composite-pk' 2026-10-02 15:01:48 -04:00
nicandClaude Opus 5.5 ad3d5ec54d fix: random per-user salts in zmupdate.pl password migration, rehash migrated passwords on every login (#5167)
* fix: give each migrated password its own random bcrypt salt

migratePasswords() stored the Bytes::Random::Secure object rather than
bytes from it, so en_base64() encoded the string
"Bytes::Random::Secure=HASH(0x...)". bcrypt only reads the first 22
characters of the salt, all from the constant class name, so every user
on every install got the same salt. With neither Bytes::Random::Secure
nor Data::Entropy installed the salt was empty and bcrypt() died with
"bad bcrypt settings", aborting zmupdate.pl. Even the Data::Entropy path
reused a single salt for every user in the run.

Read 16 bytes per user from /dev/urandom, as generateAuthHashSecret()
in ZoneMinder::Config already does. If it can't be read, leave that
user's legacy hash in place, which auth.php still verifies, instead of
writing a weak one. This drops the need for Bytes::Random::Secure and
the deprecated Data::Entropy. refs #4333

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: drop the Data::Entropy dependency from packaging

zmupdate.pl no longer uses Data::Entropy, which upstream has deprecated
(CVE-2025-1860). refs #4333

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: rehash legacy passwords on every password login

Only the login form called migrateHash(), so accounts that sign in with
user=/pass= or through the API kept their mysql or zmupdate.pl
(mysql+bcrypt) hash, including those with the fixed salt. Call it from
those paths too, and have it check the password type itself.

migrateHash() also regenerated the auth hash from the in-memory user,
which still held the old password, so getAuthUser(), which checks
against the new one in the database, rejected it. Update the in-memory
password first, and update the row by Id rather than by the username
as typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: note passwords still on a zmupdate.pl-migrated hash

Every salt migratePasswords() used before this branch was shared by all
users in a run: from Data::Entropy, which before 0.008 keys its generator
from rand() (CVE-2025-1860), or, since 38c0f743 with Bytes::Random::Secure
installed, a constant. The original hash input is gone, so zmupdate.pl
can't rehash these, and nothing distinguishes them from properly salted
ones. Log a warning listing every user still on a -ZM- hash, noting that
it is upgraded at their next login or password reset, and that unused
accounts can be disabled or deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 08:30:43 -04:00
Isaac ConnorandClaude Opus 5.5 c4ef3fdd62 feat: key Frames by (EventId, FrameId) and drop the surrogate Id refs #5164
Every query on Frames filters on EventId and orders or ranges by FrameId,
and nothing needs a frame by Id. The Id primary key was a clustered index
nothing read, and EventId_FrameId_idx was a secondary index every query
used. On a copy of a local table that secondary index was half the
table's size. With (EventId, FrameId) as the primary key the index is
gone, and an event's rows are stored together, so deleting an event is a
range delete.

zm_update-1.39.36.sql:
- converts AI_Detections.FrameId from Frames.Id to the per-event frame
  number, drops its foreign key to Frames and indexes (EventId, FrameId).
  A composite foreign key cannot replace it: ON DELETE SET NULL would
  have to null the NOT NULL EventId, and Frames rows are written in
  batches, so a detection can be recorded before its frame row.
- removes duplicate (EventId, FrameId) rows, keeping the earliest.
- rebuilds Frames with the new primary key.
- removes ON UPDATE CURRENT_TIMESTAMP from Frames.TimeStamp. Any UPDATE
  of a frame row was overwriting its capture time.
Each step checks the current schema first, so the migration can be
re-run.

REST API: view, edit and delete take /frames/<action>/<EventId>/<FrameId>.json.
The old single-Id URLs return 404. CakePHP 2 has no composite keys, so
the model's primaryKey is EventId. That keeps Event's dependent cascade
delete limited to the event's own frames. The controller writes with
explicit (EventId, FrameId) conditions instead of save(), which would
match rows on EventId alone. Edit no longer changes EventId or FrameId.

view=image with fid but no eid used to look up Frames.Id. It now returns
404.

The Perl Frame class is identified by (EventId, FrameId).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:48:26 -04:00
Isaac ConnorandClaude Opus 5.5 e99a0531a1 fix: replace and refuse the shipped ZM_AUTH_HASH_SECRET refs GHSA-wmcc-x64g-jr84 GHSA-p2c6-jw8f-ffjc
ZM_AUTH_HASH_SECRET signs the JWT access and refresh tokens, and its
default is a fixed string in the public source. Nothing generated a
per-install value and tokens signed with the default verified normally,
so on an install with auth on and the secret untouched anyone could sign
an admin token and be accepted by the web UI, the API and zms.

- ZoneMinder::Config::saveConfigToDB() now replaces an empty or default
  secret with 32 random bytes from /dev/urandom, hex encoded. Package
  installs and upgrades run zmupdate.pl -f, which saves the config, so
  existing installs get a secret on upgrade. A secret the admin set is
  left alone.
- validateToken() in PHP and zmLoadTokenUser() in C++ refuse to verify
  tokens while the secret is empty or the default, and the API refuses to
  issue them, as it already did for an empty secret.
- zmLoadTokenUser() no longer writes the signing key to the debug log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:47:22 -04:00
Isaac Connor 84967e9b52 fix: treat event DefaultVideo and Name as filenames in the perl scripts
GenerateVideo used DefaultVideo as the ffmpeg input relative to the event
directory and built the output file from Name with only whitespace
replaced, so an Events=Edit user could make zmvideo read or write any
path the account can reach. zmfilter's generateImage passed
event_path/DefaultVideo to ffmpeg -i, and the %EV% email tag attached
event_path/DefaultVideo, which could mail out any readable file.

Strip everything up to the last path separator from DefaultVideo in all
three places, and reduce Name to [-A-Za-z0-9_.] with no leading dot
before using it as the video filename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 5904023f13448e557dc205edb9440972e4a6482d)
2026-09-24 23:16:11 -04:00
Isaac Connor 2cd0863922 fix: keep filter AutoExecute tag values away from shell parsing
executeCommand substituted %TAG% placeholders in AutoExecuteCmd with raw
event and monitor fields and ran the result with qx(), so the shell parsed
those values. Several are writable by non-admin users: event Name, Cause
and Notes (Events=Edit via ajax/event.php and the API), event tags
(Events=Edit), monitor Name (Monitors=Edit), and Cause/Notes are also set
by zmtrigger clients on TCP 6802. An event named $(cmd) therefore ran cmd
as the web/zm user whenever an admin's AutoExecute command used %EN%,
%EC%, %ED%, %ETAGS%, %MN% and similar tags.

Each tag is now substituted on its own. Values made only of word
characters, spaces and /.,:+=@%- are inserted as before, so commands using
%EID%, %MID%, %EPATH% and similar keep working unchanged. Any other value
is exported as an environment variable ZM_TAG_n and referenced in a form
the shell expands without re-parsing, chosen from the quoting context the
admin placed the tag in: "${ZM_TAG_n}" when bare, ${ZM_TAG_n} inside
double quotes and '"${ZM_TAG_n}"' inside single quotes. Such a value
now always reaches the command literally; a bare one is no longer split
into several words.

substituteTags itself is unchanged, so email and message bodies behave
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b9891a4a443f1f5e0d8faa7026518a7463ec9440)
2026-09-24 23:16:11 -04:00
Isaac Connor 9f3f6c6770 fix: only trust X-Forwarded-For from configured proxies for auth hash IPs
With ZM_AUTH_HASH_IPS on, the client address bound into the auth hash was
taken from the left-most X-Forwarded-For value whenever the header was
present, both when PHP generated the hash (getRemoteAddr()) and when PHP or
zms validated it (getAuthUser(), zmLoadAuthUser()). The header is client
controlled, so anyone holding a leaked hash could replay it from anywhere by
sending the address it was bound to.

Add ZM_AUTH_TRUSTED_PROXIES, a list of exact reverse proxy addresses.
X-Forwarded-For is now used only when REMOTE_ADDR is one of them, and is read
from the right, skipping hops that are themselves listed proxies, so values a
client prepends are never chosen. With the option empty, the default, the
header is ignored and REMOTE_ADDR is used.

PHP (web/includes/Network.php getRemoteAddr()) and C++ (ClientAddress() in
zm_utils, used by zmLoadAuthUser()) implement the same rule so generation and
validation continue to agree. Every PHP caller already routes through
getRemoteAddr(), so session.php and auth.php need no change.

Reverse proxy users who enable ZM_AUTH_HASH_IPS must list their proxy in the
new option; until they do, hashes bind to the proxy address, which still
validates but no longer distinguishes clients. This is the behaviour change
that the #4921 work avoided by trusting the header.

The option is added through ConfigData only, like other recent options;
zmupdate.pl --freshen inserts it, zms falls back to the compiled-in default and
PHP treats an undefined constant as empty, so no schema migration is needed.

Tests: ClientAddress Catch2 case; tests/php/test_remote_addr.php updated for
the trusted-proxy rule.

refs GHSA-72rf-54rm-798c

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit acea5889dec0826f595cb736147a5fdc9c94a2a9)
2026-09-24 19:51:27 -04:00
Isaac Connor 093b24a530 fix: extract filter email frames from the event video without a shell
GHSA-pxq8-5c8j-xf3r reports the event Name reaching a shell through
Event::GenerateVideo. That sink is already closed by "fix: run the event
video encoder without a shell" (GHSA-pfph-4j9j-7cv7), which runs ffmpeg as a
list. Reviewing the rest of the Perl video path turned up one more place an
event field reaches a shell: zmfilter's generateImage, used to attach frames
to filter emails and messages when no capture jpeg exists, built

  ffmpeg -nostdin -ss <Delta> -i '<event path>/<DefaultVideo>' ...

and ran it with qx(). DefaultVideo is settable by an operator with
Events=Edit, so a single quote in it ends the quoted argument. The -r check
before it means the named file has to exist, but GenerateVideo itself will
create one whose name is the event Name, so the two fields together are
enough. Driving generateImage directly with DefaultVideo set to
"a';touch${IFS}<marker>;'.mp4" (and that file present) created the marker
before this change.

ffmpeg is now run through a list-form piped open, so there is no shell and
the path is one argument whatever it holds. stdout is still collected for the
debug log as qx() did; if ffmpeg cannot be started, status is reported as
127, as the shell did for a missing command. With the change the same input
passes the literal path to ffmpeg and creates no marker.

See GHSA-pxq8-5c8j-xf3r.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0f700d30e8b5e67705243029b57005236ccbc3ff)
2026-09-24 19:48:40 -04:00
Isaac Connor ce485022d2 fix: validate filter attributes and operators and quote their values
zmfilter.pl rebuilds SQL from the JSON the web tier stores verbatim in
Filters.Query_json, so a saved filter's attribute, operator and value all
arrive at the statement exactly as they were written. None of the three were
checked here, while web/includes/FilterTerm.php checks all three: values were
wrapped in quotes by hand, which a value containing one closes; the final
attribute branch concatenated 'E.'.$attr; and the generic operator branch
emitted the operator as written.

Any account that can save a filter could therefore have the daemon run SQL of
their choosing with its own credentials, against a table set that includes
Users. The daemon also deletes and moves events, so this is not only a read.

Confirmed against the module before the change, building SQL from stored terms
and checking whether the payload landed outside a string literal:

  val  = "x' OR 1=1 -- "        -> outside, injected
  attr = "Id=1 OR 1=1 -- "      -> outside, injected
  op   = "= 1 OR 1=1 -- "       -> outside, injected

Attributes now have to match what the PHP accepts, operators have to be one of
the sixteen the UI can produce, and a filter carrying anything else is refused
rather than run. Values go through DBI's quote().

Numbers are left unquoted so the statements the existing filters produce are
unchanged: a bare number cannot carry SQL, and quoting it would have altered
every numeric comparison in the file for no gain. Verified by generating the
SQL for seven representative filters before and after -- purge-when-full,
delete-after-a-day, LIKE on a name, IN on monitors, a monitor name, a score
range and an IS NULL -- and diffing: identical apart from the timestamp a
relative date resolves to.

After the change the three payloads above are contained in a literal, or the
filter is refused.

See GHSA-p8h3-4x5c-cv7p.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBHBB95RBX7D9p8ge2WDZb
(cherry picked from commit bc11642fea844bc86bac0080da42e4514d5a4d76)
2026-09-24 19:44:16 -04:00
Isaac Connor 279e467b95 fix: run the event video encoder without a shell
Event::GenerateVideo built an ffmpeg command line as a string and ran it
through qx(), and two of the values in it are event fields an operator with
Events=Edit can set through the API. DefaultVideo was interpolated with no
quoting at all. The name behind the output filename only has its whitespace
replaced, so a single quote in it closed the quoting that was there. Either one
gave arbitrary command execution as the account the daemons run as.

Both confirmed against the module before the change, driving GenerateVideo
directly:

  DefaultVideo = "x.mp4; touch <marker>; echo"     -> marker created
  Name         = "evt';touch${IFS}<marker>;echo'"  -> marker created

The second needs ${IFS} rather than spaces because the name has whitespace
substituted before it is used, which is the whole of the sanitising that was
being relied on.

The command is now a list passed to exec, so there is no shell to escape from
whatever those fields hold; ffmpeg's own output still lands in the event's
ffmpeg.log. The two configured option strings are admin-set and hold several
options each, so they are split on whitespace to become separate arguments;
shell quoting inside them is no longer honoured.

After the change both payloads run ffmpeg with the injection as a literal
argument and create no marker.

See GHSA-pfph-4j9j-7cv7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBHBB95RBX7D9p8ge2WDZb
(cherry picked from commit 5fc491728c679bc7a8ff4b51183983e7fe184200)
2026-09-24 19:44:16 -04:00
Isaac Connor 6442f404dc Merge branch 'openbsd-support' 2026-09-24 19:34:18 -04:00
ovi-vansidandClaude Opus 5.5 276eba92e6 fix: use su -s /bin/sh USER -c for running as the web user refs #5152
The long options --shell/--command are util-linux only. OpenBSD su takes
-s for the shell and passes arguments after the login name to it, and
util-linux su accepts the same form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:01:21 -04:00
Isaac Connor 8f5a0af862 Merge pull request #5143 from SteveGilvarry/feature/stream-socket-events
Replace the per-monitor media FIFOs with a unix stream socket
2026-09-23 18:17:50 -04:00
Isaac ConnorandClaude Opus 5 428c048bac feat: measure the audio level on demand, with a meter in the editor
Reverts the previous commit's always-on measurement. Decoding audio for every
monitor that has it spends CPU on a number almost nothing reads, which is the
wrong trade even though it did solve the chicken and egg of picking a
threshold without ever seeing a level.

Measure when something is actually going to use the reading instead:

 - AudioDetection is on, as before, so nothing changes for a monitor that
   scores on audio; or
 - somebody asked. SharedData gains audio_level_until, a wall clock second
   the capture thread keeps measuring up to. The monitor editor's new level
   meter pushes it forward while it is on screen and the measurement lapses a
   few seconds after the page is left, so nothing has to send a stop and a
   crashed browser cannot leave a monitor decoding forever.

When the reading stops being wanted the decoder is released and the published
level and peak are cleared, so a stale number is not left looking current and
an old peak does not land on the next frame row written.

audio_level_until is carved out of analysis_pad rather than appended, so
SharedData stays 888 bytes and no existing offset moves; the static_asserts,
Memory.pm and Monitor.php are updated together and all three now agree the
field is at +880.

The meter itself is on the audio settings, shown whether or not
AudioDetection is checked, because the level is what you need in order to
choose a threshold. It draws the threshold currently in the input as a mark on
the bar so a reading can be judged against it before saving, and a monitor
whose zmc is not running reads "no reading" rather than a confident 0, which
would be indistinguishable from silence.

Frames.AudioLevel is therefore 0 again on monitors that do not score on audio.
That is what the graph already treats as "no audio data", so it draws no line
rather than a flat one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:44 -05:00
Isaac ConnorandClaude Opus 5 16a2831fe6 fix: weigh a monitor's Importance when logging that a device is unreachable
A speaker that has dropped off the network fails on every command until
somebody fixes it, so that is the one error in IPSpeaker that repeats forever
rather than once. On a monitor deliberately marked unimportant it is noise,
and it buries the failures worth acting on.

Nothing about that is specific to speakers, so the policy goes in Logger as
importanceLevel, with ErrorImportance and WarningImportance alongside the
plain Error and Warning for callers to use. It takes the importance value
itself rather than a monitor, so Logger needs to know nothing about monitors
and callers that have some other notion of how much something matters can
still use it.

zmwatch.pl has been open coding the same idea as
WARNING+$monitor->ImportanceNumber() in three places; it now calls
WarningImportance instead, which is the same arithmetic and so leaves its
levels exactly as they were, including the Not important case that lands in
DEBUG1. That case is pinned by a test rather than quietly corrected: it is
long standing behaviour and not this change's business. zmwatch no longer
needs the logger object it was keeping for logPrint, so logInit() is called
bare there as it is in every other script.

Anything that is not a number counts as Normal, so a caller with no monitor
to ask still reports in full: not knowing how much a monitor matters is no
reason to hide its faults.

IPSpeaker is then a one line change at the call site. Only the failure to
reach the device is weighed; a refusal or unparseable content means the device
answered and something is really wrong, which is worth an error however
unimportant the monitor is, and does not repeat the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 8b14fba2db fix: bound the IPSpeaker http timeout instead of leaving it at LWP's default
LWP::UserAgent defaults to 180 seconds. Actions for a monitor are serialised
through one control daemon, so a speaker that has dropped off the network holds
that daemon for three minutes per attempt and every command queued behind it
waits. AMLink already sets a timeout; this did not.

Ten seconds, matching AMLink. The speaker answers in milliseconds when it is
reachable at all, so this only ever bounds the case where it is not - which is
the case right now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 70836e7c83 fix: recognise the AMLink plain-text reply that means the session expired
The camera does not report an expired session as a JSON error. It answers with
a bare printable string, "Invalid session in request", where a masked base64
payload belongs. Unmasking that and base64 decoding it produces noise, so a
routine timeout was logged as "malformed JSON string ... at character offset
0" - which named neither the session nor the camera's own explanation.

Detect it before unmasking, log what the camera actually said, and treat it as
a lost session so the existing recovery re-establishes it. session_error is
pure so the wording seen on the wire is pinned by a test rather than by a
camera happening to be in the right state.

refs #4423

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 b4599c16f2 fix: keep the control session alive while a monitor is busy, not only while idle
zmcontrol sent keepAlive only from the branch that runs when select() times
out. A monitor taking a steady stream of commands never reaches that branch,
so it never pinged at all, and cameras that expire a session on a timer
refresh it on the keepAlive rather than on ordinary requests. The monitors
with the most traffic were therefore the ones that lost their session.

Measured on monitor 1: it pinged twice while idle, then took a command every
three seconds for three minutes and the camera rejected the next one 181
seconds after the last ping. Its light commands were being dropped for as long
as the motion kept coming, which is exactly when they matter.

Move the decision into Control::keepAliveDue, timed from the last ping, and
check it at the top of the loop so the 'next' paths in the command handling
cannot skip it. A clock step backwards resets the baseline rather than holding
the ping off until real time catches up.

refs #4423

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 78ed0fee3c fix: re-establish the AMLink session when a reply cannot be decoded
An undecodable reply means the camera and this module no longer agree about
the session, but nothing put that right. Dahua_RPC re-logs-in only when it gets
a parseable error back, so rpc_call returning undef left the session poisoned:
in the logs a CoaxialControlIO.control failure is followed by every later
keepAlive and logout on that session failing the same way, until something else
forces a login. A light switched on by an alarm stays on for that whole period.

Split the single attempt out as rpc_once, recording why it failed, and have
rpc_call re-login and retry once when the failure was a decode failure on a
masked Request.

The guards matter more than the retry:
- bootstrap channels never recover, since login() issues them
- login() calls logout(), a Request on the dead session, so a re-entrancy guard
  stops its own decode failure starting another recovery
- one retry only, and a 5 second backoff, so a camera that always answers
  unreadably is not met with a login per command

refs #4423

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 c27f905470 fix: keep the AMLink decode diagnosis out of the part of the message that is discarded
The decode failure diagnostic appended its fields after $@, which ends in a
newline, so the payload length, alignment, unmasked-decode result and leading
bytes were pushed onto a second line where nothing looked for them.

$@ was also read after the unmasked-decode eval had already reset it, so the
error reported was that eval's outcome rather than the failure being described.

Add log_safe to flatten a message before logging, use it for the three places
that interpolate $@, capture the real error before running another eval, and
report the decoded byte count and the decoded leading bytes - the base64 alone
does not say whether what failed to parse was truncated or simply not ours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-20 18:17:43 -05:00
Claude d5012d5bc0 docs: document the shm field retirement convention for reserved_path2
The audio_fifo_path field was retired alongside video_fifo_path when the
media FIFOs were replaced by the stream socket. video_fifo_path was
repurposed as stream_socket_path, but a single socket carries both
streams so there is no separate audio path to publish; reserved_path2
stays reserved.

Spell out the convention where the field is defined, so a future reader
does not remove or reorder it (which would shift every later field for
out-of-tree shm readers) and knows the slot is free to reuse at the same
64-byte width. Align the PHP Monitor object and ZoneMinder::Memory notes.

refs #5143

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4UcdJLt1bxwdpcigGxZRD
2026-09-19 23:03:14 +00:00
Claude 89837a6b40 fix: start the monitor stream socket before capture connects
Address monitor-side stream socket review findings:

- zmc starts the stream socket before the first connect attempt, and
  SendStreamHealthEvent records the health state even when the socket is
  not up yet, so connection/prime faults during startup are observable to
  a consumer instead of being lost until the first successful prime.
- PrimeCapture announces audio whenever the camera has a decodable audio
  stream (Capture forwards audio packets unconditionally), and clears a
  previously announced stream when a re-prime no longer sees it.
- Publish the media stream socket path in the monitor shared-memory
  block (reusing the retired video_fifo_path field as stream_socket_path,
  same offset and size) so consumers discover it without hard-coding the
  convention or reading the producer's zm.conf; the PHP Monitor object
  and the ZoneMinder::Memory Perl module expose the renamed field.
- Move the wall-clock microseconds helper out of zm_monitor.cpp into
  zm_time.h as SystemClockMicros(), where time helpers belong.

refs #5143

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4UcdJLt1bxwdpcigGxZRD
2026-09-19 23:00:14 +00:00
SteveGilvarryandClaude Fable 5 94eeec6c2a refactor!: retire fifo path fields from SharedData
Rename video_fifo_path/audio_fifo_path to reserved_path1/2, keeping the
struct layout and 864-byte size unchanged so existing shared memory
mappers (and out-of-tree shm readers) keep working through the
transition. The fields are zeroed at startup as before, scrubbing any
stale path strings left by a pre-upgrade zmc.

The stream socket deliberately publishes nothing via shared memory: its
path is the documented convention PATH_SOCKS/stream_{monitor_id}.sock,
derivable from static config, and codec parameters travel in the HELLO
handshake instead of shm fields.

Perl (Memory.pm) and PHP shared memory maps updated to match by name;
offsets are unchanged.

Tests: full suite passes via ctest; php -l clean on Monitor.php.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-16 05:02:24 +10:00
Steve GilvarryandClaude Opus 5 ba49bbb33c fix: make the Perl scripts' taint-safe PATH configurable
The scripts run under -T, so they cannot trust the caller's PATH and set
their own. Sixteen of them hardcoded /bin:/usr/bin:/usr/local/bin, which
assumes everything they shell out to lives under /usr or /usr/local.

ZoneMinder::General::findDbCommand looks for a database client on that
PATH, and zmupdate.pl and zmcamtool.pl run what it finds. So an install
whose client sits anywhere else cannot apply schema changes:

  sh: mysql: command not found
  Command 'mysql -u'zmuser' ... ' exited with status: 127

even with the client on the caller's PATH. Homebrew on Apple Silicon is
the case that surfaced it - the client is in /opt/homebrew/bin - but a
--prefix=/opt install on Linux has the same shape, as does anything that
keeps its database client outside the FHS locations.

Replaced the literal with @ZM_SCRIPT_PATH@, defaulting to the same three
directories plus wherever cmake actually found a client, and overridable
for packagers who want to pin it. Warn at configure time when no client
is found at all, since that failure otherwise appears much later and
says something unrelated.

Nothing changes for an install whose client is already under /usr/bin:
the directory is only appended when it is not in the list, so the
default stays exactly as it was.

Memory.pm is deliberately left alone. It has a narrower PATH of
/bin:/usr/bin, and the only command it runs is uname through an absolute
path from ZM_PATH_UNAME, so it does not need widening.

Verified on macOS across the three cases: with the client in
/opt/homebrew/bin the directory is appended; -DZM_SCRIPT_PATH= is
respected verbatim; and pointing detection at /usr/bin/mariadb leaves
the default untouched. Build clean, suite 146 cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5KL9Xbi7K5aGsauLtd8tG
2026-09-13 13:04:59 +10:00
Isaac ConnorandClaude Opus 5 b9133999df fix: remove ZoneMinder::Control::onvif, it collides with ONVIF.pm fixes #5122
scripts/ZoneMinder/lib/ZoneMinder/Control/ held both ONVIF.pm and onvif.pm, the
only pair of paths in the tree differing solely in case. On a case insensitive
filesystem git can materialise just one of them, so a macOS clone reports the
loser as modified forever and git add -A commits one module's contents over the
other.

The runtime consequence is worse than the checkout noise. Every seeded Controls
row uses Protocol='ONVIF', and zmcontrol.pl builds the module name from that
column. Where onvif.pm is the file that survived, require ZoneMinder::Control::ONVIF
still succeeds because the filename matches, but it defines the lowercase package,
so the bless lands in an empty ::ONVIF and the first method call dies.

ONVIF.pm has replaced onvif.pm since the unified module landed. Of the subs only
onvif.pm defines, all but horizontalPatrol and horizontalPatrolStop exist there
under underscore-prefixed names, and every ONVIF-protocol Controls row has
CanAutoScan=0, so nothing can reach those two.

Nothing ever moved existing installs onto the new protocol name, so add
zm_update-1.39.29.sql to do it. zm_update-1.35.23.sql sent Protocol='onvif' to
FoscamCGI, which was right in 2021 when onvif.pm held the Foscam CGI protocol and
was copied to FoscamCGI.pm, but onvif.pm was afterwards replaced with a real ONVIF
implementation and the seed row restored, so rows written since belong on ONVIF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:19:56 -04:00
Isaac ConnorandClaude Opus 5 889a4b4b18 fix: say why an AMLink reply would not decode, and use it if it is readable
The session leak is fixed - no "too many connections" since the restart - but
decode failures continue, so there is a second cause. The old message named
only the symptom, which is why the first diagnosis chased the wrong thing.

The failure now reports the payload length, whether that length is a multiple
of four as base64 requires, whether the reply decodes without unmasking, and
the leading bytes. Those separate the plausible causes: a camera answering
unmasked, a truncated reply, and a reply masked with a key we no longer share.

If the reply does decode unmasked it is returned rather than discarded, since
an unmasked answer is still an answer.

What is already ruled out, so the next person does not repeat it: a stale
session still answers masked and decodes cleanly, just without a result field;
LWP's decoded_content is byte-identical to content here; and the masked payload
did not collide with the </cmd> terminator in 48 replies across six sessions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-11 20:13:03 -05:00
Isaac ConnorandClaude Opus 5 c3346da464 fix: release the AMLink session, and refuse to send without a mask key
The control daemon filled its log with

  failed to decode the reply to CoaxialControlIO.control: malformed JSON

on every lightOn, while the light itself still worked. Three faults in this
module compounding, all mine.

Nothing ever logged out. close() was inherited from Dahua_RPC, which only sets
a flag, so every login abandoned a session. The camera counts connections and
reclaims them slowly, and zmcontrol keeps one object for the life of the daemon
while Dahua_RPC re-logins whenever the camera times the session out - the log
shows that happening every half hour. Each one leaked a slot until the camera
answered global.login with "too many connections!", which it is still doing
here hours later.

login() then made a transient failure permanent. It cleared session and
mask_key up front, so a login that failed left the object with no key. Several
Dahua_RPC callers re-login on error and carry on without checking the result,
so every later command was sent unmasked, came back masked, and failed to
base64-decode. The logged error therefore described the symptom two steps
downstream of the cause and never mentioned the session at all.

So:
  - logout() gives the session back, and close() calls it
  - login() releases the previous session before taking another
  - rpc_call refuses to send on the masked channel with no key, and says the
    session is gone rather than emitting an unreadable decode error
  - a login refused for "too many connections" is reported as that

global.logout, with no parameters on the ordinary Request channel, confirmed
against the camera's own web bundle rather than guessed.

Tested: 5 new assertions that a Request with no key returns undef and puts
nothing on the wire, while Login, OutsideCmd and the key exchange - which
legitimately have no key yet - are still sent. Perl suite 15 files / 266
assertions.

Not yet verified against the camera: it is still refusing logins with "too many
connections" from the sessions already leaked, so the logout path could not be
exercised end to end. It needs a retry once the camera has reclaimed them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-11 20:13:03 -05:00
Isaac ConnorandClaude Opus 5 67acbac469 feat: add set_time to the AMLink control module
Points the camera at an NTP server and sets how often it syncs. The cameras
were on time.windows.com once a day, or in one case at an address on the LAN
that does not answer NTP at all, so it was not syncing.

UpdatePeriod is in minutes and 1 is accepted - established by writing it and
reading it back, since the firmware has no getConfigCaps to ask. That is as
often as the camera will go, so it can be held within a minute of the server.

Only Address, Enable and UpdatePeriod are written. TimeZone and TimeZoneDesc
are deliberately left alone: the camera's clock already reads correctly and
the index is not a standard one, 26 for "Middletime" here against a factory
default of 25 "Easterntime". Changing a timezone we do not understand to fix a
sync interval would be a poor trade.

The write is a whole-section merge that echoes back every field the camera
returned, so a partial write cannot silently drop one, and set_time skips the
write entirely when the settings already match. The camera holds this in
flash and the method is meant to be safe to re-run - from cron, for instance -
so an unconditional write would spend erase cycles for nothing. Same reasoning
as FoscamHD::set_time.

Tested: 12 new assertions over the merge and the change detection, including
that untouched fields survive, that the caller's hash is not mutated, and that
1440 against "1440" does not read as a change - which would otherwise rewrite
flash on every run. Verified against both cameras: one syncing from
time.windows.com every 1440 minutes and one pointed at a LAN address that does
not answer NTP at all every 60, both now on the local server every 1, and
re-running was a no-op. Perl suite 15 files / 261 assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-11 20:13:03 -05:00
Isaac ConnorandClaude Opus 5 210d10da1d feat: add ZoneMinder::Control::AMLink for the AMLINK AL5M white light
These cameras have a white light that ONVIF does not expose at all: imaging
offers only Brightness/ColorSaturation/Contrast/Sharpness, GetRelayOutputs is
empty with RelayOutputs="0", and there is no PTZ service and so no auxiliary
commands. The light lives behind a vendor JSON-RPC tunnel.

The vocabulary turns out to be Dahua's -- CoaxialControlIO for the light,
magicBox for reboot -- so this subclasses Dahua_RPC and replaces only open,
rpc_call and login. lightOn, lightOff, lightStatus and reboot are inherited
unchanged, and Dahua_RPC's lightStatus already parses the
params.status.WhiteLight these cameras return.

The transport is the whole of the work, and none of it is discoverable by
probing:

  * The endpoint is /Onvif/device_service with a capital O. The lowercase
    /onvif/device_service is the real ONVIF SOAP service; the capitalised
    spelling is a separate vendor tunnel sharing the path. /RPC2, /RPC3,
    /OutsideCmd and every /cgi-bin/* return 404 on this model, which is why
    the API looked absent.
  * Requests are <body><cmdType>T</cmdType><cmd>P</cmd></body>. Without
    cmdType the camera drops the connection rather than returning an error.
  * P is base64 of the JSON-RPC object, XOR-masked with a session key once one
    exists. Masking is mandatory: an unmasked Request is refused even on a
    freshly authenticated session, so the key exchange is not optional.
  * That key needs a three step bootstrap -- log in, fetch the device RSA
    public key over the unauthenticated OutsideCmd channel, then send an AES
    key encrypted under it and decrypt the mask key from the reply. The salt
    doubles as the AES key and must be 32 decimal digits; 16 is accepted by
    the AES step and then rejected by getGeneralKey.

IO polarity matches Dahua_RPC: IO 1 is on, IO 2 is off. Worth stating because
the camera's own web UI is a toggle that does not reveal which is which, and
because a floodlight cannot be verified optically in daylight -- an RTSP
brightness comparison appeared to say the opposite and was exposure drift.
CoaxialControlIO.getStatus is the reliable witness.

Tested: 26 assertions over the pure transport helpers -- mask symmetry and
cycling, NUL bytes surviving the mask, the envelope, stripping the CRLF the
device pads its replies with, and the channel routing, which fails silently on
the wire if wrong. Verified live against both units: open negotiates a 32 byte
mask key and lightStatus reports Off -> On -> Off around lightOn/lightOff. Perl
suite 15 files / 249 assertions pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-11 20:13:02 -05:00
Isaac ConnorandClaude Opus 5 c7deb3faa8 feat: score monitors on how loud their audio is
ZoneMinder has carried audio for years without ever listening to it: the
packets go into the event and nothing reads them. AudioDetector decodes
them in the capture thread and reports a 0-100 level, which Analyse()
turns into score alongside motion, ONVIF and Amcrest.

The level is dBFS-derived, not a raw amplitude ratio. Linear RMS is
unusable as a setting: ordinary speech sits at 1-3% of full scale, so
every sound worth catching would be crammed into the bottom two points of
the range and no operator could tune it. Mapping -60..0 dBFS onto 0..100
puts speech around 43 instead.

Three columns on Monitors: AudioDetection to enable it, AudioThreshold
for the level to alarm at, and AudioAlarmScore for what it contributes.
AudioAlarmScore defaults to 9, matching what an ONVIF or Amcrest alarm
already adds. A threshold of 0 means off, so enabling detection without
choosing a threshold cannot alarm on silence -- a plain level >= threshold
test would alarm on every packet in that state.

The level and the alarm flag go in SharedData's two spare bytes, renamed
from reserved1/reserved2. Offsets and sizes are unchanged, so the
888-byte cross-process layout and the Memory.pm and Monitor.php offset
tables all still agree; the readers are renamed in the same commit so the
level is available to the web UI.

The decoder is opened lazily on the first audio packet rather than at
camera setup, so a monitor with detection off never carries one and a
stream that gains audio on reconnect still gets picked up. Scoring reads
the capture thread's most recent level rather than scoring per audio
packet, because the score belongs to a video frame and audio packets do
not arrive in step with them.

Tested: 242 assertions over the pure helpers - the RMS of both sample
formats, the dB scale's monotonicity and endpoints, full-scale clamping
of decoder overshoot, and the threshold-0 case. The repo's Catch2 harness
needs v3 and only v2 is installed here, so tests/zm_audio_detector.cpp
was compiled against a v2 shim to check it, and an identical set of
assertions was run standalone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-11 20:13:02 -05:00
Isaac Connor eafdffcd95 feat: add per-monitor actions that drive a light or a speaker on alarm
A camera that detects motion should be able to sound a speaker, and the
speaker is rarely the camera: it is a separate device with its own address,
credentials, stream and Controls entry. Model it as a monitor and let a
monitor's alarm drive actions on other monitors.

Add Monitors.DeviceClass enum('Camera','Speaker'). This is what the device
is, as distinct from Type, which selects the capture backend - an IP speaker
still captures over Ffmpeg like any other RTSP device, so Type could not
carry the distinction.

Add the MonitorActions table: MonitorId is the monitor that triggers,
TargetMonitorId the device acted on, and the two are frequently different.
TriggerOn covers EventStart, EventEnd, Alarm and Manual.

Which actions a device is offered is decided by its Controls row - CanLight,
CanIndicatorLight, CanAudioPlay - so a device can only be asked to do what it
has been measured to do. The editor filters on this and the save path
re-checks it, because the request is not to be trusted.

Execution goes straight to the target's zmcontrol socket rather than forking
zmcontrol.pl per action: the daemon already accepts a line of JSON there, and
it is the same path the control panel uses. ActionCommandName maps the DB
enum onto a method name as a whitelist, so nothing out of the database
reaches the control daemon uninspected. Actions are fire-and-forget - a
speaker that is offline is logged and skipped, never allowed to hold up event
handling.

Alarm actions fire only on the genuine entry into alarm, not on the
ALERT->ALARM re-entry, which would re-sound a speaker within one incident.
EventEnd runs on the calling thread before the event is handed to the closing
thread, which does not capture `this`.

Manual actions appear as buttons on the watch page, and are the reason the
control panel is now shown for a monitor that has actions but no control of
its own. Firing one sends only the action id; the command and target are
rebuilt server side, and Control rights are required on the target device and
not merely on the monitor the action hangs off.

Also add --file to zmcontrol.pl, without which audioPlay could not be driven
from the command line. The web path was unaffected as it bypasses GetOptions.

Tests: tests/zm_monitor_action.cpp covers the command whitelist, the message
format (including that file id 0 is a real id and that a stale AudioFile is
never passed to a command that takes none), trigger names, and that every
value of the ActionType enum maps to a command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
(cherry picked from commit b561341e988af69c3a46db854da410309f7cfa82)
2026-09-11 20:13:02 -05:00
Isaac Connor a6bb811e97 feat: add audio playback control capability and an ONVIF IP speaker module
An IP speaker is controllable but has none of the capabilities the Controls
table models: nothing moves, focuses or lights up. It plays a sound file it
already holds, selected by a numeric id, and carries an output volume.

Add CanAudioPlay, MinAudioFile, MaxAudioFile and CanAudioVolume to Controls,
with migration zm_update-1.39.21.sql. CanAudioPlay renders one button per
sound id plus a stop button; MinAudioFile/MaxAudioFile bound that range;
CanAudioVolume renders the volume pair. The sound id travels in the command
name (audioPlay12), the way presets already do, because a control button has
no way to attach a separate parameter.

Add ZoneMinder::Control::IPSpeaker driving the device's own HTTP interface,
and a Controls entry for it. Playback goes over the vendor interface rather
than ONVIF because the ONVIF audio output service on this firmware only
describes the output and offers no way to start a stored file.

Measured against a device reporting ONVIF Manufacturer "IPSpeaker" and
firmware CS20-V3.3.45N:

- File ids fall in two windows, 10-14 built-in and 20-30 operator uploads.
  Anything else is refused with result -2, and an id in a window with no file
  uploaded with result -3, so valid_fileid refuses only the former: an empty
  slot is a legitimate id the operator may fill later.
- config=audio.set replaces the whole audio section, so a partial write
  reverts the microphone, codec list and echo-cancellation settings. Every
  volume change reads the section and echoes it back with the new level.
  audio.get also returns outmute, which audio.set rejects, so it is excluded
  from the field list.
- The volume reads back and round-trips, but the device's RTSP audio is a
  pre-volume tap of the playback signal: it is unchanged with outvolume at 0,
  so it cannot confirm the loudspeaker's acoustic output. CanAudioVolume is
  set on the strength of the setting persisting, and both the POD and the
  seed row say the acoustic effect is unconfirmed.

The seed row covers the built-in window only, because a fresh device has
nothing uploaded and the firmware refuses an empty slot.

Adding columns to Controls extends the 43 positional INSERTs in
db/controls.sql, which carry no column list and so must match the column
count exactly.

Tests: scripts/ZoneMinder/t/ip_speaker.t, 32 assertions covering file id
windows, volume clamping and stepping, request building and the whole-section
write. All pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
(cherry picked from commit d07e552a3c58373f7f0ccebf9aa28adb4d51baa3)
2026-09-11 20:13:02 -05:00
Isaac ConnorandClaude Opus 5 ca3a8db79a fix: use the discovered VideoSource token in the ONVIF config paths too
The original fix replaced the hardcoded 000 token in getCamParams and
_setImaging, but get_config/set_config landed upstream afterwards and
carry three more imaging requests that still address VideoSource 000:
the ImagingSettings and ImagingOptions entries in %config_types, and the
SetImagingSettings body in set_config.

Cameras that number their sources differently reject all three. On an
AMLINK AL5M-T5171EW the video source is 00000 and a request for 000 comes
back as "The requested VideoSource does not exist.", so the imaging half
of the config API is unusable on those cameras even with the earlier fix
applied.

The query bodies now carry a __VIDEO_SOURCE_TOKEN__ placeholder, matching
how __PROFILE_TOKEN__ already works, and get_config substitutes it only
when the body contains it so unrelated categories do not pay for a
GetVideoSources round trip. set_config calls _video_source_token()
directly, which is cached after the first lookup.

The added tests assert on the module source because %config_types is a
file-scoped lexical. That is deliberate: the failure this guards against
is a new imaging call being written with a literal token again, which is
exactly how get_config/set_config reintroduced the bug.

Verified against both live AMLINK units: GetVideoSources returns 00000 on
each, and GetImagingSettings answers for 00000 while faulting for 000.
Perl suite 13 files / 191 assertions pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-11 20:13:02 -05:00
Isaac Connor 116db629c9 fix: discover the ONVIF VideoSource token instead of hardcoding 000
getCamParams() and _setImaging() addressed the imaging service with a
literal VideoSourceToken of 000. That token is a different namespace from
the media ProfileToken carried in ControlDevice, so it could not be
configured around: on an AMLINK AL5M-T5171EW the media profiles are
MediaProfile00000 while the video source is 00000, and the camera answers
a request for 000 with "The requested VideoSource does not exist."
Brightness (Iris) and Contrast (White) control were unusable on any camera
that does not happen to number its first video source 000.

Read the token from GetVideoSources on first use and cache it. The
fallback to 000 is applied per call rather than cached, so a camera that
is unreachable when the control daemon starts gets another chance instead
of being pinned to the wrong token for the life of the daemon.

Parsing is split out as video_source_token_from_xml() so it can be tested
without a camera, matching the pattern used by the HikVision light
helpers.

Verified against a live AMLINK AL5M-T5171EW: token discovered as 00000,
irisAbsOpen(step 10) moved Brightness 50 -> 60, restored to 50.

(cherry picked from commit 7f1935d69d58b3e2bbf71a54fe11b1a6b4bc30af)
2026-09-11 20:13:02 -05:00
Isaac ConnorandClaude Opus 5 68854a96bf fix: correct inverted success test when saving Manufacturer and Model
Object::save() returns the error string on failure and '' on success, so
`if ($manufacturer->save())` took the success branch only when the save had
failed. ManufacturerId and ModelId were therefore assigned from an unsaved
object and left unset whenever the save actually worked.

Invert the test, capture the error string, and log it. Model.pm needs
ZoneMinder::Logger imported for Error().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 20:36:03 -04:00
Isaac ConnorandClaude Opus 5 f796053c2a fix: reconnect to db instead of bare ping in zmupdate and zmfilter loops
Both loops called $dbh->ping() and discarded the result. ping() only tests
the connection; DBD::mysql does not reconnect on its own since
mysql_auto_reconnect is off. zmupdate's update-check loop sleeps 3600s
between iterations, so on a server with wait_timeout below an hour the
handle is already closed on wake-up and the following zmDbDo fails with
"The client was disconnected by the server because of inactivity".

zmDbConnect() already pings and reconnects when needed, and assigns the
package global $dbh that zmDbDo uses, so call that instead. zmupdate keeps
passing mysql_multi_statements so a reconnect matches the original handle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:27:36 -04:00
Isaac ConnorandClaude Opus 5 eae2df6b48 fix: renumber the trigger migration to 1.39.26, upstream took 1.39.25
Upstream's schema-drift fix landed db/zm_update-1.39.25.sql and bumped
version.txt to 1.39.25 while the trigger consolidation was using the same
number for db/zm_update-1.39.25.sql.in.

Git does not see this as a conflict -- the two files have different names in
the source tree -- but configure_file generates the .sql.in into
zm_update-1.39.25.sql, which is the same install target as the tracked .sql.
Confirmed by staging an install off the merge: the installed
zm_update-1.39.25.sql was upstream's column reconciliation and the trigger
consolidation was simply absent, with no error anywhere. An upgrade would
have applied one of the two and silently skipped the other.

Renumbered to 1.39.26, version.txt to match, and the two zmstats.pl.in
comments that name the migration updated. Upstream's 1.39.25 is untouched.

Verified by staging an install again: both files are now present and hold
what they should. The migration was re-run end to end at the new number
against a scratch database -- drops the eight cascade triggers, leaves four,
and the 29 trigger assertions pass afterwards.

ai_server carries the old 1.39.25 numbering and will need the same treatment
when master is next merged into it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
2026-09-05 15:52:43 -04:00