summaryrefslogtreecommitdiffstats
path: root/modules-available/rebootcontrol
diff options
context:
space:
mode:
Diffstat (limited to 'modules-available/rebootcontrol')
-rw-r--r--modules-available/rebootcontrol/api.inc.php36
-rw-r--r--modules-available/rebootcontrol/hooks/client-update.inc.php2
-rw-r--r--modules-available/rebootcontrol/hooks/config-tgz.inc.php6
-rw-r--r--modules-available/rebootcontrol/hooks/cron.inc.php41
-rw-r--r--modules-available/rebootcontrol/inc/rebootcontrol.inc.php433
-rw-r--r--modules-available/rebootcontrol/inc/rebootutils.inc.php15
-rw-r--r--modules-available/rebootcontrol/inc/scheduler.inc.php367
-rw-r--r--modules-available/rebootcontrol/inc/sshkey.inc.php27
-rw-r--r--modules-available/rebootcontrol/install.inc.php23
-rw-r--r--modules-available/rebootcontrol/lang/de/permissions.json3
-rw-r--r--modules-available/rebootcontrol/lang/de/template-tags.json14
-rw-r--r--modules-available/rebootcontrol/lang/en/permissions.json1
-rw-r--r--modules-available/rebootcontrol/lang/en/template-tags.json6
-rw-r--r--modules-available/rebootcontrol/page.inc.php17
-rw-r--r--modules-available/rebootcontrol/pages/exec.inc.php1
-rw-r--r--modules-available/rebootcontrol/pages/jumphost.inc.php5
-rw-r--r--modules-available/rebootcontrol/pages/subnet.inc.php26
-rw-r--r--modules-available/rebootcontrol/pages/task.inc.php23
-rw-r--r--modules-available/rebootcontrol/permissions/permissions.json3
-rw-r--r--modules-available/rebootcontrol/templates/header.html51
-rw-r--r--modules-available/rebootcontrol/templates/status-checkconnection.html2
-rw-r--r--modules-available/rebootcontrol/templates/status-exec.html15
-rw-r--r--modules-available/rebootcontrol/templates/status-reboot.html5
-rw-r--r--modules-available/rebootcontrol/templates/status-wol.html26
-rw-r--r--modules-available/rebootcontrol/templates/subnet-edit.html2
-rw-r--r--modules-available/rebootcontrol/templates/subnet-list.html2
-rw-r--r--modules-available/rebootcontrol/templates/task-list.html4
27 files changed, 742 insertions, 414 deletions
diff --git a/modules-available/rebootcontrol/api.inc.php b/modules-available/rebootcontrol/api.inc.php
index 05fa5699..b3e9e976 100644
--- a/modules-available/rebootcontrol/api.inc.php
+++ b/modules-available/rebootcontrol/api.inc.php
@@ -10,39 +10,3 @@ if (Request::any('action') === 'rebuild' && isLocalExecution()) {
}
exit(0);
}
-/*
- Needed POST-Parameters:
- 'token' -- for authentication
- 'action' -- which action should be performed (shutdown or reboot)
- 'clients' -- which are to reboot/shutdown (json encoded array!)
- 'timer' -- (optional) when to perform action in minutes (default value is 0)
-*/
-
-$ips = json_decode(Request::post('clients'));
-$minutes = Request::post('timer', 0, 'int');
-
-$clients = array();
-foreach ($ips as $client) {
- $clients[] = array("ip" => $client);
-}
-
-$apikey = Property::get("rebootcontrol_APIPOSTKEY", 'not-set');
-if (!empty($apikey) && Request::post('token') === $apikey) {
- if (Request::isPost()) {
- if (Request::post('action') == 'shutdown') {
- $shutdown = true;
- $task = Taskmanager::submit("RemoteReboot", array("clients" => $clients, "shutdown" => $shutdown, "minutes" => $minutes));
- echo $task["id"];
- } else if (Request::post('action') == 'reboot') {
- $shutdown = false;
- $task = Taskmanager::submit("RemoteReboot", array("clients" => $clients, "shutdown" => $shutdown, "minutes" => $minutes));
- echo $task["id"];
- } else {
- echo "Only action=shutdown and action=reboot available.";
- }
- } else {
- echo "Only POST Method available.";
- }
-} else {
- echo "Not authorized";
-} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/hooks/client-update.inc.php b/modules-available/rebootcontrol/hooks/client-update.inc.php
index 006a5e11..e934988d 100644
--- a/modules-available/rebootcontrol/hooks/client-update.inc.php
+++ b/modules-available/rebootcontrol/hooks/client-update.inc.php
@@ -7,7 +7,7 @@ if ($type === '~poweron') {
&& $subnet[0] === $ip && $subnet[1] >= 8 && $subnet[1] < 32) {
$start = ip2long($ip);
if ($start !== false) {
- $maskHost = (int)(pow(2, 32 - $subnet[1]) - 1);
+ $maskHost = (int)(2 ** (32 - $subnet[1]) - 1);
$maskNet = ~$maskHost & 0xffffffff;
$end = $start | $maskHost;
$start &= $maskNet;
diff --git a/modules-available/rebootcontrol/hooks/config-tgz.inc.php b/modules-available/rebootcontrol/hooks/config-tgz.inc.php
index 90e32e8a..c9ce1255 100644
--- a/modules-available/rebootcontrol/hooks/config-tgz.inc.php
+++ b/modules-available/rebootcontrol/hooks/config-tgz.inc.php
@@ -8,8 +8,10 @@ if (!is_file($tmpfile) || !is_readable($tmpfile) || filemtime($tmpfile) + 86400
}
try {
$a = new PharData($tmpfile);
- $a["/etc/ssh/mgmt/authorized_keys"] = $pubkey;
- $a["/etc/ssh/mgmt/authorized_keys"]->chmod(0600);
+ $a->addFromString("/etc/ssh/mgmt/authorized_keys", $pubkey);
+ $fi = $a->offsetGet("/etc/ssh/mgmt/authorized_keys");
+ /** @var PharFileInfo $fi */
+ $fi->chmod(0600);
$file = $tmpfile;
} catch (Exception $e) {
EventLog::failure('Could not include ssh key for reboot-control in config.tgz', (string)$e);
diff --git a/modules-available/rebootcontrol/hooks/cron.inc.php b/modules-available/rebootcontrol/hooks/cron.inc.php
index 3651c779..289426c7 100644
--- a/modules-available/rebootcontrol/hooks/cron.inc.php
+++ b/modules-available/rebootcontrol/hooks/cron.inc.php
@@ -5,38 +5,18 @@
*/
if (in_array((int)date('G'), [6, 7, 9, 12, 15]) && in_array(date('i'), ['00', '01', '02', '03'])) {
$res = Database::simpleQuery('SELECT hostid, host, port, username, sshkey, script FROM reboot_jumphost');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
RebootControl::wakeViaJumpHost($row, '255.255.255.255', [['macaddr' => '00:11:22:33:44:55']]);
}
}
// CRON for Scheduler
-$now = time();
-$res = Database::simpleQuery("SELECT * FROM reboot_scheduler WHERE nextexecution < :now", ['now' => $now]);
-while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
-
- // Calculate next_execution for the event.
- $location = Database::queryFirst("SELECT openingtime FROM `location` WHERE locationid = :lid", array('lid' => $row['locationid']));
- Scheduler::updateSchedule($row['locationid'], $row['action'], $row['options'], $location['openingtime']);
-
- if ($row['nextexecution'] + 1200 < $now) continue;
-
- $machinedb = Database::simpleQuery("SELECT machineuuid, clientip, macaddr, locationid FROM machine WHERE locationid = :locid", ['locid' => $row['locationid']]);
- $machines = [];
- while ($machine = $machinedb->fetch(PDO::FETCH_ASSOC)) {
- settype($machine['locationid'], 'int');
- $machines[] = $machine;
- }
- // Options not yet used.
- $options = json_decode($row['options']);
- if ($row['action'] === 'sd') RebootControl::execute($machines, RebootControl::SHUTDOWN, 0);
- else if ($row['action'] === 'wol') RebootControl::wakeMachines($machines);
-}
+Scheduler::cron();
/*
* Client reachability test -- can be disabled
*/
-if (mt_rand(1, 2) !== 1 || Property::get(RebootControl::KEY_AUTOSCAN_DISABLED))
+if (Property::get(RebootControl::KEY_AUTOSCAN_DISABLED))
return;
class Stuff
@@ -44,7 +24,7 @@ class Stuff
public static $subnets;
}
-function destSawPw($destTask, $destMachine, $passwd)
+function destSawPw(array $destTask, array $destMachine, string $passwd): bool
{
return strpos($destTask['data']['result'][$destMachine['machineuuid']]['stdout'], "passwd=$passwd") !== false;
}
@@ -160,7 +140,7 @@ function resultToTime($result)
$next = 86400 * 7; // a week
} else {
// Test finished, reachable
- $next = 86400 * 30; // a month
+ $next = 86400 * 14; // two weeks
}
return time() + round($next * mt_rand(90, 133) / 100);
}
@@ -170,12 +150,12 @@ function resultToTime($result)
*/
// First, cleanup: delete orphaned subnets that don't exist anymore, or don't have any clients using our server
-$cutoff = strtotime('-360 days');
+$cutoff = strtotime('-720 days');
Database::exec('DELETE FROM reboot_subnet WHERE fixed = 0 AND lastseen < :cutoff', ['cutoff' => $cutoff]);
// Get machines running, group by subnet
$cutoff = time() - 301; // Really only the ones that didn't miss the most recent update
-$res = Database::simpleQuery("SELECT s.subnetid, s.end AS bcast, m.machineuuid, m.clientip, m.macaddr
+$res = Database::simpleQuery("SELECT s.subnetid, s.end AS bcast, m.machineuuid, m.clientip, m.macaddr, m.locationid
FROM reboot_subnet s
INNER JOIN machine m ON (
(m.state = 'IDLE' OR m.state = 'OCCUPIED')
@@ -191,12 +171,13 @@ if ($res->rowCount() === 0)
return;
Stuff::$subnets = [];
-while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+foreach ($res as $row) {
if (!isset(Stuff::$subnets[$row['subnetid']])) {
Stuff::$subnets[$row['subnetid']] = [];
}
Stuff::$subnets[$row['subnetid']][] = $row;
}
+unset($res);
$task = Taskmanager::submit('DummyTask', []);
$task = Taskmanager::waitComplete($task, 4000);
@@ -214,7 +195,7 @@ $res = Database::simpleQuery("SELECT subnetid FROM reboot_subnet
WHERE subnetid IN (:active) AND nextdirectcheck < UNIX_TIMESTAMP() AND fixed = 0
ORDER BY nextdirectcheck ASC LIMIT 10", ['active' => array_keys(Stuff::$subnets)]);
cron_log('Direct checks: ' . $res->rowCount() . ' (' . implode(', ', array_keys(Stuff::$subnets)) . ')');
-while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+foreach ($res as $row) {
$dst = (int)$row['subnetid'];
cron_log('Direct check for subnetid ' . $dst);
$result = testServerToClient($dst);
@@ -259,7 +240,7 @@ if (count($combos) > 0) {
ORDER BY sxs.nextcheck ASC
LIMIT 10", ['combos' => $combos]);
cron_log('C2C checks: ' . $res->rowCount());
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$src = (int)$row['srcid'];
$dst = (int)$row['dstid'];
$result = testClientToClient($src, $dst);
diff --git a/modules-available/rebootcontrol/inc/rebootcontrol.inc.php b/modules-available/rebootcontrol/inc/rebootcontrol.inc.php
index a8018004..107c2a50 100644
--- a/modules-available/rebootcontrol/inc/rebootcontrol.inc.php
+++ b/modules-available/rebootcontrol/inc/rebootcontrol.inc.php
@@ -11,6 +11,8 @@ class RebootControl
const KEY_UDP_PORT = 'rebootcontrol.port';
+ const KEY_BROADCAST_ADDRESS = 'rebootcontrol.broadcast-addr';
+
const REBOOT = 'REBOOT';
const KEXEC_REBOOT = 'KEXEC_REBOOT';
const SHUTDOWN = 'SHUTDOWN';
@@ -23,7 +25,7 @@ class RebootControl
* @param bool $kexec whether to trigger kexec-reboot instead of full BIOS cycle
* @return false|array task struct for the reboot job
*/
- public static function reboot($uuids, $kexec = false)
+ public static function reboot(array $uuids, bool $kexec = false)
{
$list = RebootUtils::getMachinesByUuid($uuids);
if (empty($list))
@@ -35,10 +37,9 @@ class RebootControl
* @param array $list list of clients containing each keys 'machineuuid', 'clientip' and 'locationid'
* @param string $mode reboot mode: RebootControl::REBOOT ::KEXEC_REBOOT or ::SHUTDOWN
* @param int $minutes delay in minutes for action
- * @param int $locationId meta data only: locationId of clients
* @return array|false the task, or false if it could not be started
*/
- public static function execute($list, $mode, $minutes)
+ public static function execute(array $list, string $mode, int $minutes)
{
$task = Taskmanager::submit("RemoteReboot", array(
"clients" => $list,
@@ -48,12 +49,20 @@ class RebootControl
"port" => 9922, // Hard-coded, must match mgmt-sshd module
));
if (!Taskmanager::isFailed($task)) {
- self::addTask($task['id'], self::TASK_REBOOTCTL, $list, $task['id'], ['action' => $mode]);
+ self::addTask($task['id'], self::TASK_REBOOTCTL, $list, ['action' => $mode]);
+ foreach ($list as $client) {
+ $client['mode'] = $mode;
+ $client['minutes'] = $minutes;
+ EventLog::applyFilterRules('#action-power', $client);
+ }
}
return $task;
}
- private static function addTask($id, $type, $clients, $taskIds, $other = false)
+ /**
+ * Add wake task metadata to database, so we can display job details on the summary page.
+ */
+ private static function addTask(string $taskId, string $type, array $clients, array $other = null): void
{
$lids = ArrayUtil::flattenByKey($clients, 'locationid');
$lids = array_unique($lids);
@@ -65,15 +74,13 @@ class RebootControl
}
$newClients[] = $d;
}
- if (!is_array($taskIds)) {
- $taskIds = [$taskIds];
- }
$data = [
- 'id' => $id,
+ 'id' => $taskId,
'type' => $type,
'locations' => $lids,
'clients' => $newClients,
- 'tasks' => $taskIds,
+ 'tasks' => [$taskId], // This did hold multiple tasks in the past; keep it in case we need this again
+ 'timestamp' => time(),
];
if (is_array($other)) {
$data += $other;
@@ -83,19 +90,20 @@ class RebootControl
/**
* @param int[]|null $locations filter by these locations
+ * @param ?string $id only with this TaskID
* @return array|false list of active tasks for reboots/shutdowns.
*/
- public static function getActiveTasks($locations = null, $id = null)
+ public static function getActiveTasks(array $locations = null, string $id = null)
{
if (is_array($locations) && in_array(0, $locations)) {
$locations = null;
}
$list = Property::getList(RebootControl::KEY_TASKLIST);
$return = [];
- foreach ($list as $entry) {
+ foreach ($list as $subkey => $entry) {
$p = json_decode($entry, true);
if (!is_array($p) || !isset($p['id'])) {
- Property::removeFromList(RebootControl::KEY_TASKLIST, $entry);
+ Property::removeFromListByKey(RebootControl::KEY_TASKLIST, $subkey);
continue;
}
if (is_array($locations) && is_array($p['locations']) && array_diff($p['locations'], $locations) !== [])
@@ -118,7 +126,7 @@ class RebootControl
}
}
if (!$valid) {
- Property::removeFromList(RebootControl::KEY_TASKLIST, $entry);
+ Property::removeFromListByKey(RebootControl::KEY_TASKLIST, $subkey);
continue;
}
$return[] = $p;
@@ -138,18 +146,18 @@ class RebootControl
* @param string $command Command or script to execute on client
* @param int $timeout in seconds
* @param string|false $privkey SSH private key to use to connect
- * @return array|false
+ * @return array|false task struct, false on error
*/
- public static function runScript($clients, $command, $timeout = 5, $privkey = false)
+ public static function runScript(array $clients, string $command, int $timeout = 5, $privkey = false)
{
$task = self::runScriptInternal($clients, $command, $timeout, $privkey);
if (!Taskmanager::isFailed($task)) {
- self::addTask($task['id'], self::TASK_EXEC, $clients, $task['id']);
+ self::addTask($task['id'], self::TASK_EXEC, $clients);
}
return $task;
}
- private static function runScriptInternal(&$clients, $command, $timeout = 5, $privkey = false)
+ private static function runScriptInternal(array &$clients, string $command, int $timeout = 5, $privkey = false)
{
$valid = [];
$invalid = [];
@@ -167,7 +175,7 @@ class RebootControl
if (!empty($invalid)) {
$res = Database::simpleQuery('SELECT machineuuid, clientip, locationid FROM machine WHERE machineuuid IN (:uuids)',
['uuids' => array_keys($invalid)]);
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (isset($invalid[$row['machineuuid']])) {
$valid[] = $row + $invalid[$row['machineuuid']];
} else {
@@ -207,13 +215,10 @@ class RebootControl
}
/**
- * @param array $sourceMachines list of source machines. array of [clientip, machineuuid] entries
- * @param string $bcast directed broadcast address to send to
- * @param string|string[] $macaddr destination mac address(es)
- * @param string $passwd optional WOL password, mac address or ipv4 notation
- * @return array|false task struct, false on error
+ * Wake clients given by MAC address(es) via jawol util.
+ * Multiple MAC addresses can be passed as a space separated list.
*/
- public static function wakeViaClient($sourceMachines, $macaddr, $bcast = false, $passwd = false)
+ private static function buildClientWakeCommand(string $macs, string $bcast = null, string $passwd = null): string
{
$command = 'jawol';
if (!empty($bcast)) {
@@ -224,22 +229,32 @@ class RebootControl
if (!empty($passwd)) {
$command .= " -p '$passwd'";
}
- if (is_array($macaddr)) {
- $macaddr = implode("' '", $macaddr);
- }
- $command .= " '$macaddr'";
- // Yes there is one zero missing from the usleep -- that's the whole point: we prefer 100ms sleeps
+ $command .= " $macs";
+ return $command;
+ }
+
+ /**
+ * @param array $sourceMachines list of source machines. array of [clientip, machineuuid] entries
+ * @param string $macaddr destination mac address(es)
+ * @param string $bcast directed broadcast address to send to
+ * @param string $passwd optional WOL password, mac address or ipv4 notation
+ * @return array|false task struct, false on error
+ */
+ public static function wakeViaClient(array $sourceMachines, string $macaddr, string $bcast = null, string $passwd = null)
+ {
+ $command = self::buildClientWakeCommand($macaddr, $bcast, $passwd);
+ // Yes there is one zero "missing" from the usleep -- that's the whole point: we prefer 100ms sleeps
return self::runScriptInternal($sourceMachines,
"for i in 1 1 0; do $command; usleep \${i}00000 2> /dev/null || sleep \$i; done");
}
/**
* @param string|string[] $macaddr destination mac address(es)
- * @param string $bcast directed broadcast address to send to
- * @param string $passwd optional WOL password; mac address or ipv4 notation
+ * @param ?string $bcast directed broadcast address to send to
+ * @param ?string $passwd optional WOL password; mac address or ipv4 notation
* @return array|false task struct, false on error
*/
- public static function wakeDirectly($macaddr, $bcast = false, $passwd = false)
+ public static function wakeDirectly($macaddr, string $bcast = null, string $passwd = null)
{
if (!is_array($macaddr)) {
$macaddr = [$macaddr];
@@ -248,15 +263,26 @@ class RebootControl
if ($port < 1 || $port > 65535) {
$port = 9;
}
- return Taskmanager::submit('WakeOnLan', [
- 'ip' => $bcast,
- 'password' => $passwd === false ? '' : $passwd,
- 'macs' => $macaddr,
- 'port' => $port,
- ]);
+ $arg = [];
+ foreach ($macaddr as $mac) {
+ $arg[] = [
+ 'ip' => $bcast,
+ 'mac' => $mac,
+ 'methods' => ['DIRECT'],
+ 'password' => $passwd,
+ ];
+ }
+ return Taskmanager::submit('WakeOnLan', ['clients' => $arg]);
}
- public static function wakeViaJumpHost($jumphost, $bcast, $clients)
+ /**
+ * Explicitly wake given clients via jumphost
+ * @param array $jumphost the according row from the database, representing the desired jumphost
+ * @param string $bcast (directed) broadcast address for WOL packet, %IP% in command template
+ * @param array $clients list of clients, must contain at least key 'macaddr' for every client
+ * @return array|false task struct on successful submission to TM, false on error
+ */
+ public static function wakeViaJumpHost(array $jumphost, string $bcast, array $clients)
{
$hostid = $jumphost['hostid'];
$macs = ArrayUtil::flattenByKey($clients, 'macaddr');
@@ -280,59 +306,42 @@ class RebootControl
}
/**
- * @param array $list list of clients containing each keys 'macaddr' and 'clientip'
- * @return string id of this job
+ * @param array $clientList list of clients containing each keys 'macaddr' and 'clientip', optionally 'locationid'
+ * @param ?array $failed list of failed clients from $clientList
+ * @return ?string taskid of this job
*/
- public static function wakeMachines($list, &$failed = [])
+ public static function wakeMachines(array $clientList, array &$failed = null): ?string
{
- /* TODO: Refactor mom's spaghetti
- * Now that I figured out what I want, do something like this:
- * 1) Group clients by subnet
- * 2) Only after step 1, start to collect possible ways to wake up clients for each subnet that's not empty
- * 3) Have some priority list for the methods, extend Taskmanager to have "negative dependency"
- * i.e. submit task B with task A as parent task, but only launch task B if task A failed.
- * If task A succeeded, mark task B as FINISHED immediately without actually running it.
- * (or introduce new statusCode for this?)
- */
$errors = '';
- $tasks = [];
- $bad = $unknown = [];
+ $sent = $unknown = $unreachable = $failed = [];
+ // For event filtering by rule
// Need all subnets...
+ /* subnetid => [
+ * subnetid => 1234,
+ * start => 1234, (ip2long)
+ * end => 5678, (ip2long)
+ * jumphosts => [id1, id2, ...],
+ */
$subnets = [];
$res = Database::simpleQuery('SELECT subnetid, start, end, isdirect FROM reboot_subnet');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row += [
- 'jumphosts' => [],
- 'direct' => [],
- 'indirect' => [],
+ 'djumphosts' => [],
+ 'ijumphosts' => [],
];
$subnets[$row['subnetid']] = $row;
}
// Get all jump hosts
- $jumphosts = [];
- $res = Database::simpleQuery('SELECT jh.hostid, host, port, username, sshkey, script, jh.reachable,
- Group_Concat(jxs.subnetid) AS subnets1, Group_Concat(sxs.dstid) AS subnets2
- FROM reboot_jumphost jh
- LEFT JOIN reboot_jumphost_x_subnet jxs ON (jh.hostid = jxs.hostid)
- LEFT JOIN reboot_subnet s ON (INET_ATON(jh.host) BETWEEN s.start AND s.end)
- LEFT JOIN reboot_subnet_x_subnet sxs ON (sxs.srcid = s.subnetid AND sxs.reachable <> 0)
- GROUP BY jh.hostid');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- if ($row['subnets1'] === null && $row['subnets2'] === null)
- continue;
- $nets = explode(',', $row['subnets1'] . ',' . $row['subnets2']);
- foreach ($nets as $net) {
- if (empty($net) || !isset($subnets[$net]))
- continue;
- $subnets[$net]['jumphosts'][$row['hostid']] = $row['hostid'];
- }
- $row['jobs'] = [];
- $jumphosts[$row['hostid']] = $row;
+ self::addJumphostsToSubnets($subnets);
+ // Determine method for all clients
+ $taskClients = []; // array of arrays with keys [ip, mac, methods]
+ $taskSsh = []; // SSH configs for task, array of arrays with keys [username, sshkey, ip, port, command]
+ $overrideBroadcast = Property::get(self::KEY_BROADCAST_ADDRESS);
+ if (empty($overrideBroadcast)) {
+ $overrideBroadcast = false;
}
- // Group by subnet
- foreach ($list as $client) {
- $ip = sprintf('%u', ip2long($client['clientip']));
- //$client['numip'] = $ip;
+ foreach ($clientList as $dbClient) {
+ $ip = sprintf('%u', ip2long($dbClient['clientip'])); // 32Bit snprintf
unset($subnet);
$subnet = false;
foreach ($subnets as &$sn) {
@@ -342,125 +351,80 @@ class RebootControl
}
}
if ($subnet === false) {
- $unknown[] = $client;
+ $unknown[] = $dbClient;
continue;
}
- $ok = false;
- if (!$ok && $subnet['isdirect']) {
- // Directly reachable
- $subnet['direct'][] = $client;
- $ok = true;
- }
- if (!$ok && !empty($subnet['jumphosts'])) {
- foreach ($subnet['jumphosts'] as $hostid) {
- if ($jumphosts[$hostid]['reachable'] != 0) {
- $jumphosts[$hostid]['jobs'][$subnet['end']][] = $client;
- $ok = true;
- break;
- }
- }
+ $taskClient = [
+ 'ip' => long2ip($subnet['end']),
+ 'mac' => $dbClient['macaddr'],
+ 'methods' => [],
+ ];
+ // If we have an override broadcast address, unconditionally add this as the
+ // first method
+ if ($overrideBroadcast !== false) {
+ $taskClient['ip'] = $overrideBroadcast;
+ $taskClient['methods'][] = 'DIRECT';
}
- if (!$ok) {
- // find clients in same subnet, or reachable ones
- self::findMachinesForSubnet($subnet);
- if (empty($subnet['dclients']) && empty($subnet['iclients'])) {
- // Nothing found -- cannot wake this host
- $bad[] = $client;
- } else {
- // Found suitable indirect host
- $subnet['indirect'][] = $client;
- $ok = true;
- }
+ self::findMachinesForSubnet($subnet);
+ // Highest priority - clients in same subnet, no directed broadcast
+ // required, should be most reliable
+ self::addSshMethodUsingClient($subnet['dclients'], $taskClient['methods'], $taskSsh);
+ // Jumphost - usually in same subnet
+ self::addSshMethodUsingJumphost($subnet['djumphosts'], true, $taskClient['methods'], $taskSsh);
+ // Jumphosts in other subnets, determined to be able to reach destination subnet
+ self::addSshMethodUsingJumphost($subnet['ijumphosts'], true, $taskClient['methods'], $taskSsh);
+ // If directly reachable from server, prefer this now over the questionable approaches below,
+ // but only if we didn't already add this above because of override
+ if ($overrideBroadcast === false && $subnet['isdirect']) {
+ $taskClient['methods'][] = 'DIRECT';
}
- if ($ok && isset($client['machineuuid'])) {
+ // Use clients in other subnets, known to be able to reach the destination net
+ self::addSshMethodUsingClient($subnet['iclients'], $taskClient['methods'], $taskSsh);
+ // Add warning if nothing works
+ if (empty($taskClient['methods'])) {
+ $unreachable[] = $dbClient;
+ } else {
// TODO: Remember WOL was attempted
}
- }
- unset($subnet);
- // Batch process
- // First, via jump host
- foreach ($jumphosts as $jh) {
- foreach ($jh['jobs'] as $bcast => $clients) {
- $errors .= 'Via jumphost ' . $jh['host'] . ': ' . implode(', ', ArrayUtil::flattenByKey($clients, 'clientip')) . "\n";
- $task = self::wakeViaJumpHost($jh, $bcast, $clients);
- if (Taskmanager::isFailed($task)) {
- // TODO: Figure out $subnet from $bcast and queue as indirect
- // (rather, overhaul this whole spaghetti code)
- $errors .= ".... FAILED TO LAUNCH TASK ON JUMPHOST!\n";
- }
- }
- }
- // Server or client
- foreach ($subnets as $subnet) {
- if (!empty($subnet['direct'])) {
- // Can wake directly
- if (!self::wakeGroup('From server', $tasks, $errors, null, $subnet['direct'], $subnet['end'])) {
- if (!empty($subnet['dclients']) || !empty($subnet['iclients'])) {
- $errors .= "Re-queueing clients for indirect wakeup\n";
- $subnet['indirect'] = array_merge($subnet['indirect'], $subnet['direct']);
- }
- }
- }
- if (!empty($subnet['indirect'])) {
- // Can wake indirectly
- $ok = false;
- if (!empty($subnet['dclients'])) {
- $ok = true;
- if (!self::wakeGroup('in same subnet', $tasks, $errors, $subnet['dclients'], $subnet['indirect'])) {
- if (!empty($subnet['iclients'])) {
- $errors .= "Re-re-queueing clients for indirect wakeup\n";
- $ok = false;
- }
- }
- }
- if (!$ok && !empty($subnet['iclients'])) {
- $ok = self::wakeGroup('across subnets', $tasks, $errors, $subnet['iclients'], $subnet['indirect'], $subnet['end']);
- }
- if (!$ok) {
- $errors .= "I'm all out of ideas.\n";
- }
+ // "Questionable approaches":
+ // Last fallback is jumphosts that were not reachable when last checked, this is really a last resort
+ self::addSshMethodUsingJumphost($subnet['djumphosts'], false, $taskClient['methods'], $taskSsh);
+ self::addSshMethodUsingJumphost($subnet['ijumphosts'], false, $taskClient['methods'], $taskSsh);
+
+ if (!empty($taskClient['methods'])) {
+ $taskClients[] = $taskClient;
+ $sent[] = $dbClient;
}
}
- if (!empty($bad)) {
- $ips = ArrayUtil::flattenByKey($bad, 'clientip');
- $errors .= "**** WARNING ****\nNo way to send WOL packets to the following machines:\n" . implode("\n", $ips) . "\n";
- }
+ unset($subnet);
+
if (!empty($unknown)) {
$ips = ArrayUtil::flattenByKey($unknown, 'clientip');
$errors .= "**** WARNING ****\nThe following clients do not belong to a known subnet (bug?)\n" . implode("\n", $ips) . "\n";
+ foreach ($unknown as $val) {
+ $failed[$val['clientip']] = $val;
+ }
}
- $failed = array_merge($bad, $unknown);
- $id = Util::randomUuid();
- self::addTask($id, self::TASK_WOL, $list, $tasks, ['log' => $errors]);
- return $id;
- }
-
- private static function wakeGroup($type, &$tasks, &$errors, $via, $clients, $bcast = false)
- {
- $macs = ArrayUtil::flattenByKey($clients, 'macaddr');
- $ips = ArrayUtil::flattenByKey($clients, 'clientip');
- if ($via !== null) {
- $srcips = ArrayUtil::flattenByKey($via, 'clientip');
- $errors .= 'Via ' . implode(', ', $srcips) . ' ';
- }
- $errors .= $type . ': ' . implode(', ', $ips);
- if ($bcast !== false) {
- $errors .= ' (UDP to ' . long2ip($bcast) . ')';
- }
- $errors .= "\n";
- if ($via === null) {
- $task = self::wakeDirectly($macs, $bcast);
- } else {
- $task = self::wakeViaClient($via, $macs, $bcast);
- }
- if ($task !== false && isset($task['id'])) {
- $tasks[] = $task['id'];
+ if (!empty($unreachable)) {
+ $ips = ArrayUtil::flattenByKey($unreachable, 'clientip');
+ $errors .= "**** WARNING ****\nThe following clients are not reachable with any method\n" . implode("\n", $ips) . "\n";
+ foreach ($unreachable as $val) {
+ $failed[$val['clientip']] = $val;
+ }
}
- if (Taskmanager::isFailed($task)) {
- $errors .= ".... FAILED TO START ACCORDING TASK!\n";
- return false;
+ $task = Taskmanager::submit('WakeOnLan', [
+ 'clients' => $taskClients,
+ 'ssh' => $taskSsh,
+ ]);
+ if (isset($task['id'])) {
+ $id = $task['id'];
+ self::addTask($id, self::TASK_WOL, $clientList, ['log' => $errors]);
+ foreach ($sent as $dbClient) {
+ EventLog::applyFilterRules('#action-wol', $dbClient);
+ }
+ return $id;
}
- return true;
+ return null;
}
private static function findMachinesForSubnet(&$subnet)
@@ -469,15 +433,12 @@ class RebootControl
return;
$cutoff = time() - 320;
// Get clients from same subnet first
- $subnet['dclients'] = Database::queryAll("SELECT machineuuid, clientip FROM machine
+ $subnet['dclients'] = Database::queryColumnArray("SELECT clientip FROM machine
WHERE state IN ('IDLE', 'OCCUPIED') AND INET_ATON(clientip) BETWEEN :start AND :end AND lastseen > :cutoff
LIMIT 3",
['start' => $subnet['start'], 'end' => $subnet['end'], 'cutoff' => $cutoff]);
- $subnet['iclients'] = [];
- if (!empty($subnet['dclients']))
- return;
// If none, get clients from other subnets known to be able to reach this one
- $subnet['iclients'] = Database::queryAll("SELECT m.machineuuid, m.clientip FROM reboot_subnet_x_subnet sxs
+ $subnet['iclients'] = Database::queryColumnArray("SELECT m.clientip FROM reboot_subnet_x_subnet sxs
INNER JOIN reboot_subnet s ON (s.subnetid = sxs.srcid AND sxs.dstid = :subnetid AND sxs.reachable = 1)
INNER JOIN machine m ON (INET_ATON(m.clientip) BETWEEN s.start AND s.end AND state IN ('IDLE', 'OCCUPIED') AND m.lastseen > :cutoff)
LIMIT 20", ['subnetid' => $subnet['subnetid'], 'cutoff' => $cutoff]);
@@ -487,15 +448,99 @@ class RebootControl
public static function prepareExec()
{
- User::assertPermission('action.exec');
+ User::assertPermission('.rebootcontrol.action.exec');
$uuids = array_values(Request::post('uuid', Request::REQUIRED, 'array'));
- $machines = RebootUtils::getFilteredMachineList($uuids, 'action.exec');
+ $machines = RebootUtils::getFilteredMachineList($uuids, '.rebootcontrol.action.exec');
if ($machines === false)
return;
$id = mt_rand();
Session::set('exec-' . $id, $machines, 60);
- Session::save();
Util::redirect('?do=rebootcontrol&show=exec&what=prepare&id=' . $id);
}
+ /**
+ * Append a "wake via client" WOL method for the given client. Append at least one, but stop
+ * if there are at least two methods already.
+ *
+ * @param array $sshClients [in] list of online clients to use for waking
+ * @param array $c [out] The client's methods array
+ * @param array $taskSsh [out] add according task struct to this array, if not already exists
+ * @return void
+ */
+ private static function addSshMethodUsingClient(array $sshClients, array &$methods, array &$taskSsh)
+ {
+ foreach ($sshClients as $host) {
+ if (!isset($taskSsh[$host])) {
+ $taskSsh[$host] = [
+ 'username' => 'root',
+ 'sshkey' => SSHKey::getPrivateKey(),
+ 'ip' => $host,
+ 'port' => 9922,
+ 'command' => self::buildClientWakeCommand('%MACS%', '%IP%'),
+ ];
+ }
+ $methods[] = $host;
+ if (count($methods) >= 2)
+ break;
+ }
+ }
+
+ private static function addSshMethodUsingJumphost(array $jumpHosts, bool $reachable, array &$methods, array &$taskSsh)
+ {
+ // If it's the fallback to apparently unreachable jump-hosts, ignore if we already have two methods
+ if (!$reachable && count($methods) >= 2)
+ return;
+ // host, port, username, sshkey, script, jh.reachable
+ foreach ($jumpHosts as $jh) {
+ if ($reachable !== (bool)$jh['reachable'])
+ continue;
+ $key = substr(md5($jh['host'] . ':' . $jh['port'] . ':' . $jh['username']), 0, 10);
+ if (!isset($taskSsh[$key])) {
+ $taskSsh[$key] = [
+ 'username' => $jh['username'],
+ 'sshkey' => $jh['sshkey'],
+ 'ip' => $jh['host'],
+ 'port' => $jh['port'],
+ 'command' => $jh['script'],
+ ];
+ }
+ $methods[] = $key;
+ if (count($methods) >= 2)
+ break;
+ }
+ }
+
+ /**
+ * Load all jumphosts from DB, sort into passed $subnets. Also split up
+ * by directly assigned subnets, and indirectly dtermined, reachable subnets.
+ * @param array $subnets [in]
+ * @return void
+ */
+ private static function addJumphostsToSubnets(array &$subnets)
+ {
+ $res = Database::simpleQuery('SELECT host, port, username, sshkey, script, jh.reachable,
+ Group_Concat(jxs.subnetid) AS dsubnets, Group_Concat(sxs.dstid) AS isubnets
+ FROM reboot_jumphost jh
+ LEFT JOIN reboot_jumphost_x_subnet jxs ON (jh.hostid = jxs.hostid)
+ LEFT JOIN reboot_subnet s ON (INET_ATON(jh.host) BETWEEN s.start AND s.end)
+ LEFT JOIN reboot_subnet_x_subnet sxs ON (sxs.srcid = s.subnetid AND sxs.reachable <> 0)
+ GROUP BY jh.hostid');
+ foreach ($res as $row) {
+ $dnets = empty($row['dsubnets']) ? [] : explode(',', $row['dsubnets']);
+ $inets = empty($row['isubnets']) ? [] : explode(',', $row['isubnets']);
+ $inets = array_diff($inets, $dnets); // There might be duplicates if both joins match
+ foreach ($dnets as $net) {
+ if (empty($net) || !isset($subnets[$net]))
+ continue;
+ $subnets[$net]['djumphosts'][] =& $row;
+ }
+ foreach ($inets as $net) {
+ if (empty($net) || !isset($subnets[$net]))
+ continue;
+ $subnets[$net]['ijumphosts'][] =& $row;
+ }
+ unset($row);
+ }
+ }
+
}
diff --git a/modules-available/rebootcontrol/inc/rebootutils.inc.php b/modules-available/rebootcontrol/inc/rebootutils.inc.php
index 99235e8a..e05d90dc 100644
--- a/modules-available/rebootcontrol/inc/rebootutils.inc.php
+++ b/modules-available/rebootcontrol/inc/rebootutils.inc.php
@@ -8,19 +8,18 @@ class RebootUtils
* @param string[] $list list of system UUIDs
* @return array list of machines with machineuuid, hostname, clientip, state and locationid
*/
- public static function getMachinesByUuid($list, $assoc = false, $columns = ['machineuuid', 'hostname', 'clientip', 'state', 'locationid'])
+ public static function getMachinesByUuid(array $list, bool $assoc = false,
+ array $columns = ['machineuuid', 'hostname', 'clientip', 'state', 'locationid']): array
{
if (empty($list))
return array();
- if (is_array($columns)) {
- $columns = implode(',', $columns);
- }
+ $columns = implode(',', $columns);
$res = Database::simpleQuery("SELECT $columns FROM machine
WHERE machineuuid IN (:list)", compact('list'));
if (!$assoc)
- return $res->fetchAll(PDO::FETCH_ASSOC);
+ return $res->fetchAll();
$ret = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$ret[$row['machineuuid']] = $row;
}
return $ret;
@@ -31,7 +30,7 @@ class RebootUtils
* Requires the array elements to have key "state" from machine table.
* @param array $clients list of clients
*/
- public static function sortRunningFirst(&$clients)
+ public static function sortRunningFirst(array &$clients): void
{
usort($clients, function($a, $b) {
$a = ($a['state'] === 'IDLE' || $a['state'] === 'OCCUPIED');
@@ -49,7 +48,7 @@ class RebootUtils
* @param string $permission name of location-aware permission to check
* @return array|false List of clients the user has access to.
*/
- public static function getFilteredMachineList($requestedClients, $permission)
+ public static function getFilteredMachineList(array $requestedClients, string $permission)
{
$actualClients = RebootUtils::getMachinesByUuid($requestedClients);
if (count($actualClients) !== count($requestedClients)) {
diff --git a/modules-available/rebootcontrol/inc/scheduler.inc.php b/modules-available/rebootcontrol/inc/scheduler.inc.php
index 27a22646..19a01beb 100644
--- a/modules-available/rebootcontrol/inc/scheduler.inc.php
+++ b/modules-available/rebootcontrol/inc/scheduler.inc.php
@@ -3,83 +3,328 @@
class Scheduler
{
- public static function updateSchedule($locationid, $action, $options, $openingTimes) {
- if ($openingTimes == '') {
- self::deleteSchedule($locationid, $action);
- return false;
- }
- $nextexec = self::calcNextexec($action, $options, $openingTimes);
- $json_options = json_encode($options);
- self::upsert($locationid, $action, $nextexec, $json_options);
- return true;
- }
+ const ACTION_SHUTDOWN = 'SHUTDOWN';
+ const ACTION_REBOOT = 'REBOOT';
+ const ACTION_WOL = 'WOL';
+ const RA_NEVER = 'NEVER';
+ const RA_SELECTIVE = 'SELECTIVE';
+ const RA_ALWAYS = 'ALWAYS';
+ const SCHEDULE_OPTIONS_DEFAULT = ['wol' => false, 'sd' => false, 'wol-offset' => 0, 'sd-offset' => 0, 'ra-mode' => self::RA_ALWAYS];
- public static function deleteSchedule($locationid, $action) {
+ /**
+ * @param int $locationid ID of location to delete WOL/shutdown settings for
+ */
+ public static function deleteSchedule(int $locationid)
+ {
Database::exec("DELETE FROM `reboot_scheduler`
- WHERE locationid = :lid AND action = :act", array(
- 'lid' => $locationid,
- 'act' => $action
- ));
+ WHERE locationid = :lid", ['lid' => $locationid]);
+ }
+
+ /**
+ * Calculate next time the given time description is reached.
+ * @param int $now unix timestamp representing now
+ * @param string $day Name of weekday
+ * @param string $time Time, fi. 13:45
+ * @return false|int unix timestamp in the future when we reach the given time
+ */
+ private static function calculateTimestamp(int $now, string $day, string $time)
+ {
+ $ts = strtotime("$day $time");
+ if ($ts < $now) {
+ $ts = strtotime("next $day $time");
+ }
+ if ($ts < $now) {
+ EventLog::warning("Invalid params to calculateTimestamp(): 'next $day $time'");
+ $ts = $now + 864000;
+ }
+ return $ts;
}
- private static function calcNextexec($action, $options, $openingTimes) {
- $openingTimes = json_decode($openingTimes, true);
- $now = time(); $times = [];
+ /**
+ * Take WOL/SD options and opening times schedule, return next event.
+ * @return array|false array with keys 'time' and 'action' false if no next event
+ */
+ private static function calculateNext(array $options, array $openingTimes)
+ {
+ // If ra-mode is selective, still execute even if wol and shutdown is disabled,
+ // because we still want to shutdown any sessions in the wrong runmode then
+ $selectiveRa = ($options['ra-mode'] === self::RA_SELECTIVE);
+ if ((!$options['wol'] && !$options['sd'] && !$selectiveRa) || empty($openingTimes))
+ return false;
+ $now = time();
+ $events = [];
+ $findWol = $options['wol'] || $options['ra-mode'] === self::RA_SELECTIVE;
+ $findSd = $options['sd'] || $options['ra-mode'] === self::RA_SELECTIVE;
foreach ($openingTimes as $row) {
- // Fetch hour and minutes of opening / closing time.
- $hourmin = explode(':', ($action == 'wol' ? $row['openingtime'] : $row['closingtime']));
- // Calculate time based on offset.
- $min = ($action == 'wol' ? $hourmin[0] * 60 + $hourmin[1] - $options['wol-offset'] : $hourmin[0] * 60 + $hourmin[1] + $options['sd-offset']);
- // Calculate opening / closing time of each day.
foreach ($row['days'] as $day) {
- $next = strtotime(date('Y-m-d H:i', strtotime($day . ' ' . $min . ' minutes')));
- if ($next < $now) {
- $times[] = strtotime(date('Y-m-d H:i', strtotime('next '.$day . ' ' . $min . ' minutes')));
- } else {
- $times[] = $next;
+ if ($findWol) {
+ $events[] = ['action' => self::ACTION_WOL,
+ 'time' => self::calculateTimestamp($now, $day, $row['openingtime'])];
+ }
+ if ($findSd) {
+ $events[] = ['action' => self::ACTION_SHUTDOWN,
+ 'time' => self::calculateTimestamp($now, $day, $row['closingtime'])];
}
}
}
- // Iterate over days, use timestamp with smallest difference to now.
- $res = 0; $smallestDiff = 0;
- foreach ($times as $time) {
- $diff = $time - $now;
- if ($res == 0 || $diff < $smallestDiff) {
- $smallestDiff = $diff;
- $res = $time;
+ $tmp = ArrayUtil::flattenByKey($events, 'time');
+ array_multisort($tmp, SORT_NUMERIC | SORT_ASC, $events);
+
+ // Only apply offsets now, so we can detect nonsensical overlap
+ $wolOffset = $options['wol-offset'] * 60;
+ $sdOffset = $options['sd-offset'] * 60;
+ $prev = PHP_INT_MAX;
+ for ($i = count($events) - 1; $i >= 0; --$i) {
+ $event =& $events[$i];
+ if ($event['action'] === self::ACTION_WOL) {
+ $event['time'] -= $wolOffset;
+ } elseif ($event['action'] === self::ACTION_SHUTDOWN) {
+ $event['time'] += $sdOffset;
+ } else {
+ error_log('BUG Unhandled event type ' . $event['action']);
+ }
+ if ($event['time'] >= $prev || $event['time'] < $now) {
+ // Overlap, delete this event
+ unset($events[$i]);
+ } else {
+ $prev = $event['time'];
+ }
+ }
+ unset($event);
+ // Reset array keys
+ $events = array_values($events);
+
+ // See which is the next suitable event to act upon
+ $lastEvent = count($events) - 1;
+ for ($i = 0; $i <= $lastEvent; $i++) {
+ $event =& $events[$i];
+ $diff = ($i === $lastEvent ? PHP_INT_MAX : $events[$i + 1]['time'] - $event['time']);
+ if ($diff < 300 && $event['action'] !== $events[$i + 1]['action']) {
+ // If difference to next event is < 5 min, ignore.
+ continue;
}
+ if ($diff < 900 && $event['action'] === self::ACTION_SHUTDOWN && $events[$i + 1]['action'] === self::ACTION_WOL) {
+ // If difference to next WOL is < 15 min and this is a shutdown, reboot instead.
+ $res['action'] = self::ACTION_REBOOT;
+ $res['time'] = $event['time'];
+ } else {
+ // Use first event.
+ $res = $event;
+ }
+ return $res;
}
- return $res;
+ unset($event);
+ return false;
}
+ /**
+ * Check if any actions have to be taken. To be called periodically by cron.
+ */
+ public static function cron()
+ {
+ $now = time();
+ $res = Database::simpleQuery("SELECT s.locationid, s.action, s.nextexecution, s.options
+ FROM reboot_scheduler s
+ WHERE s.nextexecution < :now AND s.nextexecution > 0", ['now' => $now]);
+ foreach ($res as $row) {
+ // Calculate next_execution for the event and update DB.
+ $options = json_decode($row['options'], true) + self::SCHEDULE_OPTIONS_DEFAULT;
+ // Determine proper opening times by waling up tree
+ $openingTimes = OpeningTimes::forLocation($row['locationid']);
+ if ($openingTimes !== null) {
+ self::updateScheduleSingle($row['locationid'], $options, $openingTimes);
+ }
+ // Weird clock drift? Server offline for a while? Do nothing.
+ if ($row['nextexecution'] + 900 < $now)
+ continue;
+ $selectiveRa = ($options['ra-mode'] === self::RA_SELECTIVE);
+ // Now, if selective remote access is active, we might modify the actual event:
+ if ($selectiveRa) {
+ // If this is WOL, and WOL is actually enabled, then reboot any running machines
+ // in remoteaccess mode, in addition to waking the others, so they exit remote access mode.
+ if ($row['action'] === Scheduler::ACTION_WOL && $options['wol']) {
+ self::executeCronForLocation($row['locationid'], Scheduler::ACTION_REBOOT, 'remoteaccess');
+ self::executeCronForLocation($row['locationid'], Scheduler::ACTION_WOL);
+ }
+ // If this is WOL, and WOL is disabled, shut down any running machines, this is so
+ // anybody walking into this room will not mess with a user's session by yanking the
+ // power cord etc.
+ if ($row['action'] === Scheduler::ACTION_WOL && !$options['wol']) {
+ self::executeCronForLocation($row['locationid'], Scheduler::ACTION_SHUTDOWN, 'remoteaccess');
+ }
+ // If this is SHUTDOWN, and SHUTDOWN is enabled, leave it at that.
+ if ($row['action'] === Scheduler::ACTION_SHUTDOWN && $options['sd']) {
+ self::executeCronForLocation($row['locationid'], Scheduler::ACTION_SHUTDOWN);
+ }
+ // If this is SHUTDOWN, and SHUTDOWN is disabled, do a reboot, so the machine ends up
+ // in the proper runmode.
+ if ($row['action'] === Scheduler::ACTION_SHUTDOWN && !$options['sd']) {
+ self::executeCronForLocation($row['locationid'], Scheduler::ACTION_REBOOT, '');
+ }
+ } else {
+ // Regular case, no selective remoteaccess – just do what the cron entry says
+ self::executeCronForLocation($row['locationid'], $row['action']);
+ }
+ }
+ }
+
+ /**
+ * Execute the given action for the given location.
+ * @param int $locationId location
+ * @param string $action action to perform, Scheduler::*
+ * @param string|null $onlyRunmode if not null, only process running clients in given runmode
+ * @return void
+ */
+ private static function executeCronForLocation(int $locationId, string $action, string $onlyRunmode = null)
+ {
+ if ($onlyRunmode === null) {
+ $machines = Database::queryAll("SELECT machineuuid, clientip, macaddr, locationid FROM machine
+ WHERE locationid = :locid", ['locid' => $locationId]);
+ } else {
+ $machines = Database::queryAll("SELECT machineuuid, clientip, macaddr, locationid FROM machine
+ WHERE locationid = :locid AND currentrunmode = :runmode AND state <> 'OFFLINE'",
+ ['locid' => $locationId, 'runmode' => $onlyRunmode]);
+ }
+ if (empty($machines))
+ return;
+ if ($action === Scheduler::ACTION_SHUTDOWN) {
+ RebootControl::execute($machines, RebootControl::SHUTDOWN, 0);
+ } elseif ($action === Scheduler::ACTION_WOL) {
+ RebootControl::wakeMachines($machines);
+ } elseif ($action === Scheduler::ACTION_REBOOT) {
+ RebootControl::execute($machines, RebootControl::REBOOT, 0);
+ } else {
+ EventLog::warning("Invalid action '$action' in schedule for location " . $locationId);
+ }
+ }
+ /**
+ * Get current settings for given location.
+ */
+ public static function getLocationOptions(int $id): array
+ {
+ static $optionList = false;
+ if ($optionList === false) {
+ $optionList = Database::queryKeyValueList("SELECT locationid, `options` FROM `reboot_scheduler`");
+ }
+ if (isset($optionList[$id])) {
+ return (json_decode($optionList[$id], true) ?? []) + self::SCHEDULE_OPTIONS_DEFAULT;
+ }
+ return self::SCHEDULE_OPTIONS_DEFAULT;
+ }
- private static function upsert($locationid, $action, $nextexec, $options) {
- $schedule = Database::queryFirst("SELECT locationid, action
- FROM `reboot_scheduler`
- WHERE locationid = :lid AND action = :act", array(
- 'lid' => $locationid,
- 'act' => $action
- ));
- if ($schedule === false) {
- Database::exec("INSERT INTO `reboot_scheduler` (locationid, action, nextexecution, options)
- VALUES (:lid, :act, :next, :opt)", array(
- 'lid' => $locationid,
- 'act' => $action,
- 'next' => $nextexec,
- 'opt' => $options
- ));
+ /**
+ * Write new WOL/Shutdown options for given location.
+ * @param array $options 'wol' 'sd' 'wol-offset' 'sd-offset' 'ra-mode'
+ */
+ public static function setLocationOptions(int $locationId, array $options)
+ {
+ $options += self::SCHEDULE_OPTIONS_DEFAULT;
+ $openingTimes = OpeningTimes::forLocation($locationId);
+ if (!$options['wol'] && !$options['sd'] && $options['ra-mode'] === self::RA_ALWAYS) {
+ self::deleteSchedule($locationId);
} else {
- Database::exec("UPDATE `reboot_scheduler`
- SET nextexecution = :next, options = :opt
- WHERE locationid = :lid AND action = :act", array(
- 'next' => $nextexec,
- 'opt' => $options,
- 'lid' => $locationid,
- 'act' => $action
- ));
- }
- return true;
+ // Sanitize
+ Util::clamp($options['wol-offset'], 0, 60);
+ Util::clamp($options['sd-offset'], 0, 60);
+ $json_options = json_encode($options);
+ // Write settings, reset schedule
+ Database::exec("INSERT INTO `reboot_scheduler` (locationid, action, nextexecution, options)
+ VALUES (:lid, :act, :next, :opt)
+ ON DUPLICATE KEY UPDATE
+ action = VALUES(action), nextexecution = VALUES(nextexecution), options = VALUES(options)", [
+ 'lid' => $locationId,
+ 'act' => 'WOL',
+ 'next' => 0,
+ 'opt' => $json_options,
+ ]);
+ // Write new timestamps for this location
+ if ($openingTimes !== null) {
+ self::updateScheduleSingle($locationId, $options, $openingTimes);
+ }
+ }
+ // In either case, refresh data for children as well
+ if ($openingTimes !== null) {
+ self::updateScheduleRecursive($locationId, $openingTimes);
+ }
+ }
+
+ /**
+ * Write next WOL/shutdown action to DB, using given options and opening times.
+ * @param int $locationid Location to store settings for
+ * @param array $options Options for calculation (WOL/Shutdown enabled, offsets)
+ * @param array $openingTimes Opening times to use
+ */
+ private static function updateScheduleSingle(int $locationid, array $options, array $openingTimes)
+ {
+ if (!$options['wol'] && !$options['sd'] && $options['ra-mode'] === self::RA_ALWAYS) {
+ self::deleteSchedule($locationid);
+ return;
+ }
+ $nextexec = self::calculateNext($options, $openingTimes);
+ if ($nextexec === false) {
+ // Empty opening times, or all intervals seem to be < 5 minutes, disable.
+ $nextexec = [
+ 'action' => 'WOL',
+ 'time' => 0,
+ ];
+ }
+ Database::exec("UPDATE reboot_scheduler
+ SET action = :act, nextexecution = :next
+ WHERE locationid = :lid", [
+ 'lid' => $locationid,
+ 'act' => $nextexec['action'],
+ 'next' => $nextexec['time'],
+ ]);
+ }
+
+ /**
+ * Recurse into all child locations of the given location-id and re-calculate the next
+ * WOL or shutdown event, based on the given opening times. Recursion stops at locations
+ * that come with their own opening times.
+ * @param int $parentId parent location to start recursion from. Not actually processed.
+ * @param array $openingTimes Opening times to use for calculations
+ */
+ private static function updateScheduleRecursive(int $parentId, array $openingTimes)
+ {
+ $list = Location::getLocationsAssoc();
+ if (!isset($list[$parentId]))
+ return;
+ $childIdList = $list[$parentId]['directchildren'];
+ if (empty($childIdList))
+ return;
+ $res = Database::simpleQuery("SELECT l.locationid, l.openingtime, rs.options
+ FROM location l
+ LEFT JOIN reboot_scheduler rs USING (locationid)
+ WHERE l.locationid IN (:list)", ['list' => $childIdList]);
+ $locationData = [];
+ foreach ($res as $row) {
+ $locationData[$row['locationid']] = $row;
+ }
+ // Handle all child locations
+ foreach ($childIdList as $locationId) {
+ if (!isset($locationData[$locationId]) || $locationData[$locationId]['openingtime'] !== null) {
+ continue; // Ignore entire sub-tree where new opening times are assigned
+ }
+ // This location doesn't have a new openingtimes schedule
+ // If any options are set for this location, update its schedule
+ if ($locationData[$locationId]['options'] !== null) {
+ $options = json_decode($locationData[$locationId]['options'], true);
+ if (!is_array($options)) {
+ trigger_error("Invalid options for lid:$locationId", E_USER_WARNING);
+ } else {
+ $options += self::SCHEDULE_OPTIONS_DEFAULT;
+ self::updateScheduleSingle($locationId, $options, $openingTimes);
+ }
+ }
+ // Either way, further walk down the tree
+ self::updateScheduleRecursive($locationId, $openingTimes);
+ }
+ }
+
+ public static function isValidRaMode(string $raMode): bool
+ {
+ return $raMode === self::RA_ALWAYS || $raMode === self::RA_NEVER || $raMode === self::RA_SELECTIVE;
}
} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/inc/sshkey.inc.php b/modules-available/rebootcontrol/inc/sshkey.inc.php
index cce9b3dc..e0954415 100644
--- a/modules-available/rebootcontrol/inc/sshkey.inc.php
+++ b/modules-available/rebootcontrol/inc/sshkey.inc.php
@@ -3,13 +3,17 @@
class SSHKey
{
- public static function getPrivateKey(&$regen = false) {
+ public static function getPrivateKey(?bool &$regen = false): ?string
+ {
$privKey = Property::get("rebootcontrol-private-key");
if (!$privKey) {
- $rsaKey = openssl_pkey_new(array(
+ $rsaKey = openssl_pkey_new([
'private_key_bits' => 2048,
- 'private_key_type' => OPENSSL_KEYTYPE_RSA));
- openssl_pkey_export( openssl_pkey_get_private($rsaKey), $privKey);
+ 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
+ if (!openssl_pkey_export( openssl_pkey_get_private($rsaKey), $privKey)) {
+ $regen = false;
+ return null;
+ }
Property::set("rebootcontrol-private-key", $privKey);
if (Module::isAvailable('sysconfig')) {
ConfigTgz::rebuildAllConfigs();
@@ -19,21 +23,30 @@ class SSHKey
return $privKey;
}
- public static function getPublicKey() {
+ public static function getPublicKey(): ?string
+ {
$pkImport = openssl_pkey_get_private(self::getPrivateKey());
+ if ($pkImport === false)
+ return null;
return self::sshEncodePublicKey($pkImport);
}
- private static function sshEncodePublicKey($privKey) {
+ private static function sshEncodePublicKey($privKey): ?string
+ {
$keyInfo = openssl_pkey_get_details($privKey);
+ if ($keyInfo === false)
+ return null;
$buffer = pack("N", 7) . "ssh-rsa" .
self::sshEncodeBuffer($keyInfo['rsa']['e']) .
self::sshEncodeBuffer($keyInfo['rsa']['n']);
return "ssh-rsa " . base64_encode($buffer);
}
- private static function sshEncodeBuffer($buffer) {
+ private static function sshEncodeBuffer(string $buffer): string
+ {
$len = strlen($buffer);
+ // Prefix with extra null byte if the MSB is set, to ensure
+ // nobody will ever interpret this as a negative number
if (ord($buffer[0]) & 0x80) {
$len++;
$buffer = "\x00" . $buffer;
diff --git a/modules-available/rebootcontrol/install.inc.php b/modules-available/rebootcontrol/install.inc.php
index 7d4382d0..d45a2443 100644
--- a/modules-available/rebootcontrol/install.inc.php
+++ b/modules-available/rebootcontrol/install.inc.php
@@ -39,10 +39,10 @@ $output[] = tableCreate('reboot_subnet_x_subnet', "
$output[] = tableCreate('reboot_scheduler', "
`locationid` INT(11) NOT NULL,
- `action` ENUM('wol', 'sd'),
+ `action` ENUM('WOL', 'SHUTDOWN', 'REBOOT'),
`nextexecution` INT(10) UNSIGNED NOT NULL DEFAULT 0,
`options` BLOB,
- PRIMARY KEY (`locationid`, `action`)");
+ PRIMARY KEY (`locationid`)");
$output[] = tableAddConstraint('reboot_jumphost_x_subnet', 'hostid', 'reboot_jumphost', 'hostid',
'ON UPDATE CASCADE ON DELETE CASCADE');
@@ -55,4 +55,23 @@ $output[] = tableAddConstraint('reboot_subnet_x_subnet', 'dstid', 'reboot_subnet
$output[] = tableAddConstraint('reboot_scheduler', 'locationid', 'location', 'locationid',
'ON UPDATE CASCADE ON DELETE CASCADE');
+if (tableColumnKeyType('reboot_scheduler', 'action') === 'PRI') {
+ Database::exec("DELETE FROM reboot_scheduler WHERE action <> 'wol'");
+ $res = Database::exec("ALTER TABLE `reboot_scheduler` DROP PRIMARY KEY, ADD PRIMARY KEY (`locationid`)");
+ $output[] = $res !== false ? UPDATE_DONE : UPDATE_FAILED;
+}
+if (strpos(tableColumnType('reboot_scheduler', 'action'), 'REBOOT') === false) {
+ // Fiddle with column to rename ENUM values
+ $res = Database::exec("ALTER TABLE `reboot_scheduler` MODIFY COLUMN `action` ENUM('sd', 'rb', 'WOL', 'SHUTDOWN', 'REBOOT')");
+ handleUpdateResult($res);
+ $res = Database::exec("UPDATE reboot_scheduler SET action =
+ CASE WHEN action = 'sd' THEN 'SHUTDOWN' WHEN action = 'rb' THEN 'REBOOT' ELSE 'WOL' END");
+ handleUpdateResult($res);
+ $res = Database::exec("ALTER TABLE `reboot_scheduler` MODIFY COLUMN `action` ENUM('WOL', 'SHUTDOWN', 'REBOOT')");
+ handleUpdateResult($res);
+ $output[] = UPDATE_DONE;
+}
+
+
+
responseFromArray($output); \ No newline at end of file
diff --git a/modules-available/rebootcontrol/lang/de/permissions.json b/modules-available/rebootcontrol/lang/de/permissions.json
index fb32225d..589db5b9 100644
--- a/modules-available/rebootcontrol/lang/de/permissions.json
+++ b/modules-available/rebootcontrol/lang/de/permissions.json
@@ -2,6 +2,7 @@
"action.exec": "Befehle als root auf laufenden Clients ausf\u00fchren.",
"action.reboot": "Client neustarten.",
"action.shutdown": "Client herunterfahren.",
+ "action.view": "Laufende WOL\/Reboot\/Exec-Tasks sehen.",
"action.wol": "Client per WOL starten.",
"jumphost.assign-subnet": "Einem Sprung-Host ein Subnetz zuweisen.",
"jumphost.edit": "Einen Sprung-Host bearbeiten.",
@@ -11,4 +12,4 @@
"subnet.flag": "Eigenschaften eines Subnetzes bearbeiten.",
"subnet.view": "Liste der Subnetze sehen.",
"woldiscover": "Automatische Ermittlung von subnetz\u00fcbergreifender WOL-F\u00e4higkeit."
-}
+} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/lang/de/template-tags.json b/modules-available/rebootcontrol/lang/de/template-tags.json
index 6b01dc1e..b54adbcd 100644
--- a/modules-available/rebootcontrol/lang/de/template-tags.json
+++ b/modules-available/rebootcontrol/lang/de/template-tags.json
@@ -13,6 +13,9 @@
"lang_clientCount": "# Clients",
"lang_confirmDeleteSubnet": "Dieses Subnetz wirklich l\u00f6schen?",
"lang_connecting": "Verbinde...",
+ "lang_directedBroadcastAddress": "Ziel-Adresse",
+ "lang_directedBroadcastDescription": "Diese Adresse wird als Ziel-Adresse zum Wecken s\u00e4mtlicher Clients benutzt. Dies ist bei Verwendung von WOL-Proxies sinnvoll. Wenn das Feld leer ist, wird die Directed Broadcast Adresse des Zielnetzes verwendet.",
+ "lang_directedBroadcastOverrideHeading": "Directed Broadcast Adresse \u00fcberschreiben",
"lang_editJumpHost": "Sprung-Host bearbeiten",
"lang_editSubnet": "Subnetz bearbeiten",
"lang_enterCommand": "Auszuf\u00fchrende Befehle",
@@ -20,7 +23,7 @@
"lang_execRemoteCommand": "Befehl auf Rechner(n) ausf\u00fchren",
"lang_executingRemotely": "F\u00fchre auf gew\u00e4hlten Clients aus...",
"lang_exitCode": "Exit Code",
- "lang_fixSubnetDesc": "Wenn aktiviert, wird die Erreichbarkeit f\u00fcr dieses Subnetz nicht mehr automatisch ermittelt. Sie k\u00f6nnen in diesem Fall selbst festlegen, ob das Subnetz WOL-Pakete vom Satelliten-Server empfangen kann. Au\u00dferdem wird das Subnetz bei Setzen dieser Option nicht mehr automatisch aus der Datenbank gel\u00f6scht, wenn 6 Monate lang kein Client in diesem Subnetz gesehen wurde.",
+ "lang_fixSubnetDesc": "Wenn aktiviert, wird die Erreichbarkeit f\u00fcr dieses Subnetz nicht mehr automatisch ermittelt. Sie k\u00f6nnen in diesem Fall selbst festlegen, ob das Subnetz WOL-Pakete vom Satellitenserver empfangen kann. Au\u00dferdem wird das Subnetz bei Setzen dieser Option nicht mehr automatisch aus der Datenbank gel\u00f6scht, wenn 6 Monate lang kein Client in diesem Subnetz gesehen wurde.",
"lang_fixSubnetSettings": "Subnetz-Einstellungen manuell festlegen",
"lang_genNew": "Neues Schl\u00fcsselpaar generieren",
"lang_help": "Hilfe",
@@ -31,7 +34,7 @@
"lang_hostReachable": "Host erreichbar",
"lang_ip": "IP",
"lang_isDirect": "Direkt erreichbar",
- "lang_isDirectHelp": "Dieses Subnetz kann WOL-Pakete direkt vom Satelliten-Server empfangen. Keine Sprung-Hosts oder laufende Clients im Zielnetz notwendig.",
+ "lang_isDirectHelp": "Dieses Subnetz kann WOL-Pakete direkt vom Satellitenserver empfangen. Keine Sprung-Hosts oder laufende Clients im Zielnetz notwendig.",
"lang_isFixed": "Manuell konfiguriert",
"lang_isFixedHelp": "Die direkte Erreichbarkeit vom Satelliten aus wird nicht periodisch automatisch ermittelt, sondern manuell \u00fcber die Weboberfl\u00e4che festgelegt.",
"lang_jumpHost": "Sprung-Host",
@@ -50,7 +53,7 @@
"lang_pubKey": "SSH Public Key:",
"lang_reachable": "Erreichbar",
"lang_reachableFrom": "Erreichbar von",
- "lang_reachableFromServer": "Erreichbar vom Satelliten-Server",
+ "lang_reachableFromServer": "Erreichbar vom Satellitenserver",
"lang_reachableFromServerDesc": "Wenn dieser Haken gesetzt ist wird angenommen, dass WOL-Pakete, die vom Server aus gesendet werden, dieses Subnetz erreichen k\u00f6nnen. Dazu muss der Router des Ziel-Netzes sog. \"Directed Broadcasts\" unterst\u00fctzen bzw. nicht filtern.",
"lang_rebootAt": "Neustart um:",
"lang_rebooting": "Neustart...",
@@ -64,16 +67,17 @@
"lang_stdout": "Standard-Output Ausgabe",
"lang_subnet": "Subnetz",
"lang_subnets": "Subnetze",
- "lang_subnetsDescription": "Dies sind dem Satelliten-Server bekannte Subnetze. Damit WOL \u00fcber Subnetz-Grenzen hinaus funktioniert, muss bekannt sein, in welche Netze \"Directed Broadcasts\" gesendet werden k\u00f6nnen, bzw. f\u00fcr welche Netze ein \"Sprung-Host\" existiert. Diese Liste wird sich automatisch f\u00fcllen, wenn Clients gestartet werden. Au\u00dferdem wird automatisch ermittelt, welche Netze mittels \"Directed Broadcasts\" erreichbar sind, sofern diese Funktion nicht oben unter \"Einstellungen\" deaktiviert wird.",
+ "lang_subnetsDescription": "Dies sind dem Satellitenserver bekannte Subnetze. Damit WOL \u00fcber Subnetz-Grenzen hinaus funktioniert, muss bekannt sein, in welche Netze \"Directed Broadcasts\" gesendet werden k\u00f6nnen, bzw. f\u00fcr welche Netze ein \"Sprung-Host\" existiert. Diese Liste wird sich automatisch f\u00fcllen, wenn Clients gestartet werden. Au\u00dferdem wird automatisch ermittelt, welche Netze mittels \"Directed Broadcasts\" erreichbar sind, sofern diese Funktion nicht oben unter \"Einstellungen\" deaktiviert wird.",
"lang_task": "Task",
"lang_taskListIntro": "Hier sehen Sie eine Liste k\u00fcrzlich gestarteter Aufgaben, wie z.B. WOL-Aktionen, das Neustarten oder Herunterfahren von Clients, etc.",
"lang_wakeScriptHelp": "Dieses Script wird auf dem Sprung-Host ausgef\u00fchrt, um den\/die gew\u00fcnschten Maschinen aufzuwecken. Es wird unter der Standard-Shell des oben angegebenen Benutzers ausgef\u00fchrt. Das Script kann zwei spezielle Platzhalter enthalten, die vor dem Ausf\u00fchren des Scripts vom Satellitenserver ersetzt werden: %MACS% ist eine durch Leerzeichen getrennte Liste von MAC-Adressen, die aufzuwecken sind. Das Tool \"wakeonlan\" unterst\u00fctzt direkt mehrere MAC-Adressen, sodass der Platzhalter %MACS% direkt als Kommandozeilenargument verwendet werden kann. Das Tool \"etherwake\" hingegen kann pro Aufruf immer nur einen Host aufwecken, weshalb eine for-Schleife notwendig ist. Au\u00dferdem wird der Platzhalter %IP% ersetzt, welcher je nach Ziel entweder \"255.255.255.255\" ist, oder bei einem netz\u00fcbergreifenden WOL-Paket die \"directed broadcast address\" des Zielnetzes. Netz\u00fcbergreifende WOL-Pakete werden vom \"etherwake\" nicht unterst\u00fctzt.",
"lang_wakeupScript": "Aufweck-Script",
+ "lang_when": "Wann",
"lang_wol": "WakeOnLAN",
"lang_wolAutoDiscoverCheck": "WOL-Erreichbarkeit von Subnetzen automatisch ermitteln",
"lang_wolDestPort": "UDP-Ziel-Port f\u00fcr Pakete, die vom Satelliten gesendet werden",
"lang_wolDiscoverClientToClient": "Auch Erreichbarkeit zwischen Client-Subnetzen pr\u00fcfen",
- "lang_wolDiscoverDescription": "Ist die erste Option aktiv, ermittelt der Satelliten-Server automatisch, welche Client-Subnetze direkt per \"Directed Broadcast\" erreichbar sind, unter Verwendung des oben angegebenen Ports. Ist die zweite Option aktiviert, wird zus\u00e4tzlich noch ermittelt, welche Client-Subnetze sich untereinander UDP-WOL-Pakete schicken k\u00f6nnen. Dies ist i.d.R. nicht notwendig, au\u00dfer in Setups mit ungew\u00f6hnlichen Firewall-Regelungen.",
+ "lang_wolDiscoverDescription": "Ist die erste Option aktiv, ermittelt der Satellitenserver automatisch, welche Client-Subnetze direkt per \"Directed Broadcast\" erreichbar sind, unter Verwendung des oben angegebenen Ports. Ist die zweite Option aktiviert, wird zus\u00e4tzlich noch ermittelt, welche Client-Subnetze sich untereinander UDP-WOL-Pakete schicken k\u00f6nnen. Dies ist i.d.R. nicht notwendig, au\u00dfer in Setups mit ungew\u00f6hnlichen Firewall-Regelungen.",
"lang_wolDiscoverHeading": "Automatische WOL-Ermittlung",
"lang_wolMachineSupportText": "Sind die technischen Voraussetzungen erf\u00fcllt, dass ein WOL-Paket den gew\u00fcnschten Rechner erreichen kann, ist es weiterhin erforderlich, dass der Rechner mittels BIOS und evtl. vorhandenem Betriebssystem so konfiguriert wird, dass er auch auf WOL-Pakete reagiert. Zum einen muss die Funktion im BIOS aktiviert sein. Hier ist auch darauf zu achten, ob es eine zus\u00e4tzliche Einstellung gibt, die die normale Bootreihenfolge \u00fcberschreibt, und dass diese wie gew\u00fcnscht konfiguriert wird. Ist WOL im BIOS aktiviert, kann das Betriebssystem die Funktionalit\u00e4t noch per Software ein- und ausschalten. Unter Windows erfolgt dies im Ger\u00e4temanager in den Eigenschaften der Netzwerkkarte. Dies ist relevant, wenn parallel zu bwLehrpool noch ein Windows von der lokaler Platte betrieben wird. Unter Linux kann die WOL-Funktion mit dem ethtool beeinflusst werden. bwLehrpool aktiviert WOL automatisch bei jedem Boot.",
"lang_wolReachability": "Erreichbarkeit",
diff --git a/modules-available/rebootcontrol/lang/en/permissions.json b/modules-available/rebootcontrol/lang/en/permissions.json
index f5144d18..b925c2b2 100644
--- a/modules-available/rebootcontrol/lang/en/permissions.json
+++ b/modules-available/rebootcontrol/lang/en/permissions.json
@@ -2,6 +2,7 @@
"action.exec": "Execute commands on clients (as root).",
"action.reboot": "Reboot Client.",
"action.shutdown": "Shutdown Client.",
+ "action.view": "See running WOL\/reboot\/exec jobs.",
"action.wol": "Send WOL packet to client.",
"jumphost.assign-subnet": "Assign subnet to jump host.",
"jumphost.edit": "Edit jump host.",
diff --git a/modules-available/rebootcontrol/lang/en/template-tags.json b/modules-available/rebootcontrol/lang/en/template-tags.json
index a50ba7fe..5740b208 100644
--- a/modules-available/rebootcontrol/lang/en/template-tags.json
+++ b/modules-available/rebootcontrol/lang/en/template-tags.json
@@ -13,6 +13,9 @@
"lang_clientCount": "# clients",
"lang_confirmDeleteSubnet": "Delete this subnet?",
"lang_connecting": "Connecting...",
+ "lang_directedBroadcastAddress": "Destination address",
+ "lang_directedBroadcastDescription": "This address will be used as the destination address for wake on LAN. This is useful if you want to use a WOL-proxy that does the actual waking. If you leave this field empty, the broadcast address of the destination network will be used.",
+ "lang_directedBroadcastOverrideHeading": "Override directed broadcast address",
"lang_editJumpHost": "Edit jump host",
"lang_editSubnet": "Edit subnet",
"lang_enterCommand": "Command(s) to execute",
@@ -69,11 +72,12 @@
"lang_taskListIntro": "This is a list of running or recently finished tasks (WOL, Shutdown, Reboot, ...).",
"lang_wakeScriptHelp": "This script will be executed on the jump host to wake up the selected machines. If will be executed using the default shell of the host. There are two special placeholders which will be replaced before executing the script on the host: %MACS% will be a space separated list of mac addresses to wake up. %IP% is either the directed broadcast address of the destination subnet, or simply 255.255.255.255, if the destination subnet is the same as the jump host's address.",
"lang_wakeupScript": "Wake script",
+ "lang_when": "When",
"lang_wol": "WakeOnLAN",
"lang_wolAutoDiscoverCheck": "Automatically determine WOL-reachability of subnets",
"lang_wolDestPort": "UDP destination port for packets sent from satellite",
"lang_wolDiscoverClientToClient": "Check reachability between different client subnets too",
- "lang_wolDiscoverDescription": "If the first option is active, the Satellite Server automatically determines which client subnets are directly reachable via \"Directed Broadcasts\", using the port specified above. If the second option is enabled, the satellite server also determines which client subnets can send UDP WOL packets to each other. This is usually not necessary, except in setups with unusual firewall rules. ",
+ "lang_wolDiscoverDescription": "If the first option is active, the satellite server automatically determines which client subnets are directly reachable via \"Directed Broadcasts\", using the port specified above. If the second option is enabled, the satellite server also determines which client subnets can send UDP WOL packets to each other. This is usually not necessary, except in setups with unusual firewall rules. ",
"lang_wolDiscoverHeading": "Automatic WOL detection",
"lang_wolMachineSupportText": "If the technical requirements for reaching a destination host with WOL packets are met, it is still required to enable wake on LAN on the client computer. This usually happens in the BIOS, where sometimes it is also possible to set a different boot order for when the machine was powered on via WOL. After enabling WOL in the BIOS, it is still possible to disable WOL in the driver after booting up an Operating System. In Windows, this can be configured in the device manager, which would be relevant if bwLehrpool is being used in a dual-boot environment. On Linux you can change this setting with the ethtool utility. bwLehrpool (re-)enables WOL on every boot.",
"lang_wolReachability": "Reachability",
diff --git a/modules-available/rebootcontrol/page.inc.php b/modules-available/rebootcontrol/page.inc.php
index 571e92e0..80eff842 100644
--- a/modules-available/rebootcontrol/page.inc.php
+++ b/modules-available/rebootcontrol/page.inc.php
@@ -21,10 +21,10 @@ class Page_RebootControl extends Page
}
if (User::hasPermission('jumphost.*')) {
- Dashboard::addSubmenu('?do=rebootcontrol&show=jumphost', Dictionary::translate('jumphosts', true));
+ Dashboard::addSubmenu('?do=rebootcontrol&show=jumphost', Dictionary::translate('jumphosts'));
}
if (User::hasPermission('subnet.*')) {
- Dashboard::addSubmenu('?do=rebootcontrol&show=subnet', Dictionary::translate('subnets', true));
+ Dashboard::addSubmenu('?do=rebootcontrol&show=subnet', Dictionary::translate('subnets'));
}
$section = Request::any('show', false, 'string');
@@ -48,9 +48,11 @@ class Page_RebootControl extends Page
$enabled = Request::post('enabled', false);
$c2c = Request::post('enabled-c2c', false);
$port = Request::post('port', 9, 'int');
+ $dbcast = Request::post('dbcast', '', 'string');
Property::set(RebootControl::KEY_AUTOSCAN_DISABLED, !$enabled);
Property::set(RebootControl::KEY_SCAN_CLIENT_TO_CLIENT, $c2c);
Property::set(RebootControl::KEY_UDP_PORT, $port);
+ Property::set(RebootControl::KEY_BROADCAST_ADDRESS, $dbcast);
if ($enabled) {
Message::addInfo('woldiscover-enabled');
} else {
@@ -63,7 +65,13 @@ class Page_RebootControl extends Page
if (Request::isPost()) {
Util::redirect('?do=rebootcontrol' . ($section ? '&show=' . $section : ''));
} elseif ($section === false) {
- Util::redirect('?do=rebootcontrol&show=task');
+ if (User::hasPermission('action.*')) {
+ Util::redirect('?do=rebootcontrol&show=task');
+ } elseif (User::hasPermission('jumphost.*')) {
+ Util::redirect('?do=rebootcontrol&show=jumphost');
+ } else {
+ Util::redirect('?do=rebootcontrol&show=subnet');
+ }
}
}
@@ -93,7 +101,6 @@ class Page_RebootControl extends Page
if (Taskmanager::isTask($task)) {
Util::redirect("?do=rebootcontrol&show=task&what=task&taskid=" . $task["id"]);
}
- return;
}
/**
@@ -112,13 +119,13 @@ class Page_RebootControl extends Page
'wol_auto_checked' => Property::get(RebootControl::KEY_AUTOSCAN_DISABLED) ? '' : 'checked',
'wol_c2c_checked' => Property::get(RebootControl::KEY_SCAN_CLIENT_TO_CLIENT) ? 'checked' : '',
'port' => $port,
+ 'dbcast' => Property::get(RebootControl::KEY_BROADCAST_ADDRESS),
];
Permission::addGlobalTags($data['perms'], null, ['newkeypair', 'woldiscover']);
Render::addTemplate('header', $data);
if ($this->haveSubpage) {
SubPage::doRender();
- return;
}
}
diff --git a/modules-available/rebootcontrol/pages/exec.inc.php b/modules-available/rebootcontrol/pages/exec.inc.php
index e5fe3cd8..6b5ea407 100644
--- a/modules-available/rebootcontrol/pages/exec.inc.php
+++ b/modules-available/rebootcontrol/pages/exec.inc.php
@@ -46,7 +46,6 @@ class SubPage
return;
}
Session::set('exec-' . $id, false);
- Session::save();
Render::addTemplate('exec-enter-command', ['clients' => $machines, 'id' => $id]);
}
diff --git a/modules-available/rebootcontrol/pages/jumphost.inc.php b/modules-available/rebootcontrol/pages/jumphost.inc.php
index bf0a67e2..d9aae234 100644
--- a/modules-available/rebootcontrol/pages/jumphost.inc.php
+++ b/modules-available/rebootcontrol/pages/jumphost.inc.php
@@ -32,7 +32,6 @@ class SubPage
if ($id !== false) {
User::assertPermission('jumphost.edit');
self::deleteJumphost($id);
- return;
}
}
@@ -135,7 +134,7 @@ class SubPage
LEFT JOIN reboot_jumphost_x_subnet jxs USING (hostid)
GROUP BY hostid
ORDER BY hostid');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$hosts[] = $row;
}
$data = [
@@ -188,7 +187,7 @@ class SubPage
ORDER BY start ASC',
['id' => $id]);
$list = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['cidr'] = IpUtil::rangeToCidr($row['start'], $row['end']);
if ($row['hostid'] !== null) {
$row['checked'] = 'checked';
diff --git a/modules-available/rebootcontrol/pages/subnet.inc.php b/modules-available/rebootcontrol/pages/subnet.inc.php
index cbd5d8f2..a6d8d837 100644
--- a/modules-available/rebootcontrol/pages/subnet.inc.php
+++ b/modules-available/rebootcontrol/pages/subnet.inc.php
@@ -24,7 +24,7 @@ class SubPage
User::assertPermission('subnet.edit');
$cidr = Request::post('cidr', Request::REQUIRED, 'string');
$range = IpUtil::parseCidr($cidr);
- if ($range === false) {
+ if ($range === null) {
Message::addError('invalid-cidr', $cidr);
return;
}
@@ -106,6 +106,7 @@ class SubPage
{
User::assertPermission('subnet.*');
$nets = [];
+ $c2c = Property::get(RebootControl::KEY_SCAN_CLIENT_TO_CLIENT);
$res = Database::simpleQuery('SELECT subnetid, start, end, fixed, isdirect,
nextdirectcheck, lastseen, seencount, Count(hxs.hostid) AS jumphostcount, Count(sxs.srcid) AS sourcecount
FROM reboot_subnet s
@@ -114,12 +115,15 @@ class SubPage
GROUP BY subnetid, start, end
ORDER BY start ASC, end DESC');
$deadline = strtotime('-60 days');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['cidr'] = IpUtil::rangeToCidr($row['start'], $row['end']);
$row['lastseen_s'] = Util::prettyTime($row['lastseen']);
if ($row['lastseen'] && $row['lastseen'] < $deadline) {
$row['lastseen_class'] = 'text-danger';
}
+ if (!$c2c) {
+ $row['sourcecount'] = '-';
+ }
$nets[] = $row;
}
$data = ['subnets' => $nets];
@@ -145,20 +149,24 @@ class SubPage
ORDER BY h.host ASC', ['id' => $id]);
// Mark those assigned to the current subnet
$jh = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['checked'] = $row['subnetid'] === null ? '' : 'checked';
$jh[] = $row;
}
$subnet['jumpHosts'] = $jh;
- // Get list of all subnets that can broadcast into this one
- $res = Database::simpleQuery('SELECT s.start, s.end FROM reboot_subnet s
+ $c2c = Property::get(RebootControl::KEY_SCAN_CLIENT_TO_CLIENT);
+ if ($c2c) {
+ // Get list of all subnets that can broadcast into this one
+ $res = Database::simpleQuery('SELECT s.start, s.end FROM reboot_subnet s
INNER JOIN reboot_subnet_x_subnet sxs ON (s.subnetid = sxs.srcid AND sxs.dstid = :id AND sxs.reachable = 1)
ORDER BY s.start ASC', ['id' => $id]);
- $sn = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $sn[] = ['cidr' => IpUtil::rangeToCidr($row['start'], $row['end'])];
+ $sn = [];
+ foreach ($res as $row) {
+ $sn[] = ['cidr' => IpUtil::rangeToCidr($row['start'], $row['end'])];
+ }
+ $subnet['sourceNets'] = $sn;
+ $subnet['showC2C'] = true;
}
- $subnet['sourceNets'] = $sn;
Permission::addGlobalTags($subnet['perms'], null, ['subnet.flag', 'jumphost.view', 'jumphost.assign-subnet']);
Render::addTemplate('subnet-edit', $subnet);
}
diff --git a/modules-available/rebootcontrol/pages/task.inc.php b/modules-available/rebootcontrol/pages/task.inc.php
index 691fd9e2..7db2a90b 100644
--- a/modules-available/rebootcontrol/pages/task.inc.php
+++ b/modules-available/rebootcontrol/pages/task.inc.php
@@ -26,6 +26,8 @@ class SubPage
private static function showTask()
{
+ // No permission check here - user had to guess the UUID, not very likely,
+ // but this way we can still link to some implicitly triggered job
$taskid = Request::get("taskid", Request::REQUIRED, 'string');
$type = Request::get('type', false, 'string');
if ($type === 'checkhost') {
@@ -93,8 +95,9 @@ class SubPage
} elseif ($type === RebootControl::TASK_WOL) {
// Nothing (yet)
} else {
- Util::traceError('oopsie');
+ ErrorHandler::traceError('oopsie');
}
+ $job['timestamp_s'] = Util::prettyTime($job['timestamp']);
Render::addTemplate('status-' . $template, $job);
}
@@ -103,6 +106,9 @@ class SubPage
Render::addTemplate('task-header');
// Append list of active reboot/shutdown tasks
$allowedLocs = User::getAllowedLocations("action.*");
+ if (empty($allowedLocs)) {
+ User::assertPermission('action.*');
+ }
$active = RebootControl::getActiveTasks($allowedLocs);
if (empty($active)) {
Message::addInfo('no-current-tasks');
@@ -112,8 +118,10 @@ class SubPage
if (isset($entry['clients'])) {
$entry['clients'] = count($entry['clients']);
}
+ $entry['timestamp_s'] = Util::prettyTime($entry['timestamp']);
}
unset($entry);
+ ArrayUtil::sortByColumn($active, 'timestamp', SORT_ASC, SORT_NUMERIC);
Render::addTemplate('task-list', ['list' => $active]);
}
}
@@ -121,7 +129,7 @@ class SubPage
private static function expandLocationIds(&$lids)
{
foreach ($lids as &$locid) {
- if ($locid === 0) {
+ if ($locid === null || $locid === 0) {
$name = '-';
} else {
$name = Location::getName($locid);
@@ -137,14 +145,3 @@ class SubPage
}
}
-
-
-// Remove when we require >= 7.3.0
-if (!function_exists('array_key_first')) {
- function array_key_first(array $arr) {
- foreach($arr as $key => $unused) {
- return $key;
- }
- return NULL;
- }
-}
diff --git a/modules-available/rebootcontrol/permissions/permissions.json b/modules-available/rebootcontrol/permissions/permissions.json
index 5416a482..a508f8b6 100644
--- a/modules-available/rebootcontrol/permissions/permissions.json
+++ b/modules-available/rebootcontrol/permissions/permissions.json
@@ -34,5 +34,8 @@
},
"action.exec": {
"location-aware": true
+ },
+ "action.view": {
+ "location-aware": true
}
} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/templates/header.html b/modules-available/rebootcontrol/templates/header.html
index d5e79a14..47d97714 100644
--- a/modules-available/rebootcontrol/templates/header.html
+++ b/modules-available/rebootcontrol/templates/header.html
@@ -42,28 +42,37 @@
</div>
<div class="modal-body">
<label>{{lang_wolDiscoverHeading}}</label>
- <div class="checkbox">
- <input {{perms.woldiscover.disabled}} id="wol-auto-discover"
- type="checkbox" name="enabled" {{wol_auto_checked}}>
- <label for="wol-auto-discover">{{lang_wolAutoDiscoverCheck}}</label>
- </div>
- <div class="checkbox">
- <input {{perms.woldiscover.disabled}} id="wol-c2c"
- type="checkbox" name="enabled-c2c" {{wol_c2c_checked}}>
- <label for="wol-c2c">{{lang_wolDiscoverClientToClient}}</label>
- </div>
- <div class="slx-space"></div>
- <p>{{lang_wolDiscoverDescription}}</p>
- <button {{perms.woldiscover.disabled}} class="btn btn-primary pull-right"
- onclick="generateNewKeypair()" type="submit">
- <span class="glyphicon glyphicon-floppy-disk"></span>
- {{lang_save}}
- </button>
- <div class="clearfix"></div>
+ <div class="checkbox">
+ <input {{perms.woldiscover.disabled}} id="wol-auto-discover"
+ type="checkbox" name="enabled" {{wol_auto_checked}}>
+ <label for="wol-auto-discover">{{lang_wolAutoDiscoverCheck}}</label>
+ </div>
+ <div class="checkbox">
+ <input {{perms.woldiscover.disabled}} id="wol-c2c"
+ type="checkbox" name="enabled-c2c" {{wol_c2c_checked}}>
+ <label for="wol-c2c">{{lang_wolDiscoverClientToClient}}</label>
+ </div>
+ <div class="slx-space"></div>
+ <p>{{lang_wolDiscoverDescription}}</p>
+ </div>
+ <div class="modal-body">
+ <label for="bcast-input">{{lang_directedBroadcastOverrideHeading}}</label>
+ <div class="input-group">
+ <span class="input-group-addon">{{lang_directedBroadcastAddress}}</span>
+ <input {{perms.woldiscover.disabled}} type="text" pattern="[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+"
+ minlength="7" maxlength="15" class="form-control" name="dbcast" value="{{dbcast}}" id="bcast-input">
+ </div>
+ <p>{{lang_directedBroadcastDescription}}</p>
+ </div>
+ <div class="modal-body">
+ <button {{perms.woldiscover.disabled}} class="btn btn-primary pull-right"
+ onclick="generateNewKeypair()" type="submit">
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ <div class="clearfix"></div>
</div>
</form>
- <div class="modal-body">
- </div>
</div>
</div>
</div>
@@ -98,4 +107,4 @@ document.addEventListener('DOMContentLoaded', function() {
});
-</script> \ No newline at end of file
+</script>
diff --git a/modules-available/rebootcontrol/templates/status-checkconnection.html b/modules-available/rebootcontrol/templates/status-checkconnection.html
index e31d95ea..da1177e7 100644
--- a/modules-available/rebootcontrol/templates/status-checkconnection.html
+++ b/modules-available/rebootcontrol/templates/status-checkconnection.html
@@ -1,4 +1,4 @@
-<h3>{{lang_checkingJumpHost}}: {{host}}</h3>
+<h3>{{lang_checkingJumpHost}}: {{host}} – {{timestamp_s}}</h3>
<div class="clearfix"></div>
<div class="collapse alert alert-success" id="result-ok">
diff --git a/modules-available/rebootcontrol/templates/status-exec.html b/modules-available/rebootcontrol/templates/status-exec.html
index 403b7fca..a3efef5f 100644
--- a/modules-available/rebootcontrol/templates/status-exec.html
+++ b/modules-available/rebootcontrol/templates/status-exec.html
@@ -1,3 +1,5 @@
+<h3>{{timestamp_s}}</h3>
+
<div data-tm-id="{{id}}" data-tm-log="error" data-tm-callback="updateStatus">{{lang_executingRemotely}}</div>
<div class="slx-space"></div>
@@ -14,7 +16,11 @@
{{#clients}}
<div class="list-group-item" id="client-{{machineuuid}}">
<div class="row">
- <div class="col-md-6 col-sm-8 col-xs-12 slx-bold">{{hostname}}{{^hostname}}{{clientip}}{{/hostname}}</div>
+ <div class="col-md-6 col-sm-8 col-xs-12 slx-bold">
+ <a href="?do=statistics&amp;uuid={{machineuuid}}">
+ {{hostname}}{{^hostname}}{{clientip}}{{/hostname}}
+ </a>
+ </div>
<div class="col-md-4 col-sm-2 col-xs-6 state"></div>
<div class="col-md-2 col-sm-2 col-xs-6 text-right exitCode"></div>
</div>
@@ -57,6 +63,13 @@ function updateStatusClient(id, status) {
$p.find('.exitCode').text(status.exitCode).addClass((status.exitCode === 0 ? 'text-success' : 'text-danger'));
}
ignoreHosts[id] = true;
+ var txt = status.stdout.trim();
+ if (txt.startsWith('<') && txt.endsWith('</svg>')) {
+ var $i = $('<img class="img-responsive">');
+ $i[0].src = 'data:image/svg+xml,' + encodeURIComponent(txt);
+ $p.find('.stdout').hide();
+ $p.append($i);
+ }
}
}
diff --git a/modules-available/rebootcontrol/templates/status-reboot.html b/modules-available/rebootcontrol/templates/status-reboot.html
index 7b46cab4..34971845 100644
--- a/modules-available/rebootcontrol/templates/status-reboot.html
+++ b/modules-available/rebootcontrol/templates/status-reboot.html
@@ -1,4 +1,5 @@
-<h3>{{action}}</h3>
+<h3>{{action}} – {{timestamp_s}}</h3>
+
{{#locations}}
<div class="loc">{{name}}</div>
{{/locations}}
@@ -18,7 +19,7 @@
<tbody>
{{#clients}}
<tr>
- <td>{{hostname}}{{^hostname}}{{machineuuid}}{{/hostname}}</td>
+ <td><a href="?do=statistics&amp;uuid={{machineuuid}}">{{hostname}}{{^hostname}}{{machineuuid}}{{/hostname}}</a></td>
<td>{{clientip}}</td>
<td>
<span id="status-{{machineuuid}}" class="machineuuid" data-uuid="{{machineuuid}}"></span>
diff --git a/modules-available/rebootcontrol/templates/status-wol.html b/modules-available/rebootcontrol/templates/status-wol.html
index 5a53a6f8..70517f84 100644
--- a/modules-available/rebootcontrol/templates/status-wol.html
+++ b/modules-available/rebootcontrol/templates/status-wol.html
@@ -1,10 +1,12 @@
+<h3>{{timestamp_s}}</h3>
+
{{#locations}}
<div class="loc">{{name}}</div>
{{/locations}}
<div class="clearfix slx-space"></div>
{{#tasks}}
-<div data-tm-id="{{.}}" data-tm-callback="wolCallback" data-tm-log="messages" data-tm-log-fail-only="true">{{lang_aWolJob}}</div>
+<div data-tm-id="{{.}}" data-tm-callback="wolCallback" data-tm-log="messages">{{lang_aWolJob}}</div>
{{/tasks}}
{{^tasks}}
<div class="alert alert-warning">
@@ -29,17 +31,23 @@
<tbody>
{{#clients}}
<tr>
- <td>{{hostname}}{{^hostname}}{{machineuuid}}{{^machineuuid}}{{clientip}}{{/machineuuid}}{{/hostname}}</td>
- <td>{{clientip}}</td>
+ <td>
{{#machineuuid}}
+ <a href="?do=statistics&amp;uuid={{machineuuid}}">
+ {{hostname}}{{^hostname}}{{machineuuid}}{{/hostname}}
+ </a>
+ {{/machineuuid}}
+ {{^machineuuid}}
+ {{hostname}}{{^hostname}}{{clientip}}{{/hostname}}
+ {{/machineuuid}}
+ </td>
+ <td>{{clientip}}</td>
<td>
+ {{#machineuuid}}
<span id="status-{{machineuuid}}" class="machineuuid" data-uuid="{{machineuuid}}"></span>
<span id="spinner-{{machineuuid}}" class="glyphicon glyphicon-refresh slx-rotation"></span>
- </td>
- {{/machineuuid}}
- {{^machineuuid}}
- <td></td>
{{/machineuuid}}
+ </td>
</tr>
{{/clients}}
</tbody>
@@ -48,7 +56,7 @@
<a class="text-muted" href="#debug-out" data-toggle="collapse">Debug</a>
<pre id="debug-out" class="collapse"></pre>
-<script><!--
+<script>
function wolCallback(task) {
if (task.statusCode === 'TASK_WAITING' || task.statusCode === 'TASK_PROCESSING') {
stillActive = 25;
@@ -71,4 +79,4 @@ function wolCallback(task) {
$do.text(txt);
}
}
-//--></script> \ No newline at end of file
+</script>
diff --git a/modules-available/rebootcontrol/templates/subnet-edit.html b/modules-available/rebootcontrol/templates/subnet-edit.html
index 5a6adf3c..570865c7 100644
--- a/modules-available/rebootcontrol/templates/subnet-edit.html
+++ b/modules-available/rebootcontrol/templates/subnet-edit.html
@@ -39,12 +39,14 @@
</div>
{{/jumpHosts}}
</div>
+ {{#showC2C}}
<div class="list-group-item">
<label>{{lang_reachableFrom}}</label>
{{#sourceNets}}
<div>{{cidr}}</div>
{{/sourceNets}}
</div>
+ {{/showC2C}}
</div>
<div class="panel-footer text-right">
<button type="submit" class="btn btn-danger" name="action" value="delete"
diff --git a/modules-available/rebootcontrol/templates/subnet-list.html b/modules-available/rebootcontrol/templates/subnet-list.html
index 8ecf66b4..2bc9208f 100644
--- a/modules-available/rebootcontrol/templates/subnet-list.html
+++ b/modules-available/rebootcontrol/templates/subnet-list.html
@@ -30,7 +30,7 @@
<td class="text-center">{{#fixed}}<span class="glyphicon glyphicon-lock"></span>{{/fixed}}</td>
<td class="text-center">{{#isdirect}}<span class="glyphicon glyphicon-ok"></span>{{/isdirect}}</td>
<td class="text-right"><span class="badge">{{jumphostcount}}</span> / <span class="badge">{{sourcecount}}</span></td>
- <td class="{{lastseen_class}}">{{lastseen_s}}</td>
+ <td class="{{lastseen_class}} text-nowrap">{{lastseen_s}}</td>
</tr>
{{/subnets}}
</tbody>
diff --git a/modules-available/rebootcontrol/templates/task-list.html b/modules-available/rebootcontrol/templates/task-list.html
index 5ab75675..dcb04450 100644
--- a/modules-available/rebootcontrol/templates/task-list.html
+++ b/modules-available/rebootcontrol/templates/task-list.html
@@ -2,6 +2,7 @@
<table class="table">
<thead>
<tr>
+ <th>{{lang_when}}</th>
<th>{{lang_task}}</th>
<th>{{lang_location}}</th>
<th>{{lang_clientCount}}</th>
@@ -12,6 +13,9 @@
{{#list}}
<tr>
<td class="text-nowrap">
+ {{timestamp_s}}
+ </td>
+ <td class="text-nowrap">
<a href="?do=rebootcontrol&amp;show=task&amp;what=task&amp;taskid={{id}}">{{type}}</a>
<div class="small">{{action}}</div>
</td>