summaryrefslogblamecommitdiffstats
path: root/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderipxe.inc.php
blob: 9421684f26995c61467341a50fa0307d9ad0eb5f (plain) (tree)
1
2
3
4
5
6




                                                 
                                             


















                                                                                                                            
                                                                                                              











                                                             
                                                                    




















                                                                                                                                                 
                                                                                   


























                                                                     






                                                                                                 

                                                                                                             
                                                                      


                                                                       

                             
 
                                                               
         
                                      


                                                                                       

         
                                                                        
         


                                                                                   
                                            


                                                                                    
                                                            


                                                       

                                               

                 





                                                                             






                                        




                                                                                               
                                                                      
















                                                                                          
                    
                                          

                                                                
                                           











                                           


                               
                                                                                             
         



                                            

                                                                                                                                         




















                                                                                                  
                                                           



                                                                                                                     


                                                                            
                                                                                              




                                                                                 
                                                                                           
                                                                                                                                     
                                                                                                                                                  












                                                                                    
                                                                          

















                                                                                                                                                    
                                                                                                  





                                        






                                                                                                                      
                                                    
         







                                                                                                                      
                        





                                                                           
 


                                                                                                                                                   








                                                                                                                                          

                                          
                                                                 
























                                                                                            
                                                                                           
         
                                    
                                                                                        
                                            
                                              







                                                                          
                        


                                     
                                                                


































                                                                                                
                                                                                                       























                                                                                            


                                                                                       


















































                                                                                                                                            
                                                                                             








                                                     
<?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;
	}

}