├── .gitignore ├── LICENSE ├── README.md ├── pxenow ├── setup.py └── tftp └── boot └── pxelinux.cfg └── default /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | iso/ 3 | nfs/ 4 | venv/ 5 | 6 | tftp/* 7 | !tftp/ 8 | 9 | tftp/boot/* 10 | !tftp/boot/ 11 | 12 | !tftp/boot/pxelinux.cfg/ 13 | 14 | *generated*.conf 15 | dnsmasq*.conf 16 | dnsmasq.leases 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 lvps 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pxenow 2 | 3 | Start an impromptu PXE server immediately, here, now, right in current working directory. 4 | 5 | ## Features 6 | 7 | * Configures dnsmasq to *DHCP in proxy mode*: it should work even on networks with an existing DHCP server 8 | * Provides *squashfs via NFS*, not TFTP + memdisk: no need to fit the entire ISO in RAM 9 | * ISO files are mounted/unmounted automatically and symlinks created when necessary 10 | * Syslinux/pxelinux boot menu with options to start Memtest86+, HDT, PLoP and netboot.xyz, in addition to user-specified ISOs 11 | * Downloads Memtest86+, PLoP and netboot.xyz executables automatically if not found 12 | 13 | ## Limitations 14 | 15 | * Currently supports only BIOS systems, no UEFI 16 | * Syslinux config can be reliably generated only for *live* Debian, Ubuntu and derivatives, but should be easy to add 17 | more distros or the installer (see the `get_syslinux_config_for` function) 18 | * Many commands require root privileges. There's an option (`-S`) to call `sudo` automatically, but it hasn't been 19 | tested extensively 20 | * Tested only on Arch Linux and Xubuntu as servers, some commands may fail on other distros 21 | 22 | ## Requirements 23 | 24 | - Python 3.7 25 | - dnsmasq 26 | - NFS 27 | - syslinux and pxelinux (on Arch Linux both are located in the `syslinux` package) 28 | - netifaces (optional) or use the `-s` and `-n` parameters 29 | 30 | To install netifaces locally: 31 | 32 | ```Bash 33 | python -m virtualenv venv 34 | source venv/bin/activate 35 | python setup.py install 36 | ``` 37 | 38 | ## Usage 39 | 40 | ``` 41 | usage: pxenow [-h] [-i INTERFACE] [-n NETMASK] [-s SERVER] [-k KEYMAPS] [-N] 42 | [-S] 43 | [iso [iso ...]] 44 | 45 | Create a PXE server right here, right now. 46 | 47 | positional arguments: 48 | iso Path to ISO images 49 | 50 | optional arguments: 51 | -h, --help show this help message and exit 52 | -i INTERFACE, --interface INTERFACE 53 | Interface to bind, e.g. enp3s0 54 | -n NETMASK, --netmask NETMASK 55 | Netmask, used only if -s is also used 56 | -s SERVER, --server SERVER 57 | IP address of current machine, used as TFTP, DHCP and 58 | NFS server 59 | -k KEYMAPS, --keymaps KEYMAPS 60 | Comma-separated list of keymaps 61 | -N, --nfs Blindly overwrite /etc/exports and manage NFS server 62 | -S, --sudo Use sudo for commands that require root permissions 63 | ``` 64 | 65 | Specify `-s` and `-n` if you haven't installed netifaces. If `-i` is given, `-s` and `-n` are ignored. 66 | If none of these is specified, the script will try to guess the interface and address to use. 67 | 68 | With `-N` the script starts/stops the `nfs-server` service automatically and completely **overwrites** 69 | /etc/exports if needed. Without `-N` it outputs the correct exports and you'll have to copy them into 70 | /etc/exports and manually manage the NFS service. 71 | 72 | ## License 73 | 74 | MIT. 75 | -------------------------------------------------------------------------------- /pxenow: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import glob 5 | import os 6 | import stat 7 | import urllib.request 8 | import zipfile 9 | import tempfile 10 | import re 11 | # import errno 12 | from dataclasses import dataclass 13 | from shutil import copy2 as cp, which 14 | from subprocess import call, Popen 15 | from typing import Optional 16 | 17 | 18 | class FatalError(Exception): 19 | def __init__(self, message, return_code=None): 20 | super().__init__(message) 21 | self.code = return_code 22 | 23 | 24 | def get_img_file(printable_name: str, destination_file: str, url: str, filename_in_zip: str) -> None: 25 | """ 26 | Download a zip file from the internet, extract a single file and place it somewhere. 27 | If filename_in_zip is the empty string, the downloaded file will be renamed/moved instead. 28 | 29 | :param printable_name: description of what this thing is, used to generate temporary file names and to show in messages 30 | :param destination_file: name of the extracted file, will be placed in tftp/boot/img/included/ 31 | :param url: where to download from 32 | :param filename_in_zip: This will be extracted and moved to destination_file. 33 | :return: 34 | """ 35 | if os.path.isfile("tftp/boot/img/included/{0}".format(destination_file)): 36 | print("{0} found".format(printable_name)) 37 | else: 38 | if filename_in_zip == "": 39 | # Not a zip file 40 | print("Getting {0}...".format(printable_name)) 41 | urllib.request.urlretrieve(url, filename="tftp/boot/img/included/{0}".format(destination_file)) 42 | else: 43 | # Zip file: download and extract 44 | temp_name = "pxenow-downloaded-{0}.zip".format(printable_name) 45 | 46 | if not os.path.isfile(temp_name): 47 | print("Getting {0}...".format(printable_name)) 48 | urllib.request.urlretrieve(url, filename=temp_name) 49 | 50 | with zipfile.ZipFile(temp_name, "r") as zip_ref: 51 | print("Extracting {0}...".format(filename_in_zip)) 52 | zipinfo = zip_ref.getinfo(filename_in_zip) 53 | zipinfo.filename = destination_file # avoids extracting random folders and renaming 54 | zip_ref.extract(filename_in_zip, path="tftp/boot/img/included/") 55 | os.remove(temp_name) 56 | 57 | return 58 | 59 | 60 | def guess_interface() -> str: 61 | """ 62 | Guess which network interface to use (= pick the only one other than lo and exit if more than one exists) 63 | 64 | :return: interface, e.g. enp3s0 65 | """ 66 | import netifaces 67 | 68 | interfaces = netifaces.interfaces() 69 | 70 | if 'lo' in interfaces: 71 | interfaces.remove('lo') 72 | 73 | num = len(interfaces) 74 | if num == 0: 75 | print("No interfaces found (other than loopback, possibly)") 76 | exit(1) 77 | elif num == 1: 78 | return interfaces[0] 79 | else: 80 | print("More than one interface: specify which one to use in " + str(interfaces)) 81 | exit(1) 82 | 83 | 84 | def relink(source: str, destination: str) -> None: 85 | """ 86 | Create a symlink. If it already exists, make sure it is correct. 87 | 88 | :param source: real file (absolute path required!) 89 | :param destination: the link itself 90 | :return: None 91 | """ 92 | 93 | if os.path.islink(destination): 94 | current_source = os.readlink(destination) 95 | if current_source == source: 96 | return 97 | else: 98 | os.remove(destination) 99 | elif os.path.exists(destination): 100 | raise FatalError("Cannot create link from {}: file already exists and is not a link".format(destination)) 101 | 102 | print("Creating symlink {frm} pointing to {to}...".format(frm=destination, to=source)) 103 | os.symlink(source, destination) 104 | 105 | 106 | def configure_nfs(exports_file_content: str, use_sudo: bool) -> None: 107 | print("Stopping NFS server...") 108 | sudocall(["systemctl", "stop", "nfs-server"]) 109 | 110 | different = False 111 | 112 | try: 113 | with open(ETCEXPORTS, "r") as exports_file: 114 | old_exports = exports_file.read() 115 | if old_exports != exports_file_content: 116 | different = True 117 | except IOError as why: 118 | raise FatalError("Cannot read {}: {}".format(ETCEXPORTS, str(why))) 119 | 120 | if different: 121 | print("Saving new {0}...".format(ETCEXPORTS)) 122 | 123 | try: 124 | # Try to write new file 125 | with open(ETCEXPORTS, "w") as exports_file: 126 | exports_file.write(exports_file_content) 127 | except PermissionError as why: 128 | # Cannot write /etc/exports 129 | if use_sudo: 130 | # Create a temporary file and overwrite /etc/exports (this allows calling sudo) 131 | write_content_sudocall(exports_file_content, ETCEXPORTS) 132 | else: 133 | raise FatalError("Cannot write {}: {}".format(ETCEXPORTS, str(why))) 134 | 135 | print("Reloading exports...") 136 | sudocall(["exportfs", "-a"]) 137 | else: 138 | print("No need to update {0}".format(ETCEXPORTS)) 139 | 140 | print("Starting NFS server...") 141 | sudocall(["systemctl", "start", "nfs-server"]) 142 | print("") 143 | 144 | 145 | def sudocall_wait(what: list) -> int: 146 | if args.sudo: 147 | callme = ["sudo"] 148 | callme.extend(what) 149 | else: 150 | callme = what 151 | 152 | with Popen(callme) as process: 153 | try: 154 | return process.wait() 155 | except KeyboardInterrupt: 156 | raise 157 | except Exception: 158 | process.kill() 159 | process.wait() 160 | raise 161 | 162 | 163 | def sudocall(what: list) -> int: 164 | """ 165 | Call some command, prepending sudo if args.sudo is True 166 | 167 | :param what: command and arguments 168 | :return: whatever sudo/the command returns 169 | """ 170 | if args.sudo: 171 | callme = ["sudo"] 172 | callme.extend(what) 173 | else: 174 | callme = what 175 | 176 | return call(callme) 177 | 178 | 179 | def write_content_sudocall(content: str, destination: str) -> None: 180 | """ 181 | Write content to a file with a command called via sudocall. 182 | Useful to write files where current user has no write permission. 183 | 184 | :param content: File content 185 | :param destination: Where to place it 186 | :return: Nothing 187 | """ 188 | with tempfile.NamedTemporaryFile() as tmp_file, open(tmp_file.name, 'w') as tmp: 189 | try: 190 | tmp.write(content) 191 | tmp.flush() 192 | except IOError: 193 | print("Cannot write to temporary file {0}".format(tmp_file.name)) 194 | return 195 | sudocall_result = sudocall(["cp", tmp_file.name, destination]) 196 | if sudocall_result != 0: 197 | raise FatalError("Cannot call cp {} {}".format(tmp_file.name, destination), sudocall_result) 198 | 199 | 200 | @dataclass 201 | class EntryParameters: 202 | name: str 203 | server: str 204 | vmlinuz: str 205 | initrd: str 206 | root_directory: str 207 | keymap: Optional[tuple] 208 | 209 | 210 | def get_syslinux_config_for(name: str, root_directory: str, server: str, keymap: Optional[tuple]) -> str: 211 | """ 212 | Generate a Syslinux/PXELINUX configuration entry for an ISO/directory 213 | 214 | :param name: Entry name (human-readable) 215 | :param server: Server IP 216 | :param root_directory: Directory where ISO was mounted, or extracted 217 | :param keymap: Keymaps, for entries that support them 218 | :return: 219 | """ 220 | 221 | maybe_vmlinuz = ( 222 | f"{root_directory}/casper/vmlinuz*", 223 | # f"{root_directory}/casper/vmlinuz.efi", 224 | f"{root_directory}/arch/boot/x86_64/vmlinuz", 225 | f"{root_directory}/live/vmlinuz-*", 226 | ) 227 | maybe_initrd = ( 228 | f"{root_directory}/casper/initrd.lz", 229 | # f"{root_directory}/casper/initrd", 230 | f"{root_directory}/arch/boot/x86_64/archiso.img", 231 | f"{root_directory}/live/initrd.img*" 232 | ) 233 | 234 | vmlinuz_src = search_for("vmlinuz", maybe_vmlinuz, name) 235 | initrd_src = search_for("initrd", maybe_initrd, name) 236 | 237 | vmlinuz = "mounted/{0}-vmlinuz".format(name) 238 | initrd = "mounted/{0}-initrd".format(name) 239 | 240 | relink(vmlinuz_src, "tftp/boot/" + vmlinuz) 241 | relink(initrd_src, "tftp/boot/" + initrd) 242 | 243 | ep = EntryParameters( 244 | name=name, 245 | server=server, 246 | vmlinuz=vmlinuz, 247 | initrd=initrd, 248 | root_directory=root_directory, 249 | keymap=keymap 250 | ) 251 | 252 | if os.path.isdir(f"{root_directory}/arch/boot/x86_64/"): 253 | print(f"{name} seems to be Arch Linux x86_64") 254 | return syslinux_config_arch(ep) 255 | elif os.path.isdir(f"{root_directory}/live/") and (os.path.isdir(f"{root_directory}/d-i/") or os.path.exists(f"{root_directory}/DEBIAN_CUSTOM")): 256 | print(f"{name} seems to be Debian live") 257 | return syslinux_config_debian(ep) 258 | else: 259 | print(f"{name} seems to be Ubuntu (derivative) or unrecognized") 260 | return syslinux_config_ubuntu(ep) 261 | 262 | 263 | def name_compact(name: str): 264 | return re.sub(r'\W+', '', name) 265 | 266 | 267 | def syslinux_config_debian(ep: EntryParameters): 268 | return str(f""" 269 | LABEL {name_compact(ep.name)} 270 | MENU LABEL {ep.name} 271 | TEXT HELP 272 | Boot Debian with kernel+initramfs from TFTP, 273 | and root fs from NFS server {ep.server} 274 | ENDTEXT 275 | LINUX {ep.vmlinuz} 276 | APPEND root=/dev/nfs netboot=nfs nfsroot={ep.server}:{ep.root_directory}/ initrd=::/boot/{ep.initrd} boot=live 277 | """) 278 | 279 | 280 | # localized debian live: 281 | # LABEL Italian (it) 282 | # SAY "Booting Italian (it)..." 283 | # linux /live/vmlinuz-4.9.0-7-amd64 284 | # APPEND initrd=/live/initrd.img-4.9.0-7-amd64 boot=live components locales=it_IT.UTF-8 285 | # 286 | # Somewhat recent pxe boot parameters: 287 | # https://lists.debian.org/debian-live/2016/01/msg00008.html 288 | 289 | 290 | def syslinux_config_ubuntu(ep: EntryParameters): 291 | label = name_compact(ep.name) 292 | config = "" 293 | 294 | if ep.keymap is None: 295 | keymaps = [""] 296 | else: 297 | keymaps = ep.keymap 298 | 299 | for k in keymaps: 300 | if k == "": 301 | keyboard_description = "" 302 | keyboard_param = "" 303 | else: 304 | keyboard_description = ", {} layout".format(k) 305 | keyboard_param = " keyboard-configuration/layoutcode?=" + k 306 | config += f""" 307 | LABEL {label}{k} 308 | MENU LABEL {ep.name} (NFS{keyboard_description}) 309 | TEXT HELP 310 | Boot with kernel+initrd from TFTP, 311 | and squashfs from NFS server {ep.server} 312 | ENDTEXT 313 | KERNEL {ep.vmlinuz} 314 | APPEND root=/dev/nfs boot=casper netboot=nfs nfsroot={ep.server}:{ep.root_directory} initrd={ep.initrd}{keyboard_param} --- 315 | """ 316 | 317 | return config 318 | 319 | 320 | # TODO: doesn't work 321 | def syslinux_config_arch(ep: EntryParameters): 322 | return str(f""" 323 | LABEL {name_compact(ep.name)} 324 | MENU LABEL {ep.name} 325 | TEXT HELP 326 | Boot Arch Linux with kernel+initramfs from TFTP, 327 | and root fs from NFS server {ep.server} 328 | ENDTEXT 329 | LINUX {ep.vmlinuz} 330 | INITRD {ep.initrd} 331 | APPEND ip=:: archisobasedir=arch archiso_nfs_srv={ep.server}:{ep.root_directory} 332 | SYSAPPEND 2 333 | """) 334 | 335 | 336 | def search_for(what: str, where: tuple, iso_name: str) -> str: 337 | """ 338 | Find first file that exists from a list of paths 339 | 340 | :param what: File name/description (generic, human-readable) 341 | :param where: Possible paths (to be globbed) 342 | :param iso_name: ISO name or other printable name 343 | :return: 344 | """ 345 | for maybe in where: 346 | matches = glob.glob(maybe) 347 | if len(matches) > 0: 348 | maybe = matches[0] 349 | if len(matches) > 1: 350 | print(f"Warning: ambiguous globbing for {what}, choosing {maybe}") 351 | return maybe 352 | raise FatalError(f"{what} not found in {iso_name}") 353 | 354 | 355 | def get_network_parameters(iface: Optional[str], ip: Optional[str], nmask: Optional[str]) -> {Optional[str], str, str}: 356 | """ 357 | Obtain at least an IP and a netmask from supplied parameters. 358 | 359 | :param iface: Interface 360 | :param ip: IP 361 | :param nmask: Netmask 362 | :return: Interface, IP, netmask 363 | """ 364 | if ip is None: 365 | try: 366 | import netifaces 367 | except ModuleNotFoundError: 368 | raise FatalError("Module netifaces not found, install it or specify IP and netmask (-s and -i parameters)") 369 | 370 | # No IP given and no interface, so we have to guess interface and obtain IP 371 | if iface is None: 372 | iface = guess_interface() 373 | 374 | addresses = netifaces.ifaddresses(iface)[netifaces.AF_INET] 375 | 376 | if len(addresses) == 0: 377 | raise FatalError("No addresses found for interface " + str(args.interface)) 378 | 379 | ip = addresses[0]['addr'] 380 | nmask = addresses[0]['netmask'] 381 | else: 382 | if nmask is None: 383 | raise FatalError("Provide a netmask if manually setting the IP, e.g. -n 255.255.255.0") 384 | ip = args.server 385 | 386 | return iface, ip, nmask 387 | 388 | 389 | def copy_files_paths(destination: str, filename: str, source_paths: tuple): 390 | if len(source_paths) < 1: 391 | raise Exception(f"Trying to copy {filename} to {destination} but no source paths provided") 392 | 393 | found = False 394 | for path in source_paths: 395 | src = path + filename 396 | if os.path.isfile(src): 397 | found = True 398 | break 399 | if found: 400 | print(f"Copying {filename} (found at {src})") 401 | # noinspection PyUnboundLocalVariable 402 | cp(src, destination) 403 | else: 404 | raise FatalError(f"Cannot find {filename}, tried: {str(source_paths)}") 405 | 406 | 407 | def copy_pxelinux_files() -> None: 408 | # First path is Arch Linux, second is (X)ubuntu 409 | pxelinux_dirs = ("/usr/lib/syslinux/bios/", "/usr/lib/PXELINUX/") 410 | syslinux_modules_dirs = ("/usr/lib/syslinux/bios/", "/usr/lib/syslinux/modules/bios/") 411 | syslinux_other_dirs = ("/usr/lib/syslinux/bios/", "/usr/lib/syslinux/") 412 | pci_ids_path = ("/usr/share/hwdata/", "/usr/share/misc/") 413 | 414 | files = { 415 | "pxelinux.0": pxelinux_dirs, 416 | "lpxelinux.0": pxelinux_dirs, 417 | "ldlinux.c32": syslinux_modules_dirs, 418 | "vesamenu.c32": syslinux_modules_dirs, 419 | "menu.c32": syslinux_modules_dirs, 420 | "libutil.c32": syslinux_modules_dirs, 421 | "hdt.c32": syslinux_modules_dirs, # for HDT 422 | "libmenu.c32": syslinux_modules_dirs, # for HDT 423 | "libcom32.c32": syslinux_modules_dirs, # for HDT 424 | "libgpl.c32": syslinux_modules_dirs, # for HDT 425 | "reboot.c32": syslinux_modules_dirs, 426 | "poweroff.c32": syslinux_modules_dirs, 427 | "memdisk": syslinux_other_dirs, 428 | "pci.ids": pci_ids_path 429 | } 430 | 431 | for filename, paths in files.items(): 432 | try: 433 | copy_files_paths("tftp/boot/" + filename, filename, paths) 434 | except (FileNotFoundError, OSError) as why: 435 | print() 436 | raise FatalError(f"Cannot copy file: {str(why)}") 437 | 438 | 439 | def generate_dnsmasq_configuration(interface: Optional[str], server: str) -> None: 440 | """ 441 | Generate, validate and save dnsmasq configuration 442 | 443 | :param interface: Network interface 444 | :param server: IP of the TFTP and NFS server 445 | :return: Nothing 446 | """ 447 | print("Generating dnsmasq configuration...") 448 | # useful stuff: https://wiki.archlinux.org/index.php/Dnsmasq#PXE_server 449 | dnsmasq_generated_config = "" 450 | 451 | # Could still be "none" when providing IP and netmask, but it's not mandatory 452 | if interface is not None: 453 | dnsmasq_generated_config += """interface = {iface} 454 | bind-interfaces 455 | """.format(iface=interface) 456 | 457 | dnsmasq_generated_config += """port=0 458 | dhcp-leasefile=dnsmasq.leases 459 | 460 | enable-tftp 461 | tftp-root={pwd}/tftp 462 | dhcp-option-force=66,{ip} # TFTP server 463 | 464 | log-dhcp # More info on what's going on 465 | dhcp-no-override # Don't put useless fields in DHCP offer 466 | dhcp-range={ip},proxy 467 | 468 | pxe-service=X86PC, "PXE boot NOW", boot/lpxelinux # It adds the .0 by itself 469 | dhcp-option=vendor:PXEClient,6,2b # "kill multicast" 470 | 471 | # These don't work in Proxy DHCP mode, apparently: 472 | # dhcp-boot=boot/lpxelinux.0 473 | # dhcp-option-force=209,"pxelinux.cfg/default" # PXELINUX config file (it's the default anyway) 474 | # dhcp-option-force=210,/boot/ # PathPrefix, doesn't seem to be needed (See RFC 5071) 475 | """.format(ip=server, pwd=os.getcwd()) 476 | 477 | with open(DNSMASQCONF, "w") as config_file: 478 | config_file.write(dnsmasq_generated_config) 479 | os.chmod(DNSMASQCONF, os.stat(DNSMASQCONF).st_mode | stat.S_IROTH) 480 | 481 | if which("dnsmasq") is None: 482 | raise FatalError("dnsmasq binary not found in PATH") 483 | 484 | sudocall_result = sudocall(["dnsmasq", "-d", "--test", "-C", DNSMASQCONF]) 485 | if sudocall_result != 0: 486 | raise FatalError("dnsmasq returned " + str(sudocall_result), sudocall_result) 487 | 488 | 489 | def find_bootable_files(paths) -> {list, list, list}: 490 | """ 491 | Check that supplied files and directories exist and determine what they are 492 | 493 | :param paths: Path to ISO files, IMG files, directories and whatever else 494 | :return: ISO files, IMG files, and directories 495 | """ 496 | iso_files = [] 497 | img_files = [] 498 | iso_directories = [] 499 | 500 | if paths is not None: 501 | for file in args.iso: 502 | if os.path.isdir(file): 503 | iso_directories.append(file) 504 | elif file.endswith(".iso"): 505 | iso_files.append(file) 506 | else: 507 | img_files.append(file) 508 | 509 | return iso_files, img_files, iso_directories 510 | 511 | 512 | def mount_iso(path_to_iso: str, mounted_files: list) -> {str, str}: 513 | """ 514 | Mount an ISO file 515 | 516 | :param path_to_iso: ISO file 517 | :param mounted_files: Global list of mounted files 518 | :return: Filename and directory where it was mounted 519 | """ 520 | if not os.path.isfile(path_to_iso): 521 | raise FatalError("{0} doesn't exist".format(path_to_iso)) 522 | 523 | file_name = os.path.basename(path_to_iso) 524 | mount_directory = "{cwd}/nfs/{name}.mount".format(cwd=os.getcwd(), name=file_name) 525 | 526 | if " " in mount_directory: 527 | raise FatalError( 528 | "NFS path {0} contains spaces so it won't work, move everything in a directory without spaces and retry" 529 | .format(mount_directory)) 530 | 531 | os.makedirs(mount_directory, exist_ok=True) 532 | 533 | # Could be done "if result == 0", but if script crashes midway you'd need to unmount manually... 534 | mounted_files.append(mount_directory) 535 | 536 | # TODO: detect if already mounted? 537 | sudocall_result = sudocall(["mount", "-o", "loop,ro", "-t", "iso9660", path_to_iso, mount_directory]) 538 | 539 | if sudocall_result != 0: 540 | raise FatalError("mount returned " + str(sudocall_result), sudocall_result) 541 | 542 | return file_name, mount_directory 543 | 544 | 545 | def generate_iso_config(iso: list, keymaps: Optional[tuple], mounted_files: list, server: str): 546 | syslinux_generated_config = "" 547 | 548 | if len(iso) > 0: 549 | print("ISO files: {0}\n".format(str(iso))) 550 | os.makedirs("nfs", exist_ok=True) 551 | os.makedirs("tftp/boot/mounted", exist_ok=True) 552 | 553 | syslinux_generated_config = "\tMENU SEPARATOR\n\n" 554 | 555 | for file in iso: 556 | name, mount_directory = mount_iso(file, mounted_files) 557 | print("Generating syslinux config for {0}".format(name)) 558 | syslinux_generated_config += get_syslinux_config_for(name, mount_directory, server, keymaps) 559 | 560 | with open(GENERATEDISOCONF, "w") as config_file: 561 | config_file.write(syslinux_generated_config) 562 | os.chmod(GENERATEDISOCONF, os.stat(GENERATEDISOCONF).st_mode | stat.S_IROTH) 563 | 564 | 565 | def generate_isd_config(isd: list, keymaps: Optional[tuple], mounted_files: list, server: str): 566 | syslinux_generated_config = "" 567 | 568 | if len(isd) > 0: 569 | syslinux_generated_config = "\tMENU SEPARATOR\n\n" 570 | 571 | # TODO: implement 572 | 573 | with open(GENERATEDISDCONF, "w") as config_file: 574 | config_file.write(syslinux_generated_config) 575 | os.chmod(GENERATEDISDCONF, os.stat(GENERATEDISDCONF).st_mode | stat.S_IROTH) 576 | 577 | 578 | def generate_img_config(img: list): 579 | syslinux_generated_config = "" 580 | 581 | if len(img) > 0: 582 | print("IMG files: {0}\n".format(str(img))) 583 | os.makedirs("tftp/boot/img", exist_ok=True) 584 | 585 | syslinux_generated_config = "\tMENU SEPARATOR\n\n" 586 | 587 | for file in img: 588 | file_name = os.path.basename(file) 589 | 590 | print("Generating syslinux config for {0}".format(img)) 591 | syslinux_generated_config += """ 592 | LABEL - 593 | MENU LABEL {name} 594 | TEXT HELP 595 | Launch {name} as image file 596 | ENDTEXT 597 | KERNEL memdisk 598 | APPEND initrd=img/{name} 599 | """.format(name=file_name) 600 | relink(file, "tftp/boot/img/" + file_name) 601 | 602 | with open(GENERATEDIMGCONF, "w") as config_file: 603 | config_file.write(syslinux_generated_config) 604 | os.chmod(GENERATEDIMGCONF, os.stat(GENERATEDIMGCONF).st_mode | stat.S_IROTH) 605 | 606 | 607 | def main(mounted_files) -> int: 608 | # Quick setting a static IP: 609 | # IFACE=enp3s0 610 | # ip link set "${IFACE}" up 611 | # ip addr flush dev "${IFACE}" 612 | # ip addr add 10.80.7.1/24 dev "${IFACE}" 613 | # flush is necessary only if this is done over and over again (e.g. in a script) 614 | 615 | interface, server, netmask = get_network_parameters(args.interface, args.server, args.netmask) 616 | del args.interface 617 | del args.server 618 | 619 | if args.keymaps is None: 620 | keymaps = None 621 | else: 622 | keymaps = args.keymaps.split(",") 623 | del args.keymaps 624 | 625 | copy_pxelinux_files() 626 | 627 | os.makedirs("tftp/boot/iso", exist_ok=True) 628 | os.makedirs("tftp/boot/img/included", exist_ok=True) 629 | 630 | generate_dnsmasq_configuration(interface, server) 631 | 632 | get_img_file("Memtest86+", "memtest", "https://www.memtest.org/download/v7.00/mt86plus_7.00.binaries.zip", "memtest32.bin") 633 | get_img_file("PLoP", "plpbt.bin", "https://download.plop.at/files/bootmngr/plpbt-5.0.15.zip", "plpbt-5.0.15/plpbt.bin") 634 | get_img_file("netboot.xyz", "netbootxyz", "https://boot.netboot.xyz/ipxe/netboot.xyz.lkrn", "") 635 | 636 | # ISO files, IMG files, ISo Directory, just to keep the symmetry of three-letter codes 637 | iso, img, isd = find_bootable_files(args.iso) 638 | 639 | generate_iso_config(iso, keymaps, mounted_files, server) 640 | generate_isd_config(isd, keymaps, mounted_files, server) 641 | generate_img_config(img) 642 | 643 | exports = "" 644 | 645 | for full_export in mounted_files: 646 | exports += "{export} {ip}/{nm}(ro,all_squash,insecure,no_subtree_check)\n"\ 647 | .format(export=full_export, ip=server, nm=netmask) 648 | 649 | if exports != "": 650 | if args.nfs: 651 | configure_nfs("# Created by pxenow\n" + exports, args.sudo) 652 | else: 653 | print("Almost done: add this to /etc/exports, run 'exportfs -a' and start your NFS server:") 654 | print("---------------------------------------------------------------------------------") 655 | print(exports) 656 | print("---------------------------------------------------------------------------------") 657 | 658 | try: 659 | returned = sudocall_wait(["dnsmasq", "-d", "-C", DNSMASQCONF]) 660 | if returned != 0: 661 | raise FatalError("Failed launching dnsmasq", returned) 662 | except KeyboardInterrupt: 663 | print("Keyboard interrupt (ctrl+C) detected") 664 | 665 | if args.nfs: 666 | print("Stopping NFS server...") 667 | sudocall(["systemctl", "stop", "nfs-server"]) 668 | 669 | return 0 670 | 671 | 672 | if __name__ == "__main__": 673 | parser = argparse.ArgumentParser(description='Create a PXE server right here, right now.') 674 | parser.add_argument('iso', nargs='*', type=str, help="Path to ISO images") 675 | parser.add_argument('-i', '--interface', type=str, help="Interface to bind, e.g. enp3s0") 676 | parser.add_argument('-n', '--netmask', type=str, help="Netmask, used only if -s is also used") 677 | parser.add_argument('-s', '--server', type=str, 678 | help="IP address of current machine, used as TFTP, DHCP and NFS server") 679 | parser.add_argument('-k', '--keymaps', type=str, help="Comma-separated list of keymaps") 680 | # parser.add_argument('-m', '--memdisk', action='store_true', help="Generate memdisk entries for each ISO") 681 | parser.add_argument('-N', '--nfs', action='store_true', help="Blindly overwrite /etc/exports and manage NFS server") 682 | parser.add_argument('-S', '--sudo', action='store_true', help="Use sudo for commands that require root permissions") 683 | parser.set_defaults(memdisk=False) 684 | parser.set_defaults(nfs=False) 685 | parser.set_defaults(sudo=False) 686 | args = parser.parse_args() 687 | 688 | mounted = [] 689 | DNSMASQCONF = "dnsmasq-pxe.conf" 690 | GENERATEDIMGCONF = "tftp/boot/pxelinux.cfg/img-generated.conf" 691 | GENERATEDISOCONF = "tftp/boot/pxelinux.cfg/iso-generated.conf" 692 | GENERATEDISDCONF = "tftp/boot/pxelinux.cfg/isd-generated.conf" 693 | ETCEXPORTS = "/etc/exports" 694 | 695 | mounted_list = [] 696 | code = 1 697 | try: 698 | code = main(mounted_list) 699 | except FatalError as e: 700 | print(str(e)) 701 | if e.code is None: 702 | code = 1 703 | else: 704 | code = e.code 705 | finally: 706 | if len(mounted_list) > 0: 707 | errors = False 708 | print("Unmounting all ISOs...") 709 | for directory in mounted_list: 710 | print("Unmounting {0}...".format(directory)) 711 | result = sudocall(["umount", directory]) 712 | if result == 0: 713 | try: 714 | os.rmdir(directory) 715 | except OSError: 716 | pass 717 | else: 718 | errors = True 719 | print("Failed to unmount {}: {}".format(directory, os.strerror(result))) 720 | if errors != 0: 721 | print("Warning: some umount(s) failed, try stopping/restarting the NFS server and re-running the script") 722 | exit(code) 723 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='pxenow', 5 | version='0.0.1', 6 | url='https://github.com/WEEE-Open/pxenow', 7 | license='MIT', 8 | author='lvps', 9 | author_email='', 10 | description='Start a PXE server (proxy DHCP + TFTP + NFS) now, just supply the ISO files', 11 | install_requires=['netifaces'] 12 | ) 13 | -------------------------------------------------------------------------------- /tftp/boot/pxelinux.cfg/default: -------------------------------------------------------------------------------- 1 | UI menu.c32 2 | TIMEOUT 0 3 | 4 | MENU TITLE Yay PXE boot! 5 | MENU ROWS 14 6 | MENU TABMSGROW 20 7 | MENU CMDLINEROW 20 8 | 9 | # Dark "theme": 10 | #MENU COLOR border 34;40 11 | #MENU COLOR title 1;37;40 12 | #MENU COLOR sel 1;37;44 13 | #MENU COLOR unsel 37;40 14 | #MENU COLOR disabled 37;40 15 | #MENU COLOR screen 37;40 16 | #MENU COLOR help 37;40 17 | #MENU COLOR timeout_msg 37;40 18 | #MENU COLOR timeout 1;37;40 19 | #MENU COLOR tabmsg 37;40 20 | 21 | # Light "theme": 22 | MENU COLOR border 34;47 23 | MENU COLOR title 30;47 24 | MENU COLOR sel 1;37;44 25 | MENU COLOR unsel 30;47 26 | MENU COLOR disabled 30;47 27 | MENU COLOR screen 30;47 28 | MENU COLOR help 30;47 29 | MENU COLOR timeout_msg 30;47 30 | MENU COLOR timeout 1;30;47 31 | MENU COLOR tabmsg 1;30;47 32 | 33 | LABEL memtest 34 | MENU LABEL Memtest86+ v7.00 35 | KERNEL img/included/memtest 36 | 37 | LABEL hdt 38 | MENU LABEL HDT (Hardware Detection Tool) 39 | COM32 hdt.c32 40 | 41 | LABEL plop 42 | MENU LABEL PLoP Boot Manager 43 | TEXT HELP 44 | Launch PLoP Boot Manager, to boot from USB and enable USB 2.0 on 45 | some BIOSes that only support on 1.1, if supported by the controller. 46 | ENDTEXT 47 | LINUX img/included/plpbt.bin 48 | 49 | LABEL netbootxyz 50 | MENU LABEL netboot.xyz 51 | TEXT HELP 52 | iPXE over the internet! 53 | ENDTEXT 54 | KERNEL img/included/netbootxyz 55 | 56 | INCLUDE pxelinux.cfg/iso-generated.conf 57 | INCLUDE pxelinux.cfg/isd-generated.conf 58 | INCLUDE pxelinux.cfg/img-generated.conf 59 | 60 | MENU SEPARATOR 61 | 62 | LABEL - 63 | LOCALBOOT 0 64 | MENU LABEL Boot first hard disk 65 | 66 | LABEL - 67 | LOCALBOOT 1 68 | MENU LABEL Boot second hard disk 69 | 70 | MENU SEPARATOR 71 | 72 | LABEL reload 73 | MENU LABEL Reload menu 74 | TEXT HELP 75 | Reload (text mode) menu from TFTP server 76 | "KERNEL vesamenu.c32" should reload VESA menu, but doesn't work 77 | ENDTEXT 78 | COM32 menu.c32 79 | 80 | LABEL reboot 81 | MENU LABEL Reboot 82 | COM32 reboot.c32 83 | 84 | LABEL poweroff 85 | MENU LABEL Power Off 86 | COM32 poweroff.c32 87 | --------------------------------------------------------------------------------