Commit Graph
29860 Commits
Author SHA1 Message Date
dependabot[bot] d398caad49 chore(deps): Bump the codeql group with 3 updates
Bumps the codeql group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/autobuild](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/autobuild` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/analyze` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql
- dependency-name: github/codeql-action/autobuild
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-29 17:46:03 +00:00
Isaac ConnorandClaude Opus 5 0e19f4fb4c feat: set the ntp server, timezone and daylight saving rules on Herospeed cameras
Both cameras found on this network kept accurate UTC with no timezone set, so
their clocks were five hours out on the overlay while the recordings
themselves were correctly stamped. The factory daylight saving rule was also
nonsense everywhere: thirty minutes, March to September.

set_time points the camera at an ntp server and sets its timezone, with
optional summer time rules. The timezone is derived from a UTC offset rather
than hardcoded: the vendor's list starts at GMT-12:00 and steps every thirty
minutes, so -6 becomes index 12, which its own language pack labels
"(GMT-06:00) Central Time (US&Canada)". An offset with no index, a quarter
hour zone say, is refused instead of silently rounded.

These settings live in flash and set_time is meant to be safe to re-run, so
nothing is written unless it differs. Verified against a camera: an unchanged
call issues no write at all, a changed server issues exactly one, and
restoring it issues one more. The comparison recurses into the nested start
and end rules, which would otherwise always differ as hash references, and
compares as strings because the camera returns numbers and JSON booleans
interchangeably.

dstType is deliberately not sent. Supplying week based rules makes the camera
set it itself, and it rewrote the value we sent, so sending one would mean a
read back that never matches what was written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 5a0e2871ce fix: name the Herospeed control entries after the models they describe
Matches how every other entry is named - LTS CMIP3CD42WI-28AISP, AMLINK
AL5M-T5171EW - and is what the capability flags actually depend on.
/api/image/ptz-ctrl accepts every command on every model and moves nothing on
the fixed ones, so code 0 means understood, not performed: capability cannot
be probed and has to come from knowing which model is on the other end. A
generic entry has to guess, and guessing high hands the operator buttons that
silently do nothing.

Two entries, both measured rather than assumed. On each, panPos, tiltPos and
zoomPos never moved across all eight directions and both zoom directions, and
image sharpness stayed inside its own noise for focus and iris. On the L5BW,
whose scene has passing traffic, the pan samples were checked against five
no-command intervals of the same length and they overlap, so the larger
frame deltas there are the street, not the camera.

  HeroSpeed L5TW  firmware V2.0.13.260306_R2
  HeroSpeed L5BW  firmware V2.0.0.260723_R6

The speculative ptz entry is dropped rather than shipped: no Herospeed camera
with working motors has been tested, and the module keeps the full command
set for whenever one is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 37b2fd65c9 fix: split the Herospeed control entry, the fixed models have no ptz at all
/api/image/ptz-ctrl answers code 0 to every command on every model, so
capabilities cannot be probed from the api and have to come from knowing the
model. Measured on an L5TW (fixed turret, firmware V2.0.13.260306_R2):
panPos, tiltPos and zoomPos never changed across all eight move directions
and both zoom directions, and image sharpness moved less than its own noise
for focus near, focus far, iris open, iris close and one-key autofocus. The
lens has no motors; only the light does anything.

One entry claiming ptz would give an operator of such a camera a full panel
of buttons that silently do nothing, which is the same reason CanMoveAbs,
CanMoveRel and CanMoveMap are already 0. So there are two: the ptz entry for
models that have motors, and a fixed entry offering only the light, reset and
reboot.

Guarded on Name rather than Protocol now that the two entries share one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 3a8abce499 feat: add the Herospeed control definition, and fix the id in the unsupported-command message
Controls entry for the Herospeed / LS Vision web api protocol, as both a
migration for existing installs and a controls.sql row for fresh ones.

Capabilities match what /api/image/ptz-ctrl actually offers: continuous move
in eight directions, zoom, focus with autofocus, iris, 255 presets with a home
and the ability to store them, reset and reboot, and the white floodlight.
CanMoveAbs, CanMoveRel and CanMoveMap stay 0 - the api has no absolute,
relative or map move. Speeds are 1-8 because the device advertises no range
anywhere, so the module clamps to the window every optType was observed to
accept.

Also fixes the message added with the can() guard: "monitor $id's" parses as
$id::s, the old package separator, so the monitor id silently vanished from
the one line whose job is to say which monitor to go and look at. Found by
reading the message the running daemon actually emitted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 4be5ca4744 feat: drive Herospeed cameras entirely through their own web api
Replaces the ONVIF subclass with a standalone protocol. ONVIF stays as it is
and remains the right choice for these cameras when only pan, tilt, zoom and
imaging are wanted; this exists for what ONVIF cannot reach on them.

The full ptz surface now goes through /api/image/ptz-ctrl, whose optType
values are taken from the web ui's PTZ_CONTROL_CMD_* constants: the eight
move directions and stop, zoom, focus including one-key autofocus, iris, auto
pan, lens initialisation, and presets by index and name. /api/image/ptz-info
adds pan, tilt and zoom position read-back, which ONVIF does not offer here.

The white supplement floodlight is dayNightMode.ledMode in
/api/image/image-param, switched between Warm Light and IR Light. ONVIF
exposes no light at all on this hardware - both GetRelayOutputs and the PTZ
auxiliary Wiper command are accepted and neither does anything measurable.

Login is the camera's five round handshake, with the session cookie returned
in the response body rather than a Set-Cookie header. The rounds are pure
functions and a captured exchange is replayed by a test: each one hashes the
raw bytes the previous hex digest names rather than its text, and getting
that wrong produces an equally plausible digest whose only symptom is a
refused login. Code 101 means the session expired, so every call re-logins
once and retries rather than dropping an operator's keypress.

Speed is clamped: the device advertises no range anywhere in its api or ui.
Preset indexes are validated before being sent for the same reason.

Standing alone also means no imports are inherited. Missing ones are invisible
to perl -c and only fail on the path that uses them, so the tests assert the
required modules are actually loaded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 bcb0fa85a5 feat: add a control module for Herospeed / LS Vision cameras
These identify themselves as Manufacturer "NONE", Model "IPCamera" over
ONVIF; the real name is in the ONVIF scope (name/L5TW), in the vendor api's
/system/device-info, and in the 2C:6F:51 OUI.

PTZ, presets and imaging are inherited from the ONVIF module, which the
camera implements well - absolute, relative and continuous move, 256 presets,
brightness through sharpness, and motion and tamper events.

What ONVIF does not expose is the white supplement floodlight the deterrence
feature uses. GetRelayOutputs and the PTZ auxiliary Wiper command are both
accepted by the camera and both do nothing measurable. The light lives in the
vendor REST api at /api/image/image-param, as dayNightMode.ledMode, switched
between Warm Light and IR Light.

Reaching that api needs its login handshake: sha256 over
username+salt+base64(timestamp)+password, rehashed over the raw digest bytes
with the challenge appended, optionally iterated, with the session cookie
returned in the response body rather than a Set-Cookie header. The rounds are
pure functions so the captured exchange is replayed by a test; the raw-bytes
versus hex-text distinction in particular produces an equally plausible digest
when wrong, so it is pinned rather than trusted.

The section write echoes every field back, since a partial write makes the
camera drop what it was not sent.

Note the supplement light only engages while the camera is in night mode, so
setting it during the day reads back correctly but produces no light.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 aed16db997 fix: ignore a control command the protocol does not implement instead of dying
zmcontrol dispatches straight onto the control object with
$control->$command(...). A monitor's actions and ui buttons are configured
independently of its control protocol, so nothing stops a LightOn action
pointing at a protocol with no lightOn, and Perl kills the control server with
"Can't locate object method".

Swapping a camera for a different make produces exactly that. A monitor whose
AMLink camera was replaced by an ONVIF one kept its LightOn and LightOff
actions, and ONVIF has neither, so the first alarm after the protocol change
would have taken the control server down for that monitor.

Check can() before dispatching, log which monitor and command need attention,
and answer a caller that asked for a response rather than leaving it hanging.
Direct protocol mode gets the same check, as a Fatal, since there the command
came from the command line and is a typo worth reporting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 2126ab799b feat: have tmpfiles.d age out the cache busting directory
cache_bust() in web/includes/functions.php symlinks every css and js file to a
name with the source file's mtime in it, so each time a source changes a new
link appears and the old one is left behind. Nothing has ever removed them.

The install this was written on had 8346 entries going back to April 2020,
1562 of them pointing at files that no longer exist -- one of which was the
log.js deleted in the log panel refactor, still being served because the skin
auto-includes views/js/<view>.js whenever that file is present. The directory
inode had grown to 823KB, which every lookup in that directory has to scan.

misc/zoneminder-tmpfiles.conf.in did not mention the directory at all, so
systemd neither created nor cleaned it. Add it as a d entry with a 30d age,
which the daily systemd-tmpfiles-clean.timer then acts on.

On the age, and why it is generous rather than a day or two: a symlink's mtime
and ctime are fixed when it is created, and neither being served nor having
its target read refreshes them -- verified here that traversing a symlink does
not update its atime either. So an age removes entries whether or not they are
in use, rather than only the unused ones. That is safe, because the directory
is derived and not authoritative: a link removed while still wanted is
recreated by the next page render, so the worst case is one missing asset on a
page that was already open. 30d keeps about a month of source versions for a
few recreated symlinks a month, where a short age would churn the whole set
repeatedly for no further benefit.

d and not D: D empties the directory at boot, which would throw away every
current entry on every reboot.

Tests: tests/php/test_tmpfiles_conf.php, 12 checks. Nothing exercises this
file during a build or a test run, and a mistake in it is quiet in both
directions -- a malformed line is skipped with a log message nobody reads, and
a missing age field just means a directory grows forever, which is how this
one got to 8346 entries. So the fields are parsed and checked, and the entry
is checked to name the same cmake variable that config.php.in defines
ZM_DIR_CACHE from, since if those two drift systemd would clean a directory
nothing writes to and nothing would report it.

The last check hands the file to systemd-tmpfiles itself, with every path
rewritten into a temporary directory first so that running a clean cannot
touch anything real. That check skips where systemd-tmpfiles is not installed.
Confirmed separately that a d entry with an age does remove symlinks, which is
what this directory is made of.

Verified the checks fail when the age is dropped, when d becomes D, when the
entry names a variable config.php.in does not use, and on an age systemd would
reject.

Ran: tests/php/test_tmpfiles_conf.php (12 passed), the rest of tests/php and
tests/js, npx eslint . (clean), utils/check-homoglyphs.py (clean).
test_bandwidth_clamp.php fails on this box for an unrelated reason: it cannot
read /etc/zm/conf.d/03custom.conf and the permission warning lands in its
captured output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 cac7539970 fix: key the precedence test's known list on the variable, not the line
The known-issues list recorded each finding as path:line, and a rebase onto a
master carrying 1677470af1 inserted four lines above one of them in
monitor.php. The finding moved from 1483 to 1487, so it read as both a new
offender and a missing list entry, and the test failed on code nobody had
touched.

A line number is not a stable identity for a finding. Key on the file and the
variable being assigned instead, which survives anything inserted above and
still names the offender uniquely -- there is no file here assigning the same
variable twice with a cut-short expression. The line is still reported, since
it is what someone needs in order to go and look, but it is not what gets
compared.

Verified the test still catches a new instance, and that shifting every line
in a listed file no longer breaks it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-10-28 18:01:26 -05:00
Isaac Connor 7bee4417fa Increase debug level of select timed out 2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 9b1ae0ce14 fix: show a saved monitor action's schedule as a sentence, not as cron refs #4957
A saved time based action listed its schedule as "0 21 * * *" rather than
"Every day at 21:00". The cron parser was not at fault; cronToParts('0 21 * *
*') returns the right parts. The fault was one operator:

  $drawable = ($action->Schedule() === '') or ($schedule_parts !== null);

"or" binds looser than "=", so this parses as ($drawable = ($action->Schedule()
=== '')) or (...). The right-hand clause is evaluated and thrown away, and
$drawable gets only the left one -- false for any schedule that is actually
set, which is every saved action. So the editor took every saved schedule for
one it could not draw and fell back to the read-only cron field, which is the
branch that exists for genuinely undrawable specs like "*/5 * * * *".

Changed to ||, which binds tighter than "=" and does what the line looks like
it does.

Tests: tests/php/test_assignment_precedence.php, 21 checks. It tokenizes every
php file under web/ and reports assignments whose right-hand side has an
unbracketed "and", "or" or "xor" -- tokenized rather than grepped so that the
many safely bracketed ones, in conditions and ternaries, are not reported.
There is a self-check for each distinction it has to make, since a scan that
quietly matched nothing would pass the tree-wide check on its own. Verified it
fails when the reported line is put back, and when a new instance is added
elsewhere.

The scan found six more in the rest of the tree, which the test carries as a
list of open questions rather than as exemptions. Two are the deliberate
"$fp = fopen(...) or error_log(...)" idiom and are correct. The other four are
this same bug, and each changes behaviour to fix, so none is swept up here --
in particular filter.php:401 assigns only $filter->Id() to $canDelete, so
delete is offered for any existing filter whether or not the user may edit it,
and that is access control rather than something to guess at. Anything not on
the list fails the test, so a new one cannot be added, and the list is checked
in both directions so it shrinks as the entries are dealt with.

Ran: tests/php/test_assignment_precedence.php (21 passed), the rest of
tests/php and tests/js, npx eslint . (clean), utils/check-homoglyphs.py
(clean). test_bandwidth_clamp.php fails on this box for an unrelated reason:
it cannot read /etc/zm/conf.d/03custom.conf and the permission warning lands
in its captured output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 696b479a88 fix: qualify the remaining logger calls in the web ui, and test for them
Looking for other instances of the CronTab.php fatal in the previous commit
found eleven, in ten files, dating from 2019 to 2023. Each is a call to a
logger function from the global namespace without the \ZM\ prefix, so each is
a fatal the moment its branch runs:

  web/ajax/console.php, events.php, reports.php, add_monitors.php, status.php
  web/includes/actions/snapshot.php (two), donate.php
  web/includes/functions.php, session.php
  web/views/image.php

They have survived this long because of where they sit. Every one is on an
error or diagnostic path -- an invalid sort order, a zero byte download, a
failed read lock -- so the call is only reached when something has already
gone wrong, and then it turns a handled problem into a blank 500. That is the
shape of the bug on the monitor save: the write had worked, and the thing that
broke was the attempt to log.

Verified before changing any of them that each file is in the global
namespace, does not define the function itself and does not import it, so the
call really does not resolve.

Tests: tests/php/test_logger_calls_qualified.php, 16 checks. It tokenizes
every php file under web/ and reports unqualified calls, with the function
names read out of logger.php rather than listed, so a new one is covered the
day it is added. web/api is skipped as cakephp with its own logging, and
assets as third party.

Php's own tokenizer rather than a regex, because the distinctions matter: a
method call on an object that happens to have an Error() method is fine, as
is a definition, a mention, a qualified call, the same call from inside
namespace ZM, and the name appearing in a comment or a string. There is a
check for each of those, since a scan that quietly matched nothing would pass
the tree-wide check on its own.

Verified the scan fails when the Debug() in CronTab.php is put back.

Ran: tests/php/test_logger_calls_qualified.php (16 passed), the rest of
tests/php and tests/js, npx eslint . (clean), utils/check-homoglyphs.py
(clean). test_bandwidth_clamp.php fails on this box for an unrelated reason:
it cannot read /etc/zm/conf.d/03custom.conf and the permission warning lands
in its captured output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 afa5b14307 fix: qualify the logger calls in CronTab.php refs #4957
Saving a time based action on a monitor died with

  PHP Fatal error: Uncaught Error: Call to undefined function Debug() in
  includes/CronTab.php:280

and the browser showed only "An error occurred while saving data", because
the save goes over ajax and a fatal gives it a 500 with nothing to report.
The action itself had already been written to the database; what failed was
the crontab rebuild that follows it.

Debug(), Info(), Warning() and Error() are defined in includes/logger.php,
which is namespace ZM. CronTab.php is in the global namespace, so they have to
be called as \ZM\Debug(...) -- php resolves a function name against the
current namespace and then the global one, and these are in neither from
there. Four calls were bare: the Debug at the end of cronRebuild and three
Error/Warning calls on failure paths.

Nothing catches this at include time, php -l is happy with it, and the file
works until the one branch holding the call is taken. The Error calls here are
on paths that only run when crontab itself fails, so they could have sat
unnoticed for much longer than the Debug did.

A follow-up commit adds a test that scans for this, and fixes the ten other
places in the tree that have the same bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 a907b03681 fix: size the actions table's When and Schedule columns from their contents refs #4957
Pin When to 11em and raise Schedule from 24em to 28em.

Both columns are sized by a select, and a select is as wide as its longest
option whether or not the column is. When's is "Manually triggered", which
needs about 11em; it had been sharing the leftover space three ways with
Device and Does and taking more than that. Schedule has to fit "Every
<frequency> at <time>" on one line, which comes to about 25em -- so the 24em
it was given was short, and the frequency select was the thing being
truncated.

The space comes out of Device and Does, which now divide the remainder
between them. Device is left loose on purpose: monitor names are the only
contents in this table of unpredictable length, so it is the right column to
absorb whatever is spare.

Worth knowing for later: Schedule is wide almost entirely because of one
frequency option, "weekend day (Sat & Sun)", which sets that select's width
by itself. Rewording just that label would give several em back to the rest
of the table. Not done here, since it is a visible label and not mine to
change.

Tests: tests/php/test_monitor_actions_columns.php, now 30 checks. Added a
check per select column that it fits its longest option, with the option keys
read out of the view and their text out of the language file so neither is
restated. That is the measurement these widths were chosen from, so it is the
one worth keeping: a label reworded longer would start truncating with
nothing to say it had.

The estimate for Schedule initially cleared 24em, which would have made it
too generous to catch what was reported. The two terms it was missing are
real and identifiable -- .table-sm's padding, and the collapsed space between
four controls written on separate source lines -- and together they come to
about an em and a half, which is roughly the difference complained of. There
is a check that the estimate still fails 24em, labelled as the calibration it
is rather than as a property of the css.

Verified the checks fail with Schedule back at 24em (1), with When too narrow
for its longest option (1), and with Device pinned so nothing is left loose
(3).

Ran: tests/php/test_monitor_actions_columns.php (30 passed), the rest of
tests/php and tests/js, npx eslint . (clean), utils/check-homoglyphs.py
(clean). test_bandwidth_clamp.php fails on this box for an unrelated reason:
it cannot read /etc/zm/conf.d/03custom.conf and the permission warning lands
in its captured output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 2736dd92b2 fix: widen the actions table's Enabled column to clear its own header refs #4957
4em was set from what the cells hold -- one checkbox -- and that is the wrong
thing to measure. A fixed layout will not widen a column to fit its header,
it lets the text run on into the next one, and skin.css renders every th
uppercase at 0.8em. So what has to fit is "ENABLED" plus .table-sm's padding,
which at 4em fit so exactly that it touched the When header. 5.5em leaves a
clear gap.

Tests: tests/php/test_monitor_actions_columns.php, now 21 checks. Dropped the
check that Enabled was the narrowest column "being one checkbox", since that
is the reasoning that produced the bug: "ENABLED" is a wider word than
"SOUND" whatever sits in the cells below them, so there is no ordering to
assert between the two. In its place each narrow column is checked against an
estimate of its own header's width, calibrated so that the 4em this started
with fails and 5.5em passes. The header text is read out of the language file
rather than written into the test, so a translation long enough to break the
layout fails too -- under a fixed layout it really would break it.

Ran: tests/php/test_monitor_actions_columns.php (21 passed), the rest of
tests/php and tests/js, npx eslint . (clean), utils/check-homoglyphs.py
(clean). test_bandwidth_clamp.php fails on this box for an unrelated reason:
it cannot read /etc/zm/conf.d/03custom.conf and the permission warning lands
in its captured output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 70efeff074 fix: let the monitor actions table use the full width of the tab refs #4957
ul.form indents every row by 300px to make a gutter for its label, and pulls
the label back into that gutter with an equal negative margin. It is a neat
trick for a labelled field and wrong for anything else: the actions tab's
rows have no label, so the table started 300px in with the space beside it
left empty.

Add a noLabel class that cancels the indent, and put it on the four rows of
the tab that have no label -- the table, the help text above and below it,
and the no-capable-devices warning that replaces the lot. The mobile rules
already drop the indent for every row, so there is no counterpart to add
there.

This is also why the Schedule column felt cramped. The fixed widths in
6eb390007a divide whatever the table is given, and the table was being given
300px less than the page; those widths are unchanged and now divide the whole
of it. Only one table on this view sits in a ul.form, so nothing else was
affected.

Tests: tests/php/test_monitor_actions_columns.php grows to 19 checks. Which
class cancels the indent is read out of the css rather than named in the test,
since a drift between the two would bring the indent back without erroring.
It also checks that no row carrying the class has a label, because a label in
a row with no gutter to be pulled into would hang off the left edge -- the
same mistake in the other direction. Verified the checks fail when the
table's row loses the class (2), when the css renames it and the view does
not (2), and when the class is put on a labelled row (1).

Ran: tests/php/test_monitor_actions_columns.php (19 passed), the rest of
tests/php and tests/js, npx eslint . (clean), utils/check-homoglyphs.py
(clean). test_bandwidth_clamp.php fails on this box for an unrelated reason:
it cannot read /etc/zm/conf.d/03custom.conf and the permission warning lands
in its captured output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 db4dd75e14 fix: make submitTab come back on the tab you were working in
submitTab() resubmits contentForm without saving, so the view re-renders for a
value that changes which fields it should show. It did
form.attr('tab', tab), which sets an attribute named "tab" on the <form>
element and has no effect whatever: an attribute on a form is not a submitted
value. The view reads $_REQUEST['tab'], which comes from the hidden field.

Writing it correctly would have been worse than leaving it broken. The value
it was being handed, data-tab-name, is the tab the page loaded on, while the
hidden field now holds the tab that is on screen. Assigning the attribute's
value would have undone that tracking and sent anyone who had switched tabs
back to the one they arrived on -- the bug this looks like it would fix. So
the tab is not written here at all, and the field is left to do its job.

Drop data-tab-name from the Method select, since nothing reads it now. An
attribute nothing reads is a trap: the next person here would reasonably
assume it was load bearing.

The submit stays on $j(...).submit() rather than the element's own submit(),
which is worth saying out loud in a comment because the difference is
invisible: jQuery triggers contentForm's submit handler, which validates the
form and blanks the password fields, and a native submit skips both.

Noted, not changed: skin.js also binds submitTab to ".pillList a" on click,
and no markup anywhere in the tree carries that class, so it matches nothing.
It was the only caller that would have wanted data-tab-name's meaning. Left
alone as dead but harmless, and in a file every view loads.

Tests: tests/js/monitor-tab-submit.test.js, 9 checks. skin.js and monitor.js
are loaded into one sandbox, as a page has them, because the behaviour needs
both: monitor.js tracks the tab into the hidden field and skin.js submits it.
The cases are built so that the loaded tab and the showing tab differ, since
any test where they agree would pass whether or not the bug is fixed.
Verified the checks fail when the old attr call is restored (2), when the
naive fix of writing data-tab-name into the field is applied (4), and when
the submit is changed to a native one (7).

Ran: tests/js/monitor-tab-submit.test.js (9 passed), monitor-tab-url.test.js
(14 passed), the rest of tests/js and tests/php, npx eslint . (clean),
utils/check-homoglyphs.py (clean). test_bandwidth_clamp.php fails on this box
for an unrelated reason: it cannot read /etc/zm/conf.d/03custom.conf and the
permission warning lands in its captured output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-10-28 18:01:26 -05:00
Isaac ConnorandClaude Opus 5 d884958e07 feat: keep the selected monitor tab in the url so a reload returns to it
The view already honoured ?tab=<name> on the way in: php marks that pill
active and renders its pane shown. What was missing was the other half. The
pills are switched client side, so the url kept saying whatever it said when
the page loaded, and a reload -- or a shared link, or the browser restoring
the page -- dropped you back on General.

Write the tab into the url on shown.bs.tab. replaceState rather than
pushState, as filter.js and event.js do: switching tabs in a settings form is
not navigation, and making Back walk back through it would be unhelpful on a
form holding unsaved edits. Only the tab parameter is touched, since the url
carries mid and may carry more.

Update the hidden tab field at the same time. Changing Type or Method
resubmits the form without action=save to redraw the fields that type needs,
and that field is what tells the view where to come back to; php writes it
once with the tab the page loaded on, so that submit also used to land you
back on whichever tab you arrived on rather than the one you were working in.

Noted while reading, not changed here: submitTab() in skin.js sets a `tab`
attribute on the form element rather than the hidden input's value, which
does nothing. It is invisible today because its data-tab-name is the tab the
page loaded on, the same value the input already held, and with this change
the input is now correct anyway. It is shared by every view, so it is left
alone.

Tests: tests/js/monitor-tab-url.test.js, 14 checks, run against monitor.js in
a vm sandbox as the other view script tests are. Most of them are about what
the rewrite leaves alone: rebuilding a url to change one parameter is an easy
way to drop the others, and mid is the one the whole page depends on. The
handler finds the pills by a selector and reads the tab name out of an href,
both of which are shapes in monitor.php, and if either changes the handler
stops firing without erroring -- the tabs would keep working and only the url
would be wrong -- so the markup is checked against the selector the handler
registered rather than against a copy of it. Verified the checks fail when
the pill list is renamed (1), when the no-op guard is removed (1) and when
the query is rebuilt from scratch instead of amended (3).

Ran: tests/js/monitor-tab-url.test.js (14 passed), the rest of tests/js and
tests/php, npx eslint . (clean), utils/check-homoglyphs.py (clean).
test_bandwidth_clamp.php fails on this box for an unrelated reason: it cannot
read /etc/zm/conf.d/03custom.conf and the permission warning lands in its
captured output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-10-28 18:01:25 -05:00
Isaac ConnorandClaude Opus 5 9752477083 fix: give the monitor actions table fixed column widths refs #4957
The Enabled column already asked for 5em, but under the default automatic
table layout a width on a cell is only a suggestion: the browser sizes each
column from its contents and may hand one more than it asked for. So Enabled
kept claiming space on the strength of its header text while holding a single
checkbox, and Schedule -- which has to fit "Every weekend day (Sat & Sun) at
08:00" across a label, a select and a time input -- was the column that got
squeezed.

Make the layout fixed, which binds the declared widths exactly. Enabled and
Sound are pinned to what a checkbox and a number need, Schedule is pinned to
what its sentence needs, and When, Device and Does divide what is left: those
three are selects, and a select that runs short truncates its option text
rather than breaking the row. zone.css already lays a table out this way, so
it is not a new approach in the skin.

A fixed layout will not shrink a control to fit its column, so the selects
and inputs are capped at the column width. Without that a long monitor name
in Device would spill over Does.

Tests: tests/php/test_monitor_actions_columns.php, 13 checks. The widths are
addressed positionally, as :nth-child(1), (3) and (6), because the cells carry
no classes -- which is fine until a column is inserted, when every pin after
it silently moves one column right and nothing errors. So the css is read for
which positions it pins, the view for what is in each position, and the two
are compared. Verified the checks fail when table-layout is removed (1
failure) and when a column is inserted before Schedule (6).

Not verified in a browser: the install under /usr/share/zoneminder/www is a
copy rather than a symlink of this tree, so the em values are a judgement
about what each column holds rather than something measured on screen.

Ran: tests/php/test_monitor_actions_columns.php (13 passed), the rest of
tests/php and tests/js, npx eslint . (clean), utils/check-homoglyphs.py
(clean). test_bandwidth_clamp.php fails on this box for an unrelated reason:
it cannot read /etc/zm/conf.d/03custom.conf and the permission warning lands
in its captured output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-10-28 18:01:25 -05:00
Isaac ConnorandClaude Opus 5 14ce69513d feat: add and edit scheduled tasks in a modal refs #4957
The scheduled tasks view carried an inline form above the list, so the page
was two things at once: the form was always on screen whether or not anyone
was editing, and the list it belonged to was pushed below the fold.

Make the view the list, and move adding and editing into a modal built by
web/ajax/modals/cronjob.php, following the pattern the Options modals use.
The Add button and the task name both open it; the modal posts back to
?view=cron, so includes/actions/cron.php still does the validating and the
crontab rebuild and did not have to change.

Two things follow from the form being built over ajax rather than with the
page. The modal emits getCSRFinputHTML() itself, since nothing else wraps it.
And cron.js binds the schedule pickers after the html is inserted instead of
on DOMContentLoaded: the controls do not exist until then, and a fresh set
arrives each time the modal opens.

While moving the When row, give the pickers a placeholder when they cannot
draw the stored schedule. Previously an existing task set to something like
"*/5 * * * *" rendered the pickers at their default, so the page showed
"Every day at 03:00" in large friendly controls beside a cron field reading
"*/5 * * * *" -- two different answers at once, with the wrong one the more
prominent. Adding a task still defaults to daily at 03:00, which is a default
rather than a contradiction.

Tests: tests/php/test_cron_modal.php, 42 checks. The field names are now a
contract between files that do not mention each other, so each side is parsed
out of its own source and compared rather than restated. The three states of
the When row are checked by rendering the modal against stubs, since which
picker is selected and whether the cron field is open are decisions the php
makes. Verified the new checks fail when the placeholder, a field name or the
csrf input is removed.

Ran: tests/php/test_cron_modal.php (42 passed), test_crontab.php (88 passed),
test_allowed_commands.php (31 passed), tests/js/cron-schedule.test.js (12
passed), the rest of tests/php and tests/js, npx eslint . (clean),
utils/check-homoglyphs.py (clean). test_bandwidth_clamp.php fails on this box
for an unrelated reason: it cannot read /etc/zm/conf.d/03custom.conf and the
permission warning lands in its captured output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-28 18:01:25 -05:00
Isaac ConnorandClaude Opus 5 0db43d9c1d fix: lay the scheduled tasks page out in one column refs #4957
The form put four fields of very different widths into one twelve column row
-- Name 3, Command 5, Server 2, Enabled 2 -- so the longest value on the page
had the least room and the checkbox sat under a label with a <br/> that lined
up with nothing. The submit button then shared a row with the schedule
controls.

Worse than any of that, the pickers and the cron field stood side by side as
equal peers: two controls for one value, with nothing saying which won. That
is the question the layout invited and never answered.

Lay it out as a horizontal form instead, one concept per row, label left:
name, command, when, server, enabled, then the buttons on their own row. The
command gets the full width since it is the longest thing anyone types here.

The cron field moves inside a <details> under the pickers, labelled "Enter as
cron". It is still the control that is submitted, because cron can express
schedules the pickers cannot and one set that way has to survive being
edited; but it now reads as a consequence of the pickers rather than a rival
to them. A task whose schedule the pickers cannot draw opens the disclosure
on load, so it is in front of whoever opened the row rather than hidden from
them.

The list loses its separate Edit column -- the name is the link, as it is on
the other options pages -- and the schedule column carries the raw spec as a
title, so it can be checked without opening the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-28 18:01:25 -05:00
Isaac ConnorandClaude Opus 5 78f389cdea feat: add ZM_ALLOWED_COMMANDS to restrict what filters and tasks may run refs #4957
Two places in ZoneMinder run a command the operator typed: a filter with
"Execute command", and the scheduled tasks added for #4957. Both run it as the
web user through a shell, which is close to shell access on the server. Until
now the only control over that was who may edit, and the answer was anyone
with System rights.

ZM_ALLOWED_COMMANDS names the programs that may be run, comma separated.
Empty is the default and places no restriction, so an upgrade changes nothing
and the filters people already have keep working; setting it narrows both
features at once.

Only the program is matched, because an exact whole-command match could never
work for filters: zmfilter.pl appends the event path to whatever the filter
stored, so no two runs have the same command line. Arguments are therefore
free, which means the program being on the list has to be the whole of the
guarantee -- so while a list is in force a command may not contain the
characters that start another program: semicolon, pipe, ampersand, backtick
and dollar-paren. Without that, "/usr/local/bin/backup.sh; anything" would
pass on the strength of its first word. Redirection is deliberately allowed;
it starts no program, and refusing it would break the ordinary
"... >> /var/log/mine.log".

Refused in three places, because each answers a different question. The
filter and scheduled task editors refuse on save, so the operator is told at
the time rather than by a log line days later. zmfilter.pl refuses at the
moment it runs, because a row can reach the database without passing through
the web ui and because the setting may have been narrowed since. And
cronRebuild leaves a newly disallowed task out of the crontab, so tightening
the setting takes jobs away rather than waiting for somebody to edit each one.

The rule exists twice, in web/includes/Command.php and ZoneMinder::General,
since one is PHP and the other Perl. Rather than hope they stay in step,
tests/php/test_allowed_commands.php puts every case to both and fails if they
disagree. That earned itself immediately: both split the command on
whitespace before handling quotes, so a program path containing a space was
cut in half and compared as the wrong string. The cross-check found it in
both at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-28 18:01:25 -05:00
Isaac ConnorandClaude Opus 5 f90ada1958 feat: add a UI for scheduled tasks, backed by the crontab refs #4957
This is the rest of what #4957 asked for -- run Perl, PHP, bash and binaries
on a flexible schedule, from a visual interface -- and it needs no daemon,
because cron is the daemon. 1.39.37 already taught ZoneMinder to rewrite a
marked block in the web user's crontab for scheduled monitor actions while
leaving every line outside that block alone. A CronJobs row is another line in
the same block.

So the parts that would otherwise have to be written and then supervised --
timing, catch-up, restart on boot, running as a known user -- are cron's, and
have been since 1975. What ZoneMinder adds is the table, the page, and the
rule that the crontab is a projection of the table: rebuilt whenever a row
changes, never the other way round, so the two cannot drift.

The schedule uses the same pickers the monitor actions got, reading "Every
Monday at 7pm", with the cron field beside them for anything the pickers
cannot say. The pickers build the spec in the browser so the field fills in as
you choose, and the server builds and validates it again on save, because the
browser is not what keeps a crontab line honest. The two mappings are checked
against each other in tests/js/cron-schedule.test.js, which reads the PHP map
rather than restating it.

The command is not judged on what it does; running things is the point. It is
judged on whether it stays on its own line and means in cron what the operator
meant: a newline is refused because it would end the entry and let the rest
become a line with a schedule of its own, and an unescaped % is refused
because cron stops the command there and feeds the remainder in as stdin,
which would silently run less than was typed. Cron's own escape, \%, is
accepted.

Editing takes System edit rights, and every add, change and delete is logged
with the command. Anyone who can edit here can run commands as the web user,
which is worth saying plainly -- though it is the same trust Filters'
AutoExecuteCmd has carried for years, not a new one.

ServerId NULL means every server writes the job into its own crontab, which
is what a single server install wants without having to say so; naming a
server restricts it to that one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-28 18:01:25 -05:00
Isaac ConnorandClaude Opus 5 167c60f52b feat: pick a schedule as "Every Monday at 7pm" rather than as cron refs #4957
Five cron fields are a reasonable thing to store and an unreasonable thing to
put in front of an operator. The editor now reads as the sentence it means:

  Every [day            v] at [08:00]
  Every [Monday         v] at [19:00]
  Every [weekday (Mon-Fri) v] at [07:30]

Cron stays the stored form, because it is what goes in the crontab and it can
say things the two controls cannot. cronFromParts turns what the editor
collected into a spec and cronToParts turns one back, so a saved schedule
loads into the same controls that made it.

cronToParts is deliberately partial. A spec the controls cannot draw -- every
fifteen minutes, the first of the month, hourly, a pair of weekdays -- comes
back null, and the editor shows it as it stands and saves it back untouched
through a read-only field. Rounding someone's schedule to the nearest thing
the pickers understand would be worse than admitting the editor cannot draw
it.

The time comes from <input type="time">, so it is shown in whatever format
the operator's locale uses; 8am and 08:00 are the same control. The schedule
is still built and validated server side, since the browser is not what keeps
a crontab line honest.

Tested for the round trip over every frequency and a spread of times: what
the editor can build must survive being saved and loaded back, or editing a
row would quietly change when it runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-28 18:01:25 -05:00
Isaac ConnorandClaude Opus 5 a30665c164 feat: run monitor actions at a time of day refs #4957
that already exist -- lights on, lights off, play a sound -- at set times.

MonitorActions already says what to do and to which device, with triggers for
EventStart, Alarm, Manual and so on. So this adds a trigger rather than a
subsystem: an action whose TriggerOn is 'Schedule' carries a five field cron
spec, and cron runs it.

Cron, rather than a daemon of our own, because the web user owns a crontab it
can rewrite without privilege, and because the line cron runs,

  30 22 * * * /usr/bin/zmcontrol.pl --id 24 --command=lightOff

is the same invocation web/includes/Monitor.php already falls back to when no
zmcontrol daemon is listening. A scheduled action therefore takes the path a
manual one does rather than a parallel one of its own. It also means no new
code runs as root, nothing new has to be supervised, and the feature is a
table column plus a crontab block.

Nothing a form can set reaches a shell. The schedule is refused unless
cronScheduleIsValid() accepts it -- five fields, numbers and the usual
*/,-/ only, with newline, carriage return and % rejected outright because
each would end the line or the command. The command itself comes from
MonitorAction::commandFor()'s fixed list and the ids are integers, so the
whole line is assembled from constrained parts. That is a narrower surface
than Filters.AutoExecuteCmd, which has taken an arbitrary command for years.

The crontab is not ours to own, so ZoneMinder rewrites only a block between
two markers and leaves every other line byte for byte. A block whose end
marker is missing, which is what an interrupted write leaves, is treated as
no block at all rather than swallowing everything after it: losing our own
stale block is the safe failure, losing the admin's entries is not. The block
is rebuilt from the table on every save, so the crontab is a projection of
the database rather than a second place to keep the truth.

zmc recognises the new trigger and skips it, so having one does not warn on
every reload, and nothing fires a scheduled action from the capture process.

An action targeting a monitor on another server is left out and logged: cron
runs zmcontrol.pl locally, and the web ui's proxy through the API has no cron
equivalent. Single server installs are unaffected.

The two pieces that can lose data or run the wrong thing are pure, and
tests/php/test_crontab.php covers them: schedule validation including the
injection cases, and splicing a block in, out and over without disturbing the
lines around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-28 18:01:25 -05:00
Isaac ConnorandClaude Opus 5.5 800e7e87a0 fix: weigh monitor Importance when logging stream info and missing dimensions
A camera that keeps rebooting repeatedly fails avformat_find_stream_info
or reports a video stream with no width/height. Log both at a level
adjusted by the monitor's Importance so Less/Not important monitors
stop flooding the logs with errors and warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:52:02 -04:00
Isaac ConnorandClaude Opus 5.5 0b440c4979 fix: build gsoap's C plugins with WITH_OPENSSL so CodeQL builds on GCC 15
-DWITH_OPENSSL -DWITH_DOM were only appended to CMAKE_CXX_FLAGS, but
smdevp.c, mecevp.c and logging.c are compiled as C. Without WITH_OPENSSL,
stdsoap2.h does not include <openssl/rand.h>, so mecevp.c calls RAND_bytes
with no declaration. -Wno-implicit-function-declaration hid this until
ubuntu-latest moved to 26.04 (GCC 15), where the CodeQL autobuild fails
with "implicit declaration of function 'RAND_bytes'".

Append the same defines to CMAKE_C_FLAGS and drop the
-Wno-implicit-function-declaration workaround from mecevp.c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:19:34 -04:00
Isaac ConnorandClaude Opus 5.5 e0682155f3 ci: pin aarch64 package builds to ARM64 self-hosted runners
The deb and rpm aarch64 workflows asked for a bare self-hosted runner.
x64 self-hosted runners now also serve this repository, so those jobs
could land on one and publish x64 packages to the aarch64 repos.
Require the ARM64 label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:34:10 -04:00
Isaac ConnorandClaude Opus 5.5 42c37cdcbb fix: close the zms command socket as soon as the command thread stops
The command socket stayed bound until ~StreamBase(), which runs only after
the stream thread has finished. A zms that was shutting down but still busy
in the stream thread (blocked writing to a client that had gone away, for
instance) kept accepting commands it would never read. web/ajax/stream.php
then waited out its whole MSG_TIMEOUT on each one and logged "Timed out
waiting for msg ... after waiting 5000 milliseconds", where a missing socket
would have been reported as no_socket at once.

Call closeComms() when checkCommandQueue() leaves its loop. sd is only used
from the command thread, and closeComms() clears sd and lock_fd, so the
destructor's later call does nothing and cannot remove a socket bound by a
successor zms on the same connkey.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:12:27 -04:00
Isaac ConnorandClaude Opus 5.5 f223b38e04 fix: ignore MonitorStream replies for a connkey that was already replaced
When a monitor's zms exits, the status poll gets reason=no_socket and the
stream is reloaded under a new connkey. A no_socket reply takes over a second
(stream.php waits for the socket file to appear) while the poll runs every
second, and every request on the page shares one ajaxQueue, so a second poll
for the dead connkey is usually already queued when the first reply arrives.
Its reply then reloaded the stream again: it sent CMD_QUIT to the zms the
first reload had just started and replaced the connkey a second time.

Each restart of an unreachable monitor therefore left an orphaned zms behind,
and a poll queued for that orphan between the two reloads could find it
shutting down. stream.php then waited the full MSG_TIMEOUT and logged
"Timed out waiting for msg ... after waiting 5000 milliseconds", while every
other monitor's poll on the page waited behind it in the shared queue.

Bind each reply to the connkey its request was sent with and drop replies
for a connkey we no longer hold, as EventStream already does. alarmCommand()
passes its connkey explicitly, since binding getStreamCmdResponse directly
would hand it jQuery's textStatus in that argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:12:27 -04:00
Isaac Connor 535f8285c3 Merge branch 'master' of github.com:ZoneMinder/zoneminder 2026-10-26 09:17:42 -04:00
Isaac Connor 344a2c593c Remove arp-scan from rpm build env 2026-10-26 09:17:36 -04:00
Isaac Connor 03cfd5b6f4 Merge pull request #5349 from nabbi/fix/az11-alarm-cause
feat: record the cause given to the monitors alarm API
2026-10-25 22:20:56 -04:00
Nic Boet 6fe673ee9c feat: record the cause given to the monitors alarm API
monitors/alarm/command:on now takes an optional cause (query or POST data)
and passes it to zmu --cause, so the event records it in place of
"Forced Web". Without a cause nothing changes.

The cause is stored as the event's Cause and in its Notes, so alarmCause()
keeps it to plain text: letters, digits, spaces and plain punctuation, cut
to the 31 bytes zmu stores on a character boundary, falling back to the
default when nothing is left. The value is quoted with escapeshellarg()
for the zmu command line.

Test: tests/php/test_monitor_alarm_cause.php

fixes #5348
2026-10-25 20:58:53 -05:00
Nic BoetandClaude Opus 5.5 d0ac073b46 feat: add --cause to zmu for forced alarms
zmu -a always recorded "Forced Web" as the event's cause. --cause <text>
sets it instead; without it, or with an empty value, the cause stays
"Forced Web". ForceAlarmOn keeps the first 31 bytes, as for zmtrigger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:21:50 -05:00
Isaac Connor 260a475183 Merge pull request #5319 from connortechnology/5300-vaapi-device-reuse
fix: reuse one encoder hw device context per monitor instead of per event
2026-10-25 10:39:00 -04:00
Isaac Connor 457535242e Merge pull request #5345 from SteveGilvarry/readme-docker
Point the README at the official Docker images
2026-10-25 10:37:54 -04:00
Isaac Connor b75903e888 Merge pull request #5346 from SteveGilvarry/docs-docker
docs: add a Docker page to the installation guide
2026-10-25 10:37:41 -04:00
Isaac ConnorandClaude Opus 4.8 20e7d68d05 fix: harden remote control request forwarding refs #5343
Follow-up hardening to the multi-server control forwarding added in
#5343:

- remoteControlUrl() now forwards an allowlist of known control
  parameters (id, control, xge, yge, x, y, scale, preset, newLabel,
  action, aid, response) instead of passing everything that is not on a
  denylist, so no stray key or cookie-folded value reaches the other
  server.
- forwardControlRequest() sets a 15s timeout so an unreachable recording
  server can't hang an interactive control click for default_socket_timeout,
  and, with ignore_errors, treats a non-2xx response (e.g. an auth-relay
  login redirect's HTML) as a failure.
- relayControlReply() refuses to pass a non-JSON body back to the browser
  under a JSON content-type.

Extends the test to assert unknown parameters are not forwarded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-25 09:59:36 -04:00
Isaac Connor bc44e4c09e Merge pull request #5343 from nabbi/fix/b120-remote-control
fix-medium: forward PTZ and monitor action requests to the server that records the monitor
2026-10-25 09:56:59 -04:00
Steve GilvarryandClaude Opus 5.5 a99e114dc4 docs: add a Docker page to the installation guide
The only Docker page was packpack.rst, which builds packages in a
container rather than running ZoneMinder. The project now publishes
images on Docker Hub and GHCR from the zmdockerfiles repo.

The new page, listed after RedHat in the installation guide, covers the tags, the
compose quick start, first-run setup, volumes and ports, go2rtc live view
(with a warning that its streams aren't behind ZoneMinder's login),
hardware decoding, the event server images, moving from the old images,
and verifying signatures. It links to the zmdockerfiles README for the
full list of settings rather than repeating it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKoPm5qySMSsCYJmCo8r2J
2026-10-25 20:01:06 +11:00
Steve GilvarryandClaude Opus 5.5 5ba57fefaa Point the README at the official Docker images
The README said Docker issues go to zmdockerfiles, and a Docker section
under Package Maintainers still described a Dockerfile in this repo that
"needs work". That Dockerfile moved out years ago.

Replace both with a "Run with Docker" section under Installation Methods.
It names the images on Docker Hub and GHCR, explains the tags, and gives
the compose quick start from zmdockerfiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKoPm5qySMSsCYJmCo8r2J
2026-10-25 19:47:50 +11:00
Steve Gilvarry bbf4f994c8 Merge pull request #5336 from connortechnology/5335-stream-socket-codec-name
feat: send the codec name in the stream socket HELLO
2026-10-25 13:21:18 +11:00
Isaac Connor 5ca87233cf Merge pull request #5341 from nabbi/fix/b119-renumber-next
fix-low: skip a missing event directory in zmeventtool renumber instead of dying
2026-10-24 19:44:19 -04:00
Nic BoetandClaude Opus 5.5 93c0114e7a fix: don't send camera commands to another server's daemonControl API
For a monitor recorded by another server, sendControlCommand() put any
zmcontrol command into a monitors/daemonControl URL, which only takes
start, stop and quit. Those still go there; other commands now log why
they were not sent and return false instead of requesting a malformed
URL.

fixes #5342

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:38:45 -05:00
Nic Boet 877342fe2e fix: skip a missing event directory in zmeventtool renumber instead of dying
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
2026-10-24 10:33:16 -05:00
Nic BoetandClaude Opus 5.5 99cf213f62 fix: forward control requests to the server that records the monitor
In a multi-server setup a PTZ move, preset or light command sent to one
server for a monitor that another server records never reached the
camera. Monitor::sendControlCommand() passed the zmcontrol option string
to the other server's monitors/daemonControl API, which only starts and
stops zmcontrol.pl, so the request built a URL like
daemonControl/4/ --panspeed=51 ... --command=moveConLeft/zmcontrol.pl.json
and answered "Failed". The web UI's own PTZ buttons send to the
monitor's server and were not affected; clients that send every request
to the server they log in to (zm-py, pyzmNg, zmNinjaNg, aiozoneminder)
and monitor actions whose target is on another server were.

ajax/control.php now hands such a request, as received, to the index.php
of the server that records the monitor (or the action's target), with
the auth relay in place of the caller's credentials, and answers with
that server's reply. That server checks the permissions and builds the
command itself. A request is forwarded at most once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:24:29 -05:00
Isaac Connor 1bdccda402 Merge pull request #5331 from connortechnology/fix-rtsp-auth-oob-r8cq
fix: guard malformed auth directives in RTSP/HTTP auth parser (GHSA-r8cq-jgx6-gr5p)
2026-10-24 10:28:20 -04:00
Isaac Connor db67481dd0 Merge pull request #5332 from connortechnology/fix-states-packagecontrol-9gc6
fix: make StatesController::packageControl protected (GHSA-9gc6-9xg2-5636)
2026-10-24 10:27:58 -04:00