diff options
Diffstat (limited to 'modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderipxe.inc.php')
-rw-r--r-- | modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderipxe.inc.php | 478 |
1 files changed, 478 insertions, 0 deletions
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderipxe.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderipxe.inc.php new file mode 100644 index 00000000..9421684f --- /dev/null +++ b/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderipxe.inc.php @@ -0,0 +1,478 @@ +<?php + +class ScriptBuilderIpxe extends ScriptBuilderBase +{ + + private function getUrlBase(): string + { + if (isset($_SERVER['REQUEST_URI'])) { + $url = parse_url($_SERVER['REQUEST_URI']); + if (isset($_SERVER['SCRIPT_URI']) && preg_match('#^(\w+://[^/]+)#', $_SERVER['SCRIPT_URI'], $out)) { + $urlbase = $out[1]; + } elseif (isset($_SERVER['REQUEST_SCHEME']) && isset($_SERVER['SERVER_NAME'])) { + $urlbase = $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['SERVER_NAME']; + } elseif (isset($_SERVER['REQUEST_SCHEME']) && isset($_SERVER['SERVER_ADDR'])) { + $urlbase = $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['SERVER_ADDR']; + } else { + $urlbase = 'http://' . $this->serverIp; + } + return $urlbase . $url['path']; + } + // Static fallback + return 'http://' . $this->serverIp . '/boot/ipxe'; + + } + + private function getUrlFull(?bool &$hasExt = null, ?string $key = null, ?string $value = null): string + { + $url = parse_url($_SERVER['REQUEST_URI']); + $urlbase = $this->getUrlBase(); + if (empty($url['query'])) { + $fromQuery = []; + } else { + parse_str($url['query'], $fromQuery); + foreach ($fromQuery as &$v) { + $v = urlencode($v); + } + unset($v); + } + unset($fromQuery['entryid'], $fromQuery['special']); + if ($key !== null) { + $fromQuery[$key] = $value; + } + $hasExt = isset($fromQuery['slx-extensions']); + $required = [ + 'uuid' => '${uuid}', + 'mac' => '${mac}', + 'manuf' => '${manufacturer:uristring}', + 'product' => '${product:uristring}', + 'platform' => '${platform:uristring}', + ]; + $fullQuery = '?'; + foreach ($required + $fromQuery as $k => $v) { // Loop instead of http_build_query since we don't want escaping for the varnames! + $fullQuery .= $k . '=' . $v . '&'; + } + return $urlbase . $fullQuery; + } + + /** + * Redirect to same URL, but add our extended params + */ + private function redirect(string $key = null, string $value = null): string + { + // Redirect to self with added parameters + $urlfull = $this->getUrlFull($hasExt, $key, $value); + if ($hasExt) { + $output = "#!ipxe\nset self {$urlfull} ||\n"; + } else { + $output = <<<HERE +#!ipxe +set slxtest:string something || +iseq \${slxtest:md5} \${} && set slxext 0 || set slxext 1 || +clear slxtest || +set self {$urlfull}slx-extensions=\${slxext} || + +HERE; + } + $output .= <<<HERE +:retry +echo Chaining to \${self} +chain -ar \${self} || +echo Chaining to self failed with \${errno}, retrying in a bit... +sleep 5 +goto retry + +HERE; + return $output; + } + + /** + * Called when we handle a real client request, and don't just generate static data + * for whatever use-case that might have. In the latter case, it wouldn't make much sense + * to generate a redirect code snippet. + * @return string + */ + public function bootstrapLive() + { + // Check if required arguments are given; if not, spit out according script and chain to self + if ($this->uuid === false || $this->platform === '') { + // REQUIRED so we can hide incompatible entries + return $this->redirect(); + } + return false; + } + + public function getBootEntry(?BootEntry $entry): string + { + if ($entry === null) { + return "#!ipxe\nprompt --timeout 5000 Invalid boot entry id\n"; + } + return $entry->toScript($this); + } + + public function getMenu(IPxeMenu $menu, bool $bootstrap): string + { + if ($bootstrap) { + return "#!ipxe\nimgfree ||\n" . $this->menuToScript($menu); + } + $base = $this->getUrlFull(); + return "#!ipxe\nset self {$base} ||\n" . $this->menuToScript($menu); + } + + public function menuToScript(IPxeMenu $menu): string + { + if ($this->hasExtension) { + $slxConsoleUpdate = '--update'; + } else { + $slxConsoleUpdate = ''; + } + + $output = <<<HERE +:start + +imgstat bg-menu || imgfetch --name bg-menu /tftp/pxe-menu.png || +console --left 55 --top 88 --right 63 --bottom 64 --keep --picture bg-menu || + +colour --rgb 0xffffff 7 || +colour --rgb 0xcccccc 5 || +colour --rgb 0x000000 0 || +colour --rgb 0xdddddd 6 || +cpair --foreground 0 --background 4 1 || +cpair --foreground 0 --background 5 2 || +cpair --foreground 7 --background 9 0 || + +:slx_menu + +console --left 55 --top 88 --right 63 --bottom 64 $slxConsoleUpdate --keep --picture bg-menu || + +menu -- {$menu->title} || prompt --timeout 5000 Error creating menu || + +HERE; + foreach ($menu->items as $item) { + $output .= $this->getMenuItemScript($menu->defaultEntryId, $item); + } + if ($menu->defaultEntryId === null) { + $default = "poweroff || exit 1 ||"; + } else { + $default = "chain -a \${self}&entryid={$menu->defaultEntryId} ||"; + } + $output .= "choose"; + if ($menu->timeoutMs > 0) { + $output .= " --timeout {$menu->timeoutMs}"; + } + $output .= " selection || goto default || goto fail\n"; + $output .= <<<HERE +console --left 60 --top 130 --right 67 --bottom 86 $slxConsoleUpdate || +set slx_exit \${} || +chain -a \${self}&entryid=\${selection} || +iseq \${slx_exit} \${} || console || +iseq \${slx_exit} \${} || echo Exiting with code \${slx_exit} || +iseq \${slx_exit} \${} || exit \${slx_exit} +goto fail || goto start +goto \${target} || +echo Could not find menu entry in script. +prompt Press any key to continue. +goto start +:default +$default +:fail +prompt Boot failed. Press any key to start. +goto start + +HERE; + return $output; + } + + private function getMenuItemScript(int $requestedDefaultId, MenuEntry $entry): string + { + $str = 'item '; + if ($entry->gap) { + $str .= '--gap -- '; + } else { + if ($entry->bootEntry === null || (!empty($this->platform) && !$entry->bootEntry->supportsMode($this->platform))) + return ''; + if ($entry->hidden && $this->hasExtension) { + if ($entry->hotkey === false) + return ''; // Hidden entries without hotkey are illegal + $str .= '--hidden '; + } + if ($entry->hotkey !== false) { + $str .= '--key ' . $entry->hotkey . ' '; + } + if ($entry->menuentryid == $requestedDefaultId) { + $str .= '--default '; + } + $str .= "-- {$entry->menuentryid} "; + } + if (empty($entry->title)) { + $str .= '${}'; + } else { + $str .= $entry->title; + } + return $str . " || prompt Could not create menu item for {$entry->menuentryid}\n"; + } + + public function getSpecial(string $special): string + { + if ($special === 'localboot') { + // Get preferred localboot method, depending on system model + // Check if required arguments are given; if not, spit out according script and chain to self + // Get platform - EFI or PCBIOS + $manuf = Request::any('manuf', false, 'string'); + $product = Request::any('product', false, 'string'); + if ($this->uuid === false && $manuf === false && $product === false) { + return $this->redirect('special', 'localboot'); + } + $BOOT_METHODS = Localboot::BOOT_METHODS[$this->platform]; + $localboot = false; + $model = false; + if ($this->uuid !== false && Module::get('statistics') !== false) { + // If we have the machine table, we rather try to look up the system model from there, using the UUID + $row = Database::queryFirst('SELECT systemmodel FROM machine WHERE machineuuid = :uuid', ['uuid' => $this->uuid]); + if ($row !== false && !empty($row['systemmodel'])) { + $model = $row['systemmodel']; + } + } + if ($model === false) { + // Otherwise use what iPXE sent us + $manuf = $this->modfilt($manuf); + $product = $this->modfilt($product); + if (!empty($product)) { + $model = $product; + if (!empty($manuf)) { + $model .= " ($manuf)"; + } + $model = Util::ansiToUtf8($model); + } + } + // Query + if ($model !== false) { + $e = strtolower($this->platform); // We made sure $this->platform is either PCBIOS or EFI, so no injection possible + $row = Database::queryFirst("SELECT $e AS bootmethod FROM serversetup_localboot WHERE systemmodel = :model LIMIT 1", + ['model' => $model]); + if ($row !== false) { + $localboot = $row['bootmethod']; + } + } + if ($localboot === false || !isset($BOOT_METHODS[$localboot])) { + $localboot = Localboot::getDefault()[$this->platform]; + if (!isset($BOOT_METHODS[$localboot])) { + $localboot = array_keys($BOOT_METHODS)[0]; + } + } + // Convert to actual ipxe code + $localboot = $BOOT_METHODS[$localboot] ?? 'prompt Localboot not possible'; + $output = <<<BLA +imgfree || +console || +$localboot || goto fail + +BLA; + // + } else { + $output = "prompt --timeout 5000 Unknown special command '$special' ||\nchain -ar \${self}\n"; + } + return $output; + } + + public function output(string $string): void + { + // iPXE introduced UTF-8 support at some point in 2022, and now expects all text/script files to be + // encoded as such. Since we still offer to use older versions, we need to detect that here and handle + // all non-ASCII chars differently. + // Use 'ipxe.compile-time' instead of const from IpxeBuilder to avoid pulling in another include + if (!preg_match('/Version: (\d{4})-\d{2}-\d{2}\b/', Property::get('ipxe.compile-time'), $out) + || (int)$out[1] >= 2022) { + Header('Content-Type: text/plain; charset=UTF-8'); + echo $string; + } else { + if ($this->platform === 'EFI') { + $cs = 'ASCII'; + } else { + $cs = 'IBM437'; + } + Header('Content-Type: text/plain; charset=' . $cs); + + setlocale(LC_ALL, 'de_DE.UTF-8', 'de_DE.utf-8', 'de_DE.utf8', 'de_DE', 'de', 'German', 'ge', 'en_US.UTF-8', 'en_US.utf-8'); + echo iconv('UTF-8', $cs . '//TRANSLIT//IGNORE', $string); + } + } + + public function modfilt($str) + { + if (empty($str) || preg_match('/product\s+name|be\s+filled|unknown|default\s+string|system\s+model|manufacturer/i', $str)) + return false; + return trim(preg_replace('/\s+/', ' ', $str)); + } + + const PROP_PW_SALT = 'ipxe.salt.'; + + private function passwordDialog(MenuEntry $entry): string + { + if ($this->hasExtension) { + $salt = dechex(mt_rand(0x100000, 0xFFFFFF)); + Property::addToList(self::PROP_PW_SALT . $this->clientIp, $salt, 5); + return <<<HERE +set password \${} || +login --nouser || +set password \${password:md5}-{$entry->menuentryid} +set password \${password:md5}$salt +params +param pwhash \${password:md5} +chain -a \${self}&entryid={$entry->menuentryid}##params || goto fail || + +HERE; + } + return <<<HERE +set username PASSWORD ONLY || +login || +params +param pwplain \${password} +chain -a \${self}&entryid={$entry->menuentryid}##params || goto fail || + +HERE; + } + + public function getMenuEntry(?MenuEntry $entry, bool $honorPassword = true): string + { + if ($entry === null) + return "#!ipxe\nprompt --timeout 10000 Invalid menu entry id\n"; + $base = $this->getUrlBase(); + $meid = $entry->menuEntryId(); + // Make sure legacy variables are set; they might get used + $output = <<<HERE +#!ipxe +set ipappend1 ip=\${ip}:{$this->serverIp}:\${gateway}:\${netmask} +set ipappend2 BOOTIF=01-\${mac:hexhyp} +set serverip {$this->serverIp} || +iseq \${idx} \${} && set idx:string X || +iseq \${self} \${} && set self {$base}? || +set menuentryid $meid || + +HERE; + // Check for password + if ($honorPassword && !empty($entry->md5pass)) { + $pwh = Request::post('pwhash', false, 'string'); + $pwp = Request::post('pwplain', false, 'string'); + if ($pwh === false && $pwp === false) { + return $output . $this->passwordDialog($entry); + } + $ok = false; + if ($pwh !== false) { + $list = Property::getList(self::PROP_PW_SALT . $this->clientIp); + foreach ($list as $salt) { + if ($pwh === md5($entry->md5pass . $salt)) { + $ok = true; + break; + } + } + } + if (!$ok && $pwp !== false && !empty($entry->plainpass)) { + $ok = ($pwp === $entry->plainpass); + } + if (!$ok) { + return $output . "prompt --timeout 10000 Wrong password ||\n"; + } + } + // Output actual entry + $output .= str_replace('%fail%', 'fail', $entry->getBootEntryScript($this)); + $output .= <<<HERE + +goto end +:fail +prompt --timeout 5000 Error launching selected boot entry || +:end + +HERE; + return $output; + } + + public function execDataToScript(?ExecData $agnostic, ?ExecData $bios, ?ExecData $efi) : string + { + if ($agnostic !== null) + return $this->execDataToScriptInternal($agnostic) . "\ngoto fail\n"; + + if (empty($this->platform)) { + // output dynamic code that decides client-side + $biosLabel = $this->getLabel(); + $output = 'iseq ${platform} efi || goto ' . $biosLabel . "\n"; + // EFI + if ($efi !== null) { + $output .= $this->execDataToScriptInternal($efi) . "\n"; + } else { + $output .= "echo EFI not supported\n"; + } + $output .= "goto fail\n" + . ':' . $biosLabel . "\n"; + if ($bios !== null) { + $output .= $this->execDataToScriptInternal($bios) . "\n"; + } else { + $output .= "echo BIOS not supported\n"; + } + return $output . "goto fail\n"; + } + // static, we know in advance + if ($efi !== null && $this->platform === BootEntry::EFI) + return $this->execDataToScriptInternal($efi) . "\ngoto fail\n"; + // Should be BIOS at this point + return $this->execDataToScriptInternal($bios ?? $efi ?? new ExecData()) . "\ngoto fail\n"; + } + + private function execDataToScriptInternal(ExecData $entry) : string + { + $entry->sanitize(); + $script = ''; + if ($entry->resetConsole) { + $script .= "console ||\n"; + } + if ($entry->imageFree) { + $script .= "imgfree ||\n"; + } + foreach ($entry->dhcpOptions as $opt) { + if (empty($opt['value'])) { + $val = '${}'; + } else { + if (empty($opt['hex'])) { + $val = bin2hex($opt['value']); + } else { + $val = $opt['value']; + } + preg_match_all('/[0-9a-f]{2}/', $val, $out); + $val = implode(':', $out[0]); + } + $script .= 'set net${idx}/' . $opt['opt'] . ':hex ' . $val + . ' || prompt Cannot override DHCP server option ' . $opt['opt'] . ". Press any key to continue anyways.\n"; + } + $initrds = []; + if (!empty($entry->initRd)) { + foreach (array_values($entry->initRd) as $i => $initrd) { + if (empty($initrd)) + continue; + $script .= "initrd --name initrd$i $initrd || goto fail\n"; + $initrds[] = "initrd$i"; + } + } + $script .= "boot "; + if ($entry->autoUnload) { + $script .= "-a "; + } + if ($entry->replace) { + $script .= "-r "; + } + $script .= $entry->executable; + if (!empty($initrds)) { + foreach ($initrds as $initrd) { + $script .= " initrd=$initrd"; + } + } + if (!empty($entry->commandLine)) { + $script .= ' ' . $entry->commandLine . ' slx.ipxe.id=${menuentryid}'; + } + $script .= " || goto fail\n"; + if ($entry->resetConsole) { + $script .= "goto start ||\n"; + } + return $script; + } + +} |