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
This commit is contained in:
Isaac Connor authored and GitHub committed 2026-10-25 09:56:59 -04:00
commit bc44e4c09e
4 files changed
+157

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
<?php
// Tests remoteControlUrl() in web/includes/control_functions.php: in a
// multi-server setup a PTZ or other control request for a monitor that another
// server records is handed to that server, which runs its zmcontrol.pl.
//
// Before, the server that got the request sent the zmcontrol option string to
// the other server's monitors/daemonControl API, which only starts and stops
// zmcontrol.pl, so the camera never moved and the request answered "Failed".
//
// Run: php tests/php/test_remote_control_url.php
define('ZM_SERVER_ID', 1);
$stubdir = sys_get_temp_dir().'/zm_remote_control_stubs_'.getmypid();
@mkdir($stubdir, 0700, true);
file_put_contents($stubdir.'/logger.php', "<?php\nnamespace ZM;\nfunction Warning(\$s) {}\nfunction Error(\$s) {}\nfunction Debug(\$s) {}\n");
set_include_path($stubdir.PATH_SEPARATOR.get_include_path());
require_once __DIR__.'/../../web/includes/control_functions.php';
function get_auth_relay() { return 'auth=0123456789abcdef&user=admin'; }
class StubServer {
private $id;
public function __construct($id) { $this->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);
+17
View File
@@ -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);
}
?>
+6
View File
@@ -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 {
+41
View File
@@ -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();