From 99cf213f6259b55624c8fd70ee63cc083a5fb77f Mon Sep 17 00:00:00 2001 From: Nic Boet Date: Sat, 24 Oct 2026 10:15:55 -0500 Subject: [PATCH 1/2] 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 --- tests/php/test_remote_control_url.php | 93 +++++++++++++++++++++++++++ web/ajax/control.php | 17 +++++ web/includes/control_functions.php | 41 ++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 tests/php/test_remote_control_url.php diff --git a/tests/php/test_remote_control_url.php b/tests/php/test_remote_control_url.php new file mode 100644 index 000000000..d1e1f9e09 --- /dev/null +++ b/tests/php/test_remote_control_url.php @@ -0,0 +1,93 @@ +id = $id; } + public function Id() { return $this->id; } + public function UrlToIndex() { return 'http://zm2.example:8080/zm/index.php'; } +} + +class StubMonitor { + private $serverId; + private $serverKnown; + public function __construct($serverId, $serverKnown = true) { + $this->serverId = $serverId; + $this->serverKnown = $serverKnown; + } + public function ServerId() { return $this->serverId; } + public function Server() { return new StubServer($this->serverKnown ? $this->serverId : null); } +} + +$failures = 0; +$passes = 0; + +function check($name, $got, $want) { + global $failures, $passes; + if ($got === $want) { + $passes++; + echo "ok - $name\n"; + } else { + $failures++; + echo "FAIL - $name (got ".var_export($got, true).", want ".var_export($want, true).")\n"; + } +} + +$request = array( + 'view' => 'request', 'request' => 'control', 'id' => '4', 'control' => 'moveConLeft', + 'xge' => '50', 'yge' => '50', 'token' => 'jwt', 'username' => 'admin', 'password' => 'secret', +); + +check('monitor on this server is handled here', remoteControlUrl(new StubMonitor(1), $request), null); +check('monitor with no server is handled here', remoteControlUrl(new StubMonitor(0), $request), null); +check('monitor on a server ZoneMinder does not list is handled here', + remoteControlUrl(new StubMonitor(2, false), $request), null); +check('a request forwarded once is handled where it lands', + remoteControlUrl(new StubMonitor(2), $request + array('forwarded' => '1')), null); + +$url = remoteControlUrl(new StubMonitor(2), $request); +check('monitor on another server goes to that server', + strtok($url, '?'), 'http://zm2.example:8080/zm/index.php'); +parse_str(parse_url($url, PHP_URL_QUERY), $sent); +check('the control request is passed on', array( + isset($sent['view']) ? $sent['view'] : null, + isset($sent['request']) ? $sent['request'] : null, + isset($sent['id']) ? $sent['id'] : null, + isset($sent['control']) ? $sent['control'] : null, + isset($sent['xge']) ? $sent['xge'] : null, + isset($sent['forwarded']) ? $sent['forwarded'] : null, +), array('request', 'control', '4', 'moveConLeft', '50', '1')); +check('the caller\'s credentials are not passed on', array( + isset($sent['token']), isset($sent['password'])), array(false, false)); +check('the auth relay says who is asking', array( + isset($sent['auth']) ? $sent['auth'] : null, isset($sent['user']) ? $sent['user'] : null), + array('0123456789abcdef', 'admin')); + +$action = array('view' => 'request', 'request' => 'control', 'action' => 'monitorAction', 'id' => '3', 'aid' => '7'); +parse_str(parse_url(remoteControlUrl(new StubMonitor(2), $action), PHP_URL_QUERY), $sent); +check('a monitor action keeps its action and ids', array($sent['action'], $sent['id'], $sent['aid']), + array('monitorAction', '3', '7')); + +array_map('unlink', glob($stubdir.'/*')); +@rmdir($stubdir); + +echo "\n$passes passed, $failures failed\n"; +exit($failures ? 1 : 0); diff --git a/web/ajax/control.php b/web/ajax/control.php index 2777da4b8..da1e8b729 100644 --- a/web/ajax/control.php +++ b/web/ajax/control.php @@ -37,6 +37,9 @@ if ( isset($_REQUEST['action']) and ($_REQUEST['action'] == 'monitorAction') ) { if ( !canView('Control', $target->Id()) ) ajaxError('Insufficient permissions on the target monitor'); + if ( $url = remoteControlUrl($target, $_REQUEST) ) + relayControlReply($url); + $ctrlCommand = $monitorAction->controlCommand(); if ( !$ctrlCommand ) ajaxError('Action has no usable command'); @@ -52,6 +55,9 @@ if ( isset($_REQUEST['action']) and ($_REQUEST['action'] == 'monitorAction') ) { if ( canView('Control', $_REQUEST['id']) ) { $monitor = new ZM\Monitor(validCardinal($_REQUEST['id'])); + if ( $url = remoteControlUrl($monitor, $_REQUEST) ) + relayControlReply($url); + $ctrlCommand = buildControlCommand($monitor); if ( !$ctrlCommand ) { @@ -73,4 +79,15 @@ if ( canView('Control', $_REQUEST['id']) ) { } ajaxError('Unrecognised action or insufficient permissions'); + +// Hands the request to the server that records the monitor and answers with its reply. +function relayControlReply($url) { + $reply = forwardControlRequest($url); + if ( $reply === false ) + ajaxError('Failed'); + if ( function_exists('ajaxCleanup') ) + ajaxCleanup(); + header('Content-type: application/json'); + exit($reply); +} ?> diff --git a/web/includes/control_functions.php b/web/includes/control_functions.php index 9a2e3a6e4..0e7199dc7 100644 --- a/web/includes/control_functions.php +++ b/web/includes/control_functions.php @@ -2,6 +2,47 @@ require_once('logger.php'); +// In a multi-server setup only the server that records a monitor can drive its +// camera: zmcontrol.pl runs there. When that is another server, returns the URL +// that hands it this same control request, or null to handle it here. The other +// server checks the permissions and builds the command itself. A request that +// was forwarded once is handled where it lands, so two servers that disagree +// about the monitor's server don't pass it back and forth. +function remoteControlUrl($monitor, $request) { + if ( !defined('ZM_SERVER_ID') or !ZM_SERVER_ID ) return null; + if ( !$monitor->ServerId() or ($monitor->ServerId() == ZM_SERVER_ID) ) return null; + if ( !empty($request['forwarded']) ) return null; + + $server = $monitor->Server(); + if ( !$server->Id() ) return null; + + // The other server learns who is asking from the auth relay, not from the + // credentials this request came with. + $params = array('view'=>'request', 'request'=>'control'); + foreach ( $request as $name => $value ) { + if ( !in_array($name, array('view', 'request', 'token', 'auth', 'user', 'username', 'password', 'pass', 'forwarded')) ) + $params[$name] = $value; + } + $params['forwarded'] = 1; + $url = $server->UrlToIndex().'?'.http_build_query($params); + $auth_relay = get_auth_relay(); + if ( $auth_relay ) $url .= '&'.$auth_relay; + return $url; +} + +// Sends a control request to the server that records the monitor and returns +// its JSON reply, or false when that server can't be reached. +function forwardControlRequest($url) { + $context = stream_context_create(array( + 'ssl'=>array('verify_peer'=>false, 'verify_peer_name'=>false), + )); + $reply = @file_get_contents($url, false, $context); + if ( $reply === false ) { + ZM\Error('Could not forward the control request to '.strtok($url, '?')); + } + return $reply; +} + function buildControlCommand($monitor) { $ctrlCommand = ''; $control = $monitor->Control(); From 93c0114e7a97a9b8afe6a961d68ef8d4d78bdd38 Mon Sep 17 00:00:00 2001 From: Nic Boet Date: Sat, 24 Oct 2026 10:15:55 -0500 Subject: [PATCH 2/2] 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 --- web/includes/Monitor.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/includes/Monitor.php b/web/includes/Monitor.php index 6c892cc3c..d60c9881a 100644 --- a/web/includes/Monitor.php +++ b/web/includes/Monitor.php @@ -872,6 +872,12 @@ class Monitor extends ZM_Object { } socket_close($socket); } else if ($this->ServerId()) { + # The API only starts and stops zmcontrol.pl there; a camera command has to + # go to that server's web request (ajax/control.php forwards those). + if (!in_array($command, array('quit', 'start', 'stop'))) { + Error('Control command for monitor '.$this->{'Id'}.' not sent: it is recorded by server '.$this->{'ServerId'}); + return false; + } $result = $this->Server()->SendToApi('/monitors/daemonControl/'.$this->{'Id'}.'/'.$command.'/zmcontrol.pl.json'); return $result; } else {