diff options
Diffstat (limited to 'modules-available/rebootcontrol/inc')
4 files changed, 572 insertions, 270 deletions
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; |