false, 'sd' => false, 'wol-offset' => 0, 'sd-offset' => 0, 'ra-mode' => self::RA_ALWAYS]; /** * @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", ['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; } /** * 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) { foreach ($row['days'] as $day) { 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'])]; } } } $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; } 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; } /** * 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 { // 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; } }