= :start AND endaddr <= :end", compact('start', 'end')); $locations = []; // Iterate over result, eliminate those that are dominated by others foreach ($res as $row) { foreach ($locations as &$loc) { if ($row['startaddr'] <= $loc['startaddr'] && $row['endaddr'] >= $loc['endaddr']) { $loc = false; } elseif ($row['startaddr'] >= $loc['startaddr'] && $row['endaddr'] <= $loc['endaddr']) { continue 2; } } unset($loc); $locations[] = $row; } $menu = PxeLinux::parsePxeLinux($content, true); if ($menu === null) { error_log("Skipping empty pxelinux menu file $file"); continue; } // Insert all entries first, so we can get the list of entry IDs $entries = []; self::importPxeMenuEntries($menu, $entries); $entries = array_keys($entries); $defId = null; // Look up entry IDs, if match, ref for this location if (($menuId = array_search($entries, $menus)) !== false) { error_log('Imported menu ' . $menu->title . ' exists, using for ' . count($locations) . ' locations.'); // Figure out the default label, get its label name $defSection = null; foreach ($menu->sections as $section) { if ($section->isDefault) { $defSection = $section; } elseif ($defSection === null && $section->label === $menu->timeoutLabel) { $defSection = $section; } } if ($defSection !== null && ($defIdEntry = array_search(self::pxe2BootEntry($defSection), self::$allEntries)) !== false) { // Confirm it actually exists (it should since the menu seems identical) and get menuEntryId $me = Database::queryFirst('SELECT m.defaultentryid, me.menuentryid FROM serversetup_bootentry be INNER JOIN serversetup_menuentry me ON (be.entryid = me.entryid) INNER JOIN serversetup_menu m ON (m.menuid = me.menuid) WHERE be.entryid = :id AND me.menuid = :menuid', ['id' => $defIdEntry, 'menuid' => $menuId]); if ($me !== false && $me['defaultentryid'] != $me['menuentryid']) { $defId = $me['menuentryid']; } } } else { error_log('Imported menu ' . $menu->title . ' is NEW, using for ' . count($locations) . ' locations.'); // Insert new menu $menuId = self::insertMenu($menu, 'Auto Imported', null, 0, [], []); if ($menuId === null) continue; $menus[$menuId] = $entries; $importCount++; } foreach ($locations as $loc) { if ($loc === false) continue; Database::exec('INSERT IGNORE INTO serversetup_menu_location (menuid, locationid, defaultentryid) VALUES (:menuid, :locationid, :def)', [ 'menuid' => $menuId, 'locationid' => $loc['locationid'], 'def' => $defId, ]); } } return $importCount; } public static function importLegacyMenu(bool $force = false): bool { // See if anything is there if (!$force && false !== Database::queryFirst("SELECT menuentryid FROM serversetup_menuentry LIMIT 1")) return false; // Already exists // Now create the default entry self::createDefaultEntries(); $prepend = ['bwlp-default' => null, 'localboot' => null]; $defaultLabel = 'bwlp-default'; $menuTitle = 'bwLehrpool Bootauswahl'; $pxeConfig = ''; $timeoutSecs = 60; // Try to import any customization of the legacy PXELinux menu (despite the property name hinting at iPXE) $oldMenu = json_decode(Property::get('ipxe-menu'), true); if (is_array($oldMenu)) { // if (isset($oldMenu['timeout'])) { $timeoutSecs = (int)$oldMenu['timeout']; } if (isset($oldMenu['defaultentry'])) { if ($oldMenu['defaultentry'] === 'net') { $defaultLabel = 'bwlp-default'; } elseif ($oldMenu['defaultentry'] === 'hdd') { $defaultLabel = 'localboot'; } elseif ($oldMenu['defaultentry'] === 'custom') { $defaultLabel = 'custom'; } } if (!empty($oldMenu['custom'])) { $pxeConfig = $oldMenu['custom']; } } $append = [ new PxeSection(null), 'bwlp-default-dbg' => null, new PxeSection(null), 'poweroff' => null, ]; self::insertMenu(PxeLinux::parsePxeLinux($pxeConfig, false), $menuTitle, $defaultLabel, $timeoutSecs, $prepend, $append); return !empty($pxeConfig); } /** * @param ?string $defaultLabel Fallback for the default label, if PxeMenu doesn't set one * @param int $defaultTimeoutSeconds Default timeout, if PxeMenu doesn't set one * @param (?PxeSection)[] $prepend * @param (?PxeSection)[] $append * @return ?int ID of newly created menu, or null on error, e.g. if the menu is empty */ public static function insertMenu(?PxeMenu $pxeMenu, string $menuTitle, ?string $defaultLabel, int $defaultTimeoutSeconds, array $prepend, array $append): ?int { $timeoutMs = []; $menuEntries = $prepend; if ($pxeMenu !== null) { if (!empty($pxeMenu->title)) { $menuTitle = $pxeMenu->title; } if ($pxeMenu->timeoutLabel !== null && $pxeMenu->hasLabel($pxeMenu->timeoutLabel)) { $defaultLabel = $pxeMenu->timeoutLabel; } elseif ($pxeMenu->hasLabel($pxeMenu->default)) { $defaultLabel = $pxeMenu->default; } $timeoutMs[] = $pxeMenu->timeoutMs; $timeoutMs[] = $pxeMenu->totalTimeoutMs; self::importPxeMenuEntries($pxeMenu, $menuEntries); } if (!empty($append)) { $menuEntries += $append; } if (empty($menuEntries)) return null; // Make menu $timeoutMs = array_filter($timeoutMs, function($x) { return is_int($x) && $x > 0; }); if (empty($timeoutMs)) { $timeoutMs = (int)($defaultTimeoutSeconds * 1000); } else { $timeoutMs = min($timeoutMs); } $isDefault = (int)(Database::queryFirst('SELECT menuid FROM serversetup_menu WHERE isdefault = 1') === false); Database::exec("INSERT INTO serversetup_menu (timeoutms, title, defaultentryid, isdefault) VALUES (:timeoutms, :title, NULL, :isdefault)", [ 'title' => self::sanitizeIpxeString($menuTitle), 'timeoutms' => $timeoutMs, 'isdefault' => $isDefault, ]); $menuId = Database::lastInsertId(); // Figure out entryid for default label // Fiddly diddly way of getting the mangled entryid for the wanted pxe menu label $defaultEntryId = false; $fallbackDefault = false; foreach ($menuEntries as $entryId => $section) { if ($section === null) continue; if ($section->isDefault) { $defaultEntryId = $entryId; break; } if ($section->label === $defaultLabel) { $defaultEntryId = $entryId; } if ($fallbackDefault === false && !empty($entryId)) { $fallbackDefault = $entryId; } } if ($defaultEntryId === false) { $defaultEntryId = $fallbackDefault; } // Link boot entries to menu $defaultMenuEntryId = null; $order = 1000; foreach ($menuEntries as $entryId => $entry) { if ($entry !== null && $entry->isTextOnly()) { // Gap entry Database::exec("INSERT INTO serversetup_menuentry (menuid, entryid, hotkey, title, hidden, sortval, plainpass, md5pass) VALUES (:menuid, :entryid, :hotkey, :title, :hidden, :sortval, '', '')", [ 'menuid' => $menuId, 'entryid' => null, 'hotkey' => '', 'title' => self::sanitizeIpxeString($entry->title), 'hidden' => 0, 'sortval' => $order += 100, ]); continue; } $data = Database::queryFirst("SELECT entryid, hotkey, title FROM serversetup_bootentry WHERE entryid = :entryid", ['entryid' => $entryId]); if ($data === false) continue; $data['pass'] = ''; $data['hidden'] = 0; if ($entry !== null) { $data['hidden'] = (int)$entry->isHidden; // Prefer explicit data from this imported menu over the defaults $title = self::sanitizeIpxeString($entry->title); if (!empty($title)) { $data['title'] = $title; } if (MenuEntry::getKeyCode($entry->hotkey) !== false) { $data['hotkey'] = $entry->hotkey; } if (!empty($entry->passwd)) { // Most likely it's a hash, so we cannot recover; ask people to reset $data['pass'] ='please_reset'; } } $data['menuid'] = $menuId; $data['sortval'] = $order += 100; $res = Database::exec("INSERT INTO serversetup_menuentry (menuid, entryid, hotkey, title, hidden, sortval, plainpass, md5pass) VALUES (:menuid, :entryid, :hotkey, :title, :hidden, :sortval, :pass, :pass)", $data); if ($res !== false && $entryId === $defaultEntryId) { $defaultMenuEntryId = Database::lastInsertId(); } } // Now we can set default entry if (!empty($defaultMenuEntryId)) { Database::exec("UPDATE serversetup_menu SET defaultentryid = :menuentryid WHERE menuid = :menuid", ['menuid' => $menuId, 'menuentryid' => $defaultMenuEntryId]); } // TODO: masterpw? rather pointless.... //$oldMenu['masterpasswordclear']; return $menuId; } /** * Import only the bootentries from the given PXELinux menu * * @param PxeSection[] $menuEntries Where to append the generated menu items to */ public static function importPxeMenuEntries(PxeMenu $pxe, array &$menuEntries): void { if (self::$allEntries === false) { self::$allEntries = BootEntry::getAll(); } foreach ($pxe->sections as $section) { if ($section->isLocalboot()) { $menuEntries['localboot'] = $section; continue; } if ($section->isTextOnly()) { $menuEntries[] = $section; continue; } $label = self::cleanLabelFixLocal($section); $entry = self::pxe2BootEntry($section); if ($entry === null) continue; // Error? Ignore if ($label !== false || ($label = array_search($entry, self::$allEntries)) !== false) { // Exact Duplicate, Do Nothing error_log('Ignoring duplicate boot entry ' . $section->label . ' (' . $section->kernel . ')'); } else { // Seems new one; make sure label doesn't collide error_log('Adding new boot entry ' . $section->label . ' (' . $section->kernel . ')'); $label = substr(preg_replace('/[^a-z0-9_\-]/', '', strtolower($section->label)), 0, 16); while (empty($label) || array_key_exists($label, self::$allEntries)) { $label = 'i-' . substr(md5(microtime(true) . $section->kernel . mt_rand()), 0, 14); } self::$allEntries[$label] = $entry; $hotkey = MenuEntry::filterKeyName($section->hotkey); // Create boot entry $data = $entry->toArray(); $title = self::sanitizeIpxeString($section->title); if (empty($title)) { $title = self::sanitizeIpxeString($section->label); } if (empty($title)) { $title = $label; } Database::exec('INSERT IGNORE INTO serversetup_bootentry (entryid, module, hotkey, title, builtin, data) VALUES (:label, :module, :hotkey, :title, 0, :data)', [ 'label' => $label, 'module' => ($entry instanceof StandardBootEntry) ? '.exec' : '.script', 'hotkey' => $hotkey, 'title' => $title, 'data' => json_encode($data), ]); } $menuEntries[$label] = $section; } } public static function createDefaultEntries() { $query = 'INSERT IGNORE INTO serversetup_bootentry (entryid, hotkey, title, builtin, module, data) VALUES (:entryid, :hotkey, :title, 1, :module, :data) ON DUPLICATE KEY UPDATE builtin = 1, module = VALUES(module), data = VALUES(data)'; Database::exec($query, [ 'entryid' => 'bwlp-default', 'hotkey' => 'B', 'title' => 'bwLehrpool-Umgebung starten', 'module' => 'minilinux', 'data' => json_encode([ 'id' => 'default', 'kcl-extra' => '', 'debug' => false, ]), ]); Database::exec($query, [ 'entryid' => 'bwlp-default-dbg', 'hotkey' => 'D', 'title' => 'bwLehrpool-Umgebung starten (nosplash, debug output)', 'module' => 'minilinux', 'data' => json_encode([ 'id' => 'default', 'kcl-extra' => '', 'debug' => true, ]), ]); Database::exec($query, [ 'entryid' => 'bwlp-default-sh', 'hotkey' => 'S', 'title' => 'bwLehrpool-Umgebung starten (nosplash, !!! debug shell !!!)', 'module' => 'minilinux', 'data' => json_encode([ 'id' => 'default', 'kcl-extra' => 'debug=1', 'debug' => true, ]), ]); Database::exec($query, [ 'entryid' => 'localboot', 'hotkey' => 'L', 'title' => 'Lokales System starten', 'module' => '.special', 'data' => json_encode(['type' => 'localboot']), ]); Database::exec($query, [ 'entryid' => 'poweroff', 'hotkey' => 'P', 'title' => 'Power off', 'module' => '.script', 'data' => json_encode([ 'script' => 'poweroff || goto fail ||', ]), ]); Database::exec($query, [ 'entryid' => 'reboot', 'hotkey' => 'R', 'title' => 'Reboot', 'module' => '.script', 'data' => json_encode([ 'script' => 'reboot || goto fail ||', ]), ]); } /** * Try to figure out whether this is one of our default entries and returns * that according label. * Also it patches the entry if it's referencing the local bwlp install * but with different options. * * @return string|false existing label if match, false otherwise */ private static function cleanLabelFixLocal(PxeSection $section) { $myip = Property::getServerIp(); // Detect our "old" entry types if (count($section->initrd) === 1 && preg_match(",$myip/boot/default/kernel\$,", $section->kernel) && preg_match(",$myip/boot/default/initramfs-stage31\$,", $section->initrd[0])) { // Kernel and initrd match, examine KCL if ($section->append === 'slxbase=boot/default vga=current quiet splash') { // Normal return 'bwlp-default'; } elseif ($section->append === 'slxbase=boot/default') { // Debug output return 'bwlp-default-dbg'; } else { // Transform to relative URL, leave KCL $section->kernel = '/boot/default/kernel'; $section->initrd = ['/boot/default/initramfs-stage31']; } } return false; } /** * @return BootEntry|null The according boot entry, null if it's unparsable */ private static function pxe2BootEntry(PxeSection $section): ?BootEntry { if (preg_match('/(pxechain\.com|pxechn\.c32)$/i', $section->kernel)) { // Chaining -- create script $args = preg_split('/\s+/', $section->append); $script = ''; $file = false; for ($i = 0; $i < count($args); ++$i) { $arg = $args[$i]; if ($arg === '-c') { // PXELINUX config file option ++$i; $script .= "set netX/209:string {$args[$i]} || goto %fail%\n"; } elseif ($arg === '-p') { // PXELINUX prefix path option ++$i; $script .= "set netX/210:string {$args[$i]} || goto %fail%\n"; } elseif ($arg === '-t') { // PXELINUX timeout option ++$i; $script .= "set netX/211:int32 {$args[$i]} || goto %fail%\n"; } elseif ($arg === '-o') { // Overriding various DHCP options ++$i; if (preg_match('/^((?:0x)?[a-f0-9]{1,4})\.([bwlsh])=(.*)$/i', $args[$i], $out)) { // TODO: 'q' (8byte) unsupported for now $opt = intval($out[1], 0); if ($opt > 0 && $opt < 255) { static $optType = ['b' => 'uint8', 'w' => 'uint16', 'l' => 'int32', 's' => 'string', 'h' => 'hex']; $type = $optType[$out[2]]; $script .= "set netX/{$opt}:{$type} {$args[$i]} || goto %fail%\n"; } } } elseif ($arg[0] === '-') { continue; } elseif ($file === false) { $file = self::parseFile($arg); } } if ($file !== false) { $url = parse_url($file); if (isset($url['host'])) { $script .= "set netX/next-server {$url['host']} || goto %fail%\n"; } if (isset($url['path'])) { $script .= "set netX/filename {$url['path']} || goto %fail%\n"; } $script .= "chain -ar {$file} || goto %fail%\n"; return BootEntry::newCustomBootEntry(['script' => $script]); } return null; } // "Normal" entry that should be convertible into a StandardBootEntry $section->kernel = self::parseFile($section->kernel); foreach ($section->initrd as &$initrd) { $initrd = self::parseFile($initrd); } return BootEntry::newStandardBootEntry($section); } /** * Parse PXELINUX file notion. Basically, turn * server::file into tftp://server/file. */ private static function parseFile(string $file): string { if (preg_match(',^([^:/]+)::(.*)$,', $file, $out)) { return 'tftp://' . $out[1] . '/' . $out[2]; } return $file; } public static function sanitizeIpxeString(string $string): string { return str_replace(['&', '|', ';', '$', "\r", "\n"], ['+', '/', ':', 'S', ' ', ' '], $string); } public static function makeMd5Pass(string $plainpass, string $salt): string { if (empty($plainpass)) return ''; return md5(md5($plainpass) . '-' . $salt); } /** * Modify a kernel command line. Add or remove items to a given command line. The $modifier * string is a list of space separated arguments. If the argument starts with a '-', all * according occurrences of the given option will be removed from the command line. It is assumed * that options are either of format "option" or "option=value", so a modifier of "-option" will * remove any occurrence of either "option" or "option=something". If the argument starts with a * '+', it will be added to the command line after removing the '+'. If the argument starts with any * other character, it will also be added to the command line. * * @param string $cmdLine command line to modify * @param string $modifier modification string of space separated arguments * @return string the modified command line */ public static function modifyCommandLine(string $cmdLine, string $modifier): string { $items = preg_split('/\s+/', $modifier, -1, PREG_SPLIT_NO_EMPTY); foreach ($items as $item) { if ($item[0] === '-') { $item = preg_quote(substr($item, 1), '/'); $cmdLine = preg_replace('/(^|\s)' . $item . '(=\S*)?($|\s)/', ' ', $cmdLine); } else { $cmdLine .= ' ' . $item; } } return $cmdLine; } }