Perl's continue is not loop control: outside a given/when block it dies with
"Can't \"continue\" outside a when block", so renumber stopped at the first
event whose directory was missing and never handled the ones after it.
fixes#5340
zmaudit removes a filesystem monitor link that no longer points to a valid
monitor directory with executeShellCommand("$ZM_PATH_RM \"$link\""). The link
name is the monitor name. The web layer names the link from the raw request
(basename($_REQUEST['newMonitor']['Name'])) rather than the filtered name
stored in the database, so it can contain shell metacharacters that the stored
name cannot. Inside the double-quoted rm argument, $(...) and backticks in the
name are still expanded, so a user with Monitors create/edit can have zmaudit
run arbitrary shell commands when it later cleans up the dangling link.
Use Perl unlink() on the de-tainted link name: it removes the symlink itself
with no shell, so nothing in the name is interpreted.
Addresses GHSA-746p-4622-p3fr.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When building an AutoExecute command, zmfilter treats a substituted tag value
as plain (inserted into the command unquoted) when it matches
/^[\w\/.,:+=@% -]*$/. In Perl $ also matches immediately before a trailing
newline, so a value like "foo\n" is classed plain and inserted bare. The
newline then terminates that shell command line; a second plain tag placed
after it on the template is run as its own command. A user with Events edit
controls event fields that feed tags, so on a filter with an AutoExecute action
this is authenticated command execution.
Anchor with \z (true end of string) instead of $, so any trailing newline fails
the plain test and the value is quoted like other unsafe input.
Addresses GHSA-9v28-c6w5-v8w6.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
zmconfgen.pl wrote zm_create.sql's Config rows without the Private and System
columns, so a database created from it had both at 0 for every option until
zmupdate.pl -f rewrote the table from ConfigData. The web UI relies on Private
to keep a secret out of the browser: skin.js.php exports every option that
isn't Private as a JavaScript constant on every page, the login page
included, and the Options page shows a Private value only to users who can
edit System settings (GHSA-7g3f-93q8-fhwj). So on such a database
ZM_AUTH_HASH_SECRET, which is enough to sign an admin token, was sent to
anyone who loaded the login page. A fresh RPM install is one: the spec runs
zmupdate.pl -f only on upgrade. Debian packages run -f on install.
- zmconfgen.pl writes Private and System like saveConfigToDB() does.
- ZoneMinder::Config::syncConfigFlags() sets each row's Private and System
flags from ConfigData without touching its value, and zmupdate.pl calls it
on every update, so an existing database is repaired the next time it is
updated without needing -f.
- skin.js.php never exports a password option, whatever its flag says.
Tested with zm_create.sql generated from this tree, loaded into a scratch
database: the fresh install has the 3 Private options and 1 System option
ConfigData marks. With every flag zeroed, syncConfigFlags() set those 4 rows
and on a second run changed none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add ZM_PASSWORD_MIN_LENGTH (Options, System; default 8, 0 turns it off).
passwordProblem() refuses a shorter password, so the user page, the API
and both login-page forms apply it alike. Existing passwords keep
working; only a password being set is checked. The length counts
characters, not bytes.
There is no setting for character classes: they push people to
predictable variations (Password1!) and add less than length does.
A database whose Config table doesn't have the option yet (an upgrade
not run with zmupdate.pl -f) gets no minimum. The password inputs carry
minlength so the browser says so before the form is sent; the server
check is the one that counts. The API answers with the English text, as
for the other reasons.
Tests: tests/php/test_default_password.php.
ZM_OPT_USE_AUTH shipped as 0, so a new install accepted every request as
the default user with System=Edit. That includes requests a browser sends
on behalf of any web page a user on the same network opens, so a server
that is never exposed to the internet could still be driven from it.
Default it to 1. zm_create.sql (generated from ConfigData) gives new
installs authentication on. Existing installs keep their value:
zmupdate.pl -f loads Value from the database and writes it back, and only
DefaultValue changes. zm_update-1.39.41.sql also sets DefaultValue to 1
for the row, so that an upgrade run without -f doesn't leave
Value = DefaultValue = 0, which the C++ config loader would skip in favour
of its compiled-in default (on) while the web UI read off.
Update the option help and the user guide (getting started, FAQ, system
options) to say authentication is on for new installs and kept on
upgrade. tests/perl/test_auth_default.pl checks the default, the stored
value and that no update script sets the row's Value; tests/zm_config.cpp
now expects the compiled-in default (opt_use_auth) to be on.
A boolean option set in zm.conf or conf.d wins over the Config table, and a
person editing a file writes yes or off rather than 1 or 0. The C++ daemons
now read those words (ConfigFileValue, 4d7609d36), but PHP and Perl took the
value as written, and a non-empty string is true in both. So
ZM_STRICT_VIDEO_CONFIG=off in conf.d left the option on in the web UI and the
Perl scripts while zmc turned it off.
Read a file value for a boolean option the same way in all three: yes, true
and on are 1; no, false and off are 0; anything else stays as written. Only
booleans are read this way, so a string option such as a title keeps "Yes".
PHP defines the file values as constants before it reads the Config table,
since it needs the database settings to connect, and only the table says
which options are booleans. So a word now waits until the table has been
read, apart from ZM_DB_* settings, whose words database.php reads itself.
Perl reads the type with each row.
Tested on 1.38's zm138 database with a conf.d file setting
ZM_OPT_FAST_DELETE=yes (0 in the table), ZM_STRICT_VIDEO_CONFIG=off (1)
and ZM_WEB_TITLE=Yes. The installed PHP and Perl gave 'yes', 'off' and
'Yes'; these give '1', '0' and 'Yes'. tests/php/test_config_file_booleans.php
checks the rule.
refs #5289
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add JanusEnabled, Go2RTCEnabled, RTSP2WebEnabled and RTSPServer to the
per-monitor telemetry, so we can tell whether Janus and RTSP2Web are still
in use before deciding whether to deprecate them. Nothing in the existing
payload says.
Bring the privacy statement's list of collected monitor fields up to date
too. It listed Function, which no longer exists, and left out Capturing,
Analysing and Recording, and the source path, which is sent with its user
name, password and host replaced. Only en_gb is updated; the other
translations keep their own lists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Event::Close() renamed the event to <prefix><Id>(r) every time. That
was fine while only zmeventtool.pl close and zmaudit called it, by hand
or a day later; with zmwatch closing events left open by a zmc that
died, it would overwrite a name the user set while the event recorded.
Rename only when the name is still the placeholder 'New Event', as
zmc's own close does (~Event: Name = IF(Name='New Event', ...)). The
'Recovered.' note still marks the event.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reolink_HTTP.pm and Uniview.pm said `use JSON`, and
ZoneMinder::General::jsonLoad required JSON. The Debian and Ubuntu
packages depend on libjson-maybexs-perl, not libjson-perl, so on a
packaged install neither control module loads ("Can't locate JSON.pm in
@INC") and jsonLoad, which reads MacVendors.json for the network scan,
logs that error and returns nothing.
Use JSON::MaybeXS as the other control modules (AMLink, Dahua_RPC,
IPSpeaker) and the rest of ZoneMinder::General already do. encode_json
and decode_json behave the same; JSON::MaybeXS picks Cpanel::JSON::XS,
JSON::XS or the core JSON::PP.
tests/perl/test_json_maybexs.pl hides JSON.pm from @INC and loads both
modules and runs jsonLoad.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
* 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>
* 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>
An event open when its zmc is killed (lost stop, OOM, crash) keeps
EndDateTime NULL. Nothing closes it on a default install: zmaudit only
does with ZM_RUN_AUDIT on and after ZM_AUDIT_MIN_AGE, and the next zmc
does not look at earlier events.
A running zmc closes its event before it reconnects and sets
startup_time in shared memory after it connects, so an open event of
its monitor that started before that time belongs to a process that is
gone. Once per zmc start, when its heartbeat is fresh, zmwatch closes
those events through Event::Close(). Only this server's monitors are
checked, and re-analysis events (Cause 'Re-analysis', created by zma
with their original times) are left alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Event::Close() (zmeventtool.pl close) took the end of an event only from
its Frames rows and refused events that had none. zmc writes Frames rows
in batches and updates the event row's Length, Frames and scores with
each batch, so an event whose zmc was killed in its first seconds has no
Frames rows, and either side can be ahead of the other.
Take Length, Frames, AlarmFrames, TotScore and MaxScore as the larger of
the event row and the Frames rows; EndDateTime follows the Length that
was used. The name and Notes are written as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
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
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
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
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
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
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>
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>
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>
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
* 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>
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>
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>
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)
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)
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)
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)
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)
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)
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>
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
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
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
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
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
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
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
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
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