<?php
class IPxe
{
/**
* Import all IP-Range based pxe menus from the given directory.
*
* @param string $configPath The pxelinux.cfg path where to look for menu files in hexadecimal IP format.
* @return Number of menus imported
*/
public static function importPxeMenus($configPath)
{
$importCount = 0;
$menus = [];
foreach (glob($configPath . '/*', GLOB_NOSORT) as $file) {
if (!is_file($file) || !preg_match('~/[A-F0-9]{1,8}$~', $file))
continue;
$content = file_get_contents($file);
if ($content === false)
continue;
$file = basename($file);
$start = hexdec(str_pad($file,8, '0'));
$end = hexdec(str_pad($file,8, 'f')); // TODO ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^PREFIX
error_log('From ' . long2ip($start) . ' to ' . long2ip($end));
$res = Database::simpleQuery("SELECT locationid, startaddr, endaddr FROM subnet
WHERE startaddr >= :start AND endaddr <= :end", compact('start', 'end'));
$locations = [];
while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
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);
$key = $menu->hash(true);
if (isset($menus[$key])) {
$menuId = $menus[$key];
$defId = null;
// Figure out the default label, get it's label name
foreach ($menu->sections as $section) {
if ($section->isDefault) {
$defId = $section;
} elseif ($defId === null && $section->label === $menu->timeoutLabel) {
$defId = $section;
}
}
if ($defId !== null) {
$defId = self::cleanLabelFixLocal($defId);
// 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' => $defId, 'menuid' => $menuId]);
if ($me === false || $me['defaultentryid'] == $me['menuentryid']) {
$defId = null; // Not found, or is already default - don't override if it's the same
} else {
$defId = $me['menuentryid'];
}
}
} else {
$menuId = self::insertMenu($menu, 'Imported', false, 0, [], []);
$menus[$key] = $menuId;
$defId = null;
$importCount++;
}
if ($menuId === false)
continue;
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($force = false)
{
if (!$force && false !== Database::queryFirst("SELECT entryid FROM serversetup_bootentry WHERE entryid = 'bwlp-default'"))
return false; // Already exists
// Now create the default entry
self::createDefaultEntries();
$prepend = ['bwlp-default' => false, 'localboot' => false];
$defaultLabel = 'bwlp-default';
$menuTitle = 'bwLehrpool Bootauswahl';
$pxeConfig = '';
$timeoutSecs = 60;
// Try to import any customization
$oldMenu = Property::getBootMenu();
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 = [
'',
'bwlp-default-dbg' => false,
'',
'poweroff' => false,
];
return self::insertMenu(PxeLinux::parsePxeLinux($pxeConfig), $menuTitle, $defaultLabel, $timeoutSecs, $prepend, $append);
}
/**
* @param PxeMenu $pxeMenu
* @param string $menuTitle
* @param string|false $defaultLabel
* @param $defaultTimeoutSeconds
* @param $prepend
* @param $append
* @return bool|int
*/
private static function insertMenu($pxeMenu, $menuTitle, $defaultLabel, $defaultTimeoutSeconds, $prepend, $append)
{
$timeoutMs = [];
$menuEntries = $prepend;
settype($menuEntries, 'array');
if (!empty($pxeMenu)) {
$pxe =& $pxeMenu;
if (!empty($pxe->title)) {
$menuTitle = $pxe->title;
}
if ($pxe->timeoutLabel !== null) {
$defaultLabel = $pxe->timeoutLabel;
}
$timeoutMs[] = $pxe->timeoutMs;
$timeoutMs[] = $pxe->totalTimeoutMs;
foreach ($pxe->sections as $section) {
if ($section->localBoot || preg_match('/chain\.c32$/i', $section->kernel)) {
$menuEntries['localboot'] = 'localboot';
continue;
}
if ($section->label === null) {
if (!$section->isHidden && !empty($section->title)) {
$menuEntries[] = $section->title;
}
continue;
}
if (empty($section->kernel)) {
if (!$section->isHidden && !empty($section->title)) {
$menuEntries[] = $section->title;
}
continue;
}
$entry = self::pxe2BootEntry($section);
if ($entry === null)
continue;
$label = self::cleanLabelFixLocal($section);
if ($defaultLabel === $section->label) {
$defaultLabel = $label;
}
$hotkey = MenuEntry::filterKeyName($section->hotkey);
// Create boot entry
$data = $entry->toArray();
Database::exec('INSERT IGNORE INTO serversetup_bootentry (entryid, hotkey, title, builtin, data)
VALUES (:label, :hotkey, :title, 0, :data)', [
'label' => $label,
'hotkey' => $hotkey,
'title' => self::sanitizeIpxeString($section->title),
'data' => json_encode($data),
]);
$menuEntries[$label] = $section;
}
}
if (is_array($append)) {
$menuEntries += $append;
}
if (empty($menuEntries))
return false;
// Make menu
$timeoutMs = array_filter($timeoutMs, 'is_int');
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();
if (!array_key_exists($defaultLabel, $menuEntries) && $timeoutMs > 0) {
$defaultLabel = array_keys($menuEntries)[0];
}
// Link boot entries to menu
$defaultEntryId = null;
$order = 1000;
foreach ($menuEntries as $label => $entry) {
if (is_string($entry)) {
// 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),
'hidden' => 0,
'sortval' => $order += 100,
]);
continue;
}
$data = Database::queryFirst("SELECT entryid, hotkey, title FROM serversetup_bootentry WHERE entryid = :entryid", ['entryid' => $label]);
if ($data === false)
continue;
$data['pass'] = '';
$data['hidden'] = 0;
if ($entry instanceof PxeSection) {
$data['hidden'] = (int)$entry->isHidden;
// Prefer explicit data from this imported menu over the defaults
$data['title'] = self::sanitizeIpxeString($entry->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 && $label === $defaultLabel) {
$defaultEntryId = Database::lastInsertId();
}
}
// Now we can set default entry
if (!empty($defaultEntryId)) {
Database::exec("UPDATE serversetup_menu SET defaultentryid = :entryid WHERE menuid = :menuid",
['menuid' => $menuId, 'entryid' => $defaultEntryId]);
}
// TODO: masterpw? rather pointless....
//$oldMenu['masterpasswordclear'];
return $menuId;
}
private static function createDefaultEntries()
{
$query = 'INSERT IGNORE INTO serversetup_bootentry (entryid, hotkey, title, builtin, data)
VALUES (:entryid, :hotkey, :title, 1, :data)';
Database::exec($query,
[
'entryid' => 'bwlp-default',
'hotkey' => 'B',
'title' => 'bwLehrpool-Umgebung starten',
'data' => json_encode([
'executable' => '/boot/default/kernel',
'initRd' => '/boot/default/initramfs-stage31',
'commandLine' => 'slxbase=boot/default quiet splash loglevel=5 rd.systemd.show_status=auto intel_iommu=igfx_off ${ipappend1} ${ipappend2}',
'replace' => true,
'autoUnload' => true,
'resetConsole' => true,
]),
]);
Database::exec($query,
[
'entryid' => 'bwlp-default-dbg',
'hotkey' => '',
'title' => 'bwLehrpool-Umgebung starten (nosplash, debug)',
'data' => json_encode([
'executable' => '/boot/default/kernel',
'initRd' => '/boot/default/initramfs-stage31',
'commandLine' => 'slxbase=boot/default loglevel=7 intel_iommu=igfx_off ${ipappend1} ${ipappend2}',
'replace' => true,
'autoUnload' => true,
'resetConsole' => true,
]),
]);
Database::exec($query,
[
'entryid' => 'localboot',
'hotkey' => 'L',
'title' => 'Lokales System starten',
'data' => json_encode([
'script' => 'goto slx_localboot || goto %fail% ||',
]),
]);
Database::exec($query,
[
'entryid' => 'poweroff',
'hotkey' => 'P',
'title' => 'Power off',
'data' => json_encode([
'script' => 'poweroff || goto %fail% ||',
]),
]);
Database::exec($query,
[
'entryid' => 'reboot',
'hotkey' => 'R',
'title' => 'Reboot',
'data' => json_encode([
'script' => 'reboot || goto %fail% ||',
]),
]);
}
/**
* Create unique label for a boot entry. It will try to figure out whether
* this is one of our default entries and if not, create a unique label
* representing the menu entry contents.
* Also it patches the entry if it's referencing the local bwlp install
* because side effects.
*
* @param PxeSection $section
* @return string
*/
private static function cleanLabelFixLocal($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, fall through to generic label gen
$section->kernel = '/boot/default/kernel';
$section->initrd = ['/boot/default/initramfs-stage31'];
}
}
// Generic -- "smart" hash of kernel, initrd and command line
$str = $section->kernel . ' ' . implode(',', $section->initrd);
$array = preg_split('/\s+/', $section->append, -1, PREG_SPLIT_NO_EMPTY);
sort($array);
$str .= ' ' . implode(' ', $array);
return 'i-' . substr(md5($str), 0, 12);
}
/**
* @param PxeSection $section
* @return BootEntry|null The according boot entry, null if it's unparsable
*/
private static function pxe2BootEntry($section)
{
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 209:string {$args[$i]} || goto %fail%\n";
} elseif ($arg === '-p') { // PXELINUX prefix path option
++$i;
$script .= "set 210:string {$args[$i]} || goto %fail%\n";
} elseif ($arg === '-t') { // PXELINUX timeout option
++$i;
$script .= "set 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 {$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 next-server {$url['host']} || goto %fail%\n";
}
if (isset($url['path'])) {
$script .= "set filename {$url['path']} || goto %fail%\n";
}
$script .= "chain -ar {$file} || goto %fail%\n";
return new CustomBootEntry(['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.
*
* @param string $file
* @return string
*/
private static function parseFile($file)
{
if (preg_match(',^([^:/]+)::(.*)$,', $file, $out)) {
return 'tftp://' . $out[1] . '/' . $out[2];
}
return $file;
}
public static function sanitizeIpxeString($string)
{
return str_replace(['&', '|', ';', '$', "\r", "\n"], ['+', '/', ':', 'S', ' ', ' '], $string);
}
public static function makeMd5Pass($plainpass, $salt)
{
if (empty($plainpass))
return '';
return md5(md5($plainpass) . '-' . $salt);
}
}