Files
zoneminder/web/api/app/Controller/MonitorsController.php
T
Nic Boet 35e36e4dda fix: give zmu a short-lived token instead of the password or auth hash
getZmuCommand() and the API's alarm action put the user's credentials on
zmu's command line, where every local user can read them (ps,
/proc/<pid>/cmdline): an auth hash with the default hashed relay, the
plaintext password with ZM_AUTH_RELAY=plain. The API also wrote the
whole command to the debug log. With the default ZM_AUTH_HASH_IPS=0 that
hash is accepted as an auth= login from any address for ZM_AUTH_HASH_TTL
hours.

zmuCredentials() builds them in one place: with a usable
ZM_AUTH_HASH_SECRET, an access token for the user that expires after a
minute (zmu -T, accepted with every relay). Without the secret zmu
refuses tokens, so it gets what it did before. The API logs the command
without the credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 32bc2b3fee87b752590ba6f210403afd9222d9ff)
2026-10-30 19:30:27 -04:00

511 lines
18 KiB
PHP

<?php
App::uses('AppController', 'Controller');
/**
* Monitors Controller
*
* @property Monitor $Monitor
* @property PaginatorComponent $Paginator
*/
class MonitorsController extends AppController {
/**
* Components
*
* @var array
*/
public $components = array('Paginator', 'RequestHandler');
public function beforeRender() {
$this->set($this->Monitor->enumValues());
}
public function beforeFilter() {
parent::beforeFilter();
}
/**
* index method
*
* @return void
*/
public function index() {
$this->Monitor->recursive = 0;
if ($this->request->params['named']) {
$this->FilterComponent = $this->Components->load('Filter');
$conditions = $this->FilterComponent->buildFilter($this->request->params['named']);
} else {
$conditions = array();
}
// Only join Groups_Monitors when the request actually filters by group, so
// a bare GroupId named param can resolve against that table. Joining it
// unconditionally multiplied rows for monitors in multiple groups, which we
// used to collapse with GROUP BY `Monitor`.`Id`. That GROUP BY fails under
// ONLY_FULL_GROUP_BY on engines without functional-dependency detection
// (e.g. MariaDB), which rejects the non-grouped SELECT columns. Refs #3633.
$group_filter = false;
foreach ($conditions as $key => $value) {
if (strpos((string)$key, 'GroupId') !== false) { $group_filter = true; break; }
if (is_array($value)) {
foreach ($value as $sub_key => $sub_value) {
if (strpos((string)$sub_key, 'GroupId') !== false) { $group_filter = true; break 2; }
}
}
}
$find_array = array(
'conditions' => &$conditions,
'contain' => array('Group'),
);
if ($group_filter) {
$find_array['joins'] = array(
array(
'table' => 'Groups_Monitors',
'type' => 'left',
'conditions' => array(
'Groups_Monitors.MonitorId = Monitor.Id',
),
),
);
}
$monitors = $this->Monitor->find('all', $find_array);
$allowed_monitors = [];
$seen_monitor_ids = [];
require_once __DIR__ .'/../../../includes/Monitor.php';
foreach ($monitors as $m) {
// A monitor matching multiple GroupId values appears once per match;
// collapse those here rather than with a GROUP BY (see note above).
$monitor_id = $m['Monitor']['Id'];
if (isset($seen_monitor_ids[$monitor_id])) continue;
$seen_monitor_ids[$monitor_id] = true;
$monitor = new ZM\Monitor($m['Monitor']);
if (!$monitor->canView()) continue;
array_push($allowed_monitors, $m);
}
$this->set(array(
'monitors' => $allowed_monitors,
'_serialize' => array('monitors')
));
}
/**
* view method
*
* @throws NotFoundException
* @param string $id
* @return void
*/
public function view($id = null) {
$this->Monitor->recursive = 0;
if (!$this->Monitor->exists($id)) {
throw new NotFoundException(__('Invalid monitor'));
}
require_once __DIR__ .'/../../../includes/Monitor.php';
$options = array('conditions' => array( array('Monitor.'.$this->Monitor->primaryKey => $id)));
$monitor = $this->Monitor->find('first', $options);
$zm_monitor = new ZM\Monitor($monitor['Monitor']);
if (!$zm_monitor->canView()) {
throw new UnauthorizedException(__('Insufficient Privileges'));
return;
}
if ($zm_monitor->JanusEnabled()) {
$monitor['Monitor']['Janus_Pin'] = $zm_monitor->Janus_Pin();
}
$this->set(array(
'monitor' => $monitor,
'_serialize' => array('monitor')
));
}
/**
* add method
*
* @return void
*/
// The EventStartCommand / EventEndCommand values the request supplies, if any.
private function requestCommandFields() {
require_once __DIR__ .'/../../../includes/Monitor.php';
$fields = array();
foreach (ZM\Monitor::$commandFields as $field) {
$value = $this->requestField('Monitor', $field);
if ($value !== null) $fields[$field] = $value;
}
return $fields;
}
public function add() {
if ( $this->request->is('post') ) {
global $user;
$canAdd = (!$user) || ($user->System() == 'Edit' || $user->Monitors() == 'Create' );
if ( !$canAdd ) {
throw new UnauthorizedException(__('Insufficient privileges'));
return;
}
// A new monitor's command fields start empty, so any value is a change.
require_once __DIR__ .'/../../../includes/Monitor.php';
$new_monitor = new ZM\Monitor();
if (count($new_monitor->commandFieldChangesNotAllowed($this->requestCommandFields()))) {
throw new UnauthorizedException(__('Setting the event commands requires System edit permission'));
}
$this->pinRequestId($this->Monitor, null);
$this->Monitor->create();
if ($this->Monitor->save($this->request->data) ) {
$this->runDaemonControl($this->Monitor->id, 'start');
//return $this->flash(__('The monitor has been saved.'), array('action' => 'index'));
$message = 'Saved';
} else {
$message = 'Error';
// if there is a validation message, use it
if (!$this->Monitor->validates()) {
$message = $this->Monitor->validationErrors;
}
}
$this->set(array(
'message' => $message,
'_serialize' => array('message')
));
}
}
/**
* edit method
*
* @throws NotFoundException
* @param string $id
* @return void
*/
public function edit($id = null) {
$this->Monitor->id = $id;
if ( !$this->Monitor->exists($id) ) {
throw new NotFoundException(__('Invalid monitor'));
}
$monitor = $this->Monitor->find('first', array(
'conditions' => array('Id' => $id)
))['Monitor'];
require_once __DIR__ .'/../../../includes/Monitor.php';
$zm_monitor = new ZM\Monitor($monitor);
if (!$zm_monitor->canEdit()) {
throw new UnauthorizedException(__('Insufficient Privileges'));
return;
}
if (count($zm_monitor->commandFieldChangesNotAllowed($this->requestCommandFields()))) {
throw new UnauthorizedException(__('Changing the event commands requires System edit permission'));
}
$this->pinRequestId($this->Monitor, $id);
$message = '';
if ($this->Monitor->save($this->request->data)) {
$message = 'Saved';
// Restart the daemons the way the console's save does
// (includes/actions/monitor.php): zmc, and the control server of a
// controllable monitor, which otherwise keeps the ControlAddress,
// ControlDevice and Control it read when it started. ZM\Monitor sends
// the commands to the monitor's own server in a multi-server setup.
// Stopped with the old row, started with the saved one.
if ($zm_monitor->Type() != 'WebSite') {
$zm_monitor->zmcControl('stop');
if ($zm_monitor->Controllable()) {
$zm_monitor->sendControlCommand('stop');
}
}
$saved = new ZM\Monitor($this->Monitor->find('first', array(
'conditions' => array('Id' => $id)
))['Monitor']);
if ($saved->Capturing() != 'None' and $saved->Type() != 'WebSite' and !$saved->Deleted()) {
$saved->zmcControl('start');
if ($saved->Controllable()) {
$saved->sendControlCommand('start');
}
}
} else {
$message = 'Error ' . print_r($this->Monitor->invalidFields(), true);
}
$this->set(array(
'message' => $message,
'_serialize' => array('message')
));
} // end function edit
/**
* delete method
*
* @throws NotFoundException
* @param string $id
* @return void
*/
public function delete($id = null) {
$this->Monitor->id = $id;
if ( !$this->Monitor->exists() ) {
throw new NotFoundException(__('Invalid monitor'));
}
global $user;
$canEdit = (!$user) || ($user->System() == 'Edit');
if ( !$canEdit ) {
throw new UnauthorizedException(__('Insufficient privileges'));
return;
}
$this->request->allowMethod('post', 'delete');
// Delete the way the console does: ZM\Monitor::delete() stops zmc and
// zmcontrol and marks the monitor Deleted. A hard delete of the row left
// its events pointing at a monitor that no longer exists.
require_once __DIR__ .'/../../../includes/Monitor.php';
$monitor = new ZM\Monitor($id);
if ( $monitor->delete() ) {
return $this->flash(__('The monitor has been deleted.'), array('action' => 'index'));
} else {
return $this->flash(__('The monitor could not be deleted. Please, try again.'), array('action' => 'index'));
}
}
public function sourceTypes() {
$sourceTypes = $this->Monitor->query('describe Monitors Type;');
preg_match('/^enum\((.*)\)$/', $sourceTypes[0]['COLUMNS']['Type'], $matches);
foreach( explode(',', $matches[1]) as $value ) {
$enum[] = trim( $value, "'" );
}
$this->set(array(
'sourceTypes' => $enum,
'_serialize' => array('sourceTypes')
));
}
// The cause given to alarm on, as zmu records it: letters, digits, spaces and plain
// punctuation only, at most 31 bytes (the size of the shared trigger_cause field), cut on
// a character boundary. The cause is stored as the event's Cause and in its Notes, so it
// is kept to plain text: markup, quotes, backslashes and control characters are dropped.
// Returns '' when nothing usable is left, which keeps the default "Forced Web".
private function alarmCause($cause) {
if (!is_string($cause)) return '';
// Fails (null) on invalid UTF-8
$cause = preg_replace('/[^\p{L}\p{M}\p{N} _.,:;!?()\[\]\/+#@%=-]+/u', '', $cause);
if ($cause === null) return '';
$cause = trim(preg_replace('/ {2,}/', ' ', $cause));
$cut = '';
foreach (preg_split('//u', $cause, -1, PREG_SPLIT_NO_EMPTY) as $char) {
if (strlen($cut) + strlen($char) > 31) break;
$cut .= $char;
}
return rtrim($cut);
}
// arm/disarm alarms
// expected format: http(s):/portal-api-url/monitors/alarm/id:M/command:C.json
// where M=monitorId
// where C=on|off|status|disable
// command:on takes an optional cause (query or POST data), recorded on the event in
// place of "Forced Web" after alarmCause() has filtered it
public function alarm() {
$id = $this->request->params['named']['id'];
if ( !$this->Monitor->exists($id) ) {
throw new NotFoundException(__('Invalid monitor'));
}
$cmd = strtolower($this->request->params['named']['command']);
switch ($cmd) {
case 'on':
$q = '-a';
$verbose = '-v';
$cause = $this->alarmCause($this->request->query('cause') ? $this->request->query('cause') : $this->request->data('cause'));
if ($cause !== '') {
$q .= ' --cause '.escapeshellarg($cause);
}
break;
case 'off':
$q = '-c';
$verbose = '-v';
break;
case 'disable':
$q = '-n';
$verbose = '-v';
break;
case 'status':
$verbose = ''; // zmu has a bug - gives incorrect verbose output in this case
$q = '-s';
break;
default :
throw new BadRequestException(__('Invalid command'));
}
// zmu only checks that the user can see the monitor, so a monitor the user may only
// view could have its alarms forced or disabled. Changing alarm state needs edit.
require_once __DIR__ .'/../../../includes/Monitor.php';
$zm_monitor = new ZM\Monitor($id);
if (!(($cmd == 'status') ? $zm_monitor->canView() : $zm_monitor->canEdit())) {
throw new UnauthorizedException(__('Insufficient Privileges'));
}
$shellcmd = ZM_PATH_BIN.'/zmu'
.($verbose ? " $verbose" : '')
.' -m'.escapeshellarg($id)
." $q";
$status = exec($shellcmd.zmuCredentials(), $output, $rc);
// Logged without the credentials.
ZM\Debug("Command: $shellcmd output: ".implode(PHP_EOL, $output)." rc: $rc");
if ($rc) {
$this->set(array(
'status'=>'false',
'code' => $rc,
'error'=> implode(PHP_EOL, $output),
'_serialize' => array('status','code','error'),
));
} else if ($cmd == 'status') {
// In 1.36.16 the values got shifted up so that we could index into an array of strings.
// So do a hack to restore the previous behavour
$this->set(array(
'status' => intval($status)-1,
'output' => intval($output[0])-1,
'_serialize' => array('status','output'),
));
} else {
$this->set(array(
'status' => $status,
'output' => implode(PHP_EOL, $output),
'_serialize' => array('status','output'),
));
}
}
// Check if a daemon is running for the monitor id
/**
* Load a monitor the caller is allowed to act on.
*
* daemonStatus() and daemonControl() checked nothing at all, so any enabled,
* API-enabled account could read the state of, and start or stop, the daemons
* of a monitor it is not permitted even to see. index() and view() have always
* filtered on ZM\Monitor::canView(), so the two daemon actions were the odd
* ones out.
*
* @param int $id monitor id
* @param bool $for_edit whether the caller intends to change the monitor's state
* @throws NotFoundException|UnauthorizedException
* @return array the Monitor row
*/
private function monitorForDaemonAction($id, $for_edit) {
if (!$this->Monitor->exists($id)) {
throw new NotFoundException(__('Invalid monitor'));
}
require_once __DIR__ .'/../../../includes/Monitor.php';
$monitor = $this->Monitor->find('first', array(
'conditions' => array('Monitor.'.$this->Monitor->primaryKey => $id)
));
$zm_monitor = new ZM\Monitor($monitor['Monitor']);
# Reporting state needs view; starting or stopping capture is a change to
# the monitor, so it needs edit, the same as any other mutation here.
if ($for_edit ? !$zm_monitor->canEdit() : !$zm_monitor->canView()) {
throw new UnauthorizedException(__('Insufficient Privileges'));
}
return $monitor['Monitor'];
}
/**
* zmdc.pl takes the daemon and command as separate arguments, and they are
* interpolated into the command line, so both are restricted to known values
* rather than merely escaped.
*/
private function assertDaemonAndCommand($daemon, $command) {
$daemons = array('zmc', 'zma', 'zmfilter.pl', 'zmaudit.pl', 'zmtrigger.pl',
'zmwatch.pl', 'zmupdate.pl', 'zmtrack.pl', 'zmcontrol.pl', 'zmstats.pl', 'zmeventnotification.pl');
$commands = array('start', 'stop', 'restart', 'reload', 'status', 'check', 'logrot');
if (($daemon !== null) and !in_array($daemon, $daemons, true)) {
throw new BadRequestException(__('Invalid daemon'));
}
if (!in_array($command, $commands, true)) {
throw new BadRequestException(__('Invalid command'));
}
}
public function daemonStatus() {
$id = $this->request->params['named']['id'];
$daemon = $this->request->params['named']['daemon'];
$this->monitorForDaemonAction($id, false);
if (preg_match('/^[a-z]+$/i', $daemon) !== 1) {
throw new BadRequestException(__('Invalid command'));
}
$monitor = $this->Monitor->find('first', array(
'fields' => array('Id', 'Type', 'Device', 'Capturing'),
'conditions' => array('Id' => $id)
));
// Clean up the returned array
$monitor = Set::extract('/Monitor/.', $monitor);
if ($monitor[0]['Capturing'] == 'None') {
$this->set(array(
'status' => false,
'statustext' => 'Monitor capturing is set to None',
'_serialize' => array('status','statustext'),
));
return;
}
// Pass -d for local, otherwise -m
if ( $monitor[0]['Type'] == 'Local' ) {
$args = '-d '. escapeshellarg($monitor[0]['Device']);
} else {
$args = '-m '. escapeshellarg($monitor[0]['Id']);
}
// Build the command, and execute it
$command = escapeshellcmd(ZM_PATH_BIN."/zmdc.pl status $daemon $args");
$status = exec($command);
ZM\Debug("Command: $command output: $status");
// If 'not' is present, the daemon is not running, so return false
// https://github.com/ZoneMinder/ZoneMinder/issues/799#issuecomment-108996075
// Also sending back the status text so we can check if the monitor is in pending
// state which means there may be an error
$statustext = $status;
$status = (strpos($status, 'not')) ? false : true;
$this->set(array(
'status' => $status,
'statustext' => $statustext,
'_serialize' => array('status','statustext'),
));
}
/**
* Stop or start a monitor's daemons, with no permission check of its own.
*
* Kept separate from the daemonControl() route so that delete(), which has
* already required System Edit, is not additionally required to pass the
* per-monitor edit check that the route applies. A System Edit account need not
* hold Monitors Edit, and re-gating here would have stopped it deleting.
*/
private function runDaemonControl($id, $command, $daemon=null) {
// Need to see if it is local or remote
$monitor = $this->Monitor->find('first', array(
'fields' => array('Id', 'Type', 'Capturing', 'Device', 'ServerId'),
'conditions' => array('Id' => $id)
));
return $this->Monitor->daemonControl($monitor['Monitor'], $command, $daemon);
}
public function daemonControl($id, $command, $daemon=null) {
$this->assertDaemonAndCommand($daemon, $command);
$this->monitorForDaemonAction($id, true);
$status_text = $this->runDaemonControl($id, $command, $daemon);
$this->set(array(
'status' => 'ok',
'statustext' => $status_text,
'_serialize' => array('status','statustext'),
));
} // end function daemonControl
} // end class MonitorsController