commit 6cb1c7d48b5aaacc0714c7844be88ad16f75a861 Author: Dreaded_X Date: Fri Nov 7 05:29:32 2025 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef4e642 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +ipxe/ +rendered/ +tftp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..182021a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM docker.io/library/debian:stable AS builder-ipxe +RUN apt-get update \ + && apt-get install -y \ + build-essential \ + curl \ + liblzma-dev \ + genisoimage +ARG IPXE_VERSION=b41bda4413bf286d7b7a449bc05e1531da1eec2e +RUN curl -L https://github.com/ipxe/ipxe/archive/${IPXE_VERSION}.tar.gz | tar -xz +WORKDIR /ipxe-${IPXE_VERSION}/src + +# Enable HTTPS +RUN sed -i 's/^#undef[\t ]DOWNLOAD_PROTO_HTTPS.*$/#define DOWNLOAD_PROTO_HTTPS/g' config/general.h + +RUN mkdir /build +RUN make -j$(nproc) bin/ipxe.pxe && cp bin/ipxe.pxe /build +RUN make -j$(nproc) bin-x86_64-efi/ipxe.efi && cp bin-x86_64-efi/ipxe.efi /build + +FROM docker.io/library/python:3.13-slim AS config-renderer +COPY --from=docker.io/hairyhenderson/gomplate:v4.3 /gomplate /bin/gomplate +COPY ./requirements.txt /requirements.txt +RUN pip install -r /requirements.txt +COPY ./generate.sh /generate.sh +COPY ./tools /tools +COPY ./nodes /nodes +COPY ./templates /templates +RUN ./generate.sh + +FROM docker.io/library/alpine:3.22.2 AS runtime +RUN apk add dnsmasq + +COPY --from=builder-ipxe /build/ipxe.pxe /tftproot/ +COPY --from=builder-ipxe /build/ipxe.efi /tftproot/ +COPY --from=config-renderer /rendered/boot.ipxe /tftproot/ +COPY --from=config-renderer /rendered/dnsmasq.conf /dnsmasq.conf + +EXPOSE 67/udp +EXPOSE 69/udp + +CMD ["dnsmasq", "--conf-file=/dnsmasq.conf", "--keep-in-foreground", "--user=root", "--log-facility=-", "--port=0"] diff --git a/dhcp.yaml b/dhcp.yaml new file mode 100644 index 0000000..183bc1f --- /dev/null +++ b/dhcp.yaml @@ -0,0 +1 @@ +tftpIp: 10.0.0.3 diff --git a/generate.sh b/generate.sh new file mode 100755 index 0000000..1c6d211 --- /dev/null +++ b/generate.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euxo pipefail +SCRIPT_DIR=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")") + +${SCRIPT_DIR}/tools/merge.py ./nodes | gomplate -d nodes=stdin://nodes.json -d dhcp=${SCRIPT_DIR}/dhcp.yaml --input-dir ${SCRIPT_DIR}/templates --output-dir ${SCRIPT_DIR}/rendered diff --git a/nodes/_defaults.yaml b/nodes/_defaults.yaml new file mode 100644 index 0000000..72530cc --- /dev/null +++ b/nodes/_defaults.yaml @@ -0,0 +1,9 @@ +schematicID: !schematic "_schematic.yaml" +arch: amd64 +talosVersion: v1.11.3 +kernelArgs: talos.platform=metal console=tty0 init_on_alloc=1 slab_nomerge pti=on consoleblank=0 nvme_core.io_timeout=4294967295 printk.devkmsg=on selinux=1 lockdown=confidentiality +dns0: 1.1.1.1 +dns1: 8.8.8.8 +ntp: nl.pool.ntp.org +install: false +upgradeIPXE: false diff --git a/nodes/_schematic.yaml b/nodes/_schematic.yaml new file mode 100644 index 0000000..c766285 --- /dev/null +++ b/nodes/_schematic.yaml @@ -0,0 +1,5 @@ +customization: + systemExtensions: + officialExtensions: + - siderolabs/iscsi-tools + - siderolabs/util-linux-tools diff --git a/nodes/production/_defaults.yaml b/nodes/production/_defaults.yaml new file mode 100644 index 0000000..6dc5f34 --- /dev/null +++ b/nodes/production/_defaults.yaml @@ -0,0 +1,3 @@ +netmask: 255.255.252.0 +gateway: 10.0.0.1 +install: true diff --git a/nodes/production/helios.yaml b/nodes/production/helios.yaml new file mode 100644 index 0000000..4a048d6 --- /dev/null +++ b/nodes/production/helios.yaml @@ -0,0 +1,3 @@ +serial: 5CZ7NX2 +interface: enp2s0 +ip: 10.0.0.202 diff --git a/nodes/production/hyperion.yaml b/nodes/production/hyperion.yaml new file mode 100644 index 0000000..b856b0f --- /dev/null +++ b/nodes/production/hyperion.yaml @@ -0,0 +1,3 @@ +serial: F3PKRH2 +interface: enp3s0 +ip: 10.0.0.201 diff --git a/nodes/production/selene.yaml b/nodes/production/selene.yaml new file mode 100644 index 0000000..c7ca14d --- /dev/null +++ b/nodes/production/selene.yaml @@ -0,0 +1,3 @@ +serial: J33CHY2 +interface: enp2s0 +ip: 10.0.0.203 diff --git a/nodes/vm/_defaults.yaml b/nodes/vm/_defaults.yaml new file mode 100644 index 0000000..1066e5a --- /dev/null +++ b/nodes/vm/_defaults.yaml @@ -0,0 +1,3 @@ +netmask: 255.255.255.0 +gateway: 192.168.1.1 +upgradeIPXE: ipxe.pxe diff --git a/nodes/vm/vm.yaml b/nodes/vm/vm.yaml new file mode 100644 index 0000000..a3e9b55 --- /dev/null +++ b/nodes/vm/vm.yaml @@ -0,0 +1,4 @@ +serial: vm +interface: enp1s0 +ip: 192.168.1.2 +install: true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..769d3cf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PyYAML==6.0.3 +requests==2.32.5 diff --git a/templates/boot.ipxe b/templates/boot.ipxe new file mode 100644 index 0000000..d8841b2 --- /dev/null +++ b/templates/boot.ipxe @@ -0,0 +1,36 @@ +#!ipxe + +dhcp + +:start +# Is a known serial is set, execute that +# If an unknown serial is set, exit +# If no serial is set, ask the user +goto node_${serial} || goto manual +# Default behavior (non install mode) is to exit iPXE script + +{{ range (datasource "nodes" | jsonArray) }} +{{- if .install }} +# {{ .filename }} +:node_{{ .serial }} +{{- $ipArg := printf "ip=%s::%s:%s:%s:%s::%s:%s:%s" .ip .gateway .netmask .hostname .interface .dns0 .dns1 .ntp }} +{{- $kernelArgs := printf "%s %s" $ipArg .kernelArgs }} +imgfree +kernel https://pxe.factory.talos.dev/image/{{ .schematicID }}/{{ .talosVersion }}/kernel-{{ .arch }} {{ $kernelArgs }} {{- if .upgradeIPXE }} || boot {{ .upgradeIPXE }} {{- end }} +initrd https://pxe.factory.talos.dev/image/{{ .schematicID }}/{{ .talosVersion }}/initramfs-{{ .arch }}.xz +boot +{{- end }} +{{ end }} + +:manual +menu Select node +{{ range (datasource "nodes" | jsonArray) }} +item {{ .serial }} {{ .hostname }} +{{ end }} +choose selected || goto cancel +goto node_${selected} + +:cancel +echo Type exit to restart script +shell +goto start diff --git a/templates/dnsmasq.conf b/templates/dnsmasq.conf new file mode 100644 index 0000000..8bd1d9c --- /dev/null +++ b/templates/dnsmasq.conf @@ -0,0 +1,40 @@ +{{ $tftpIp := (ds "dhcp").tftpIp -}} + +enable-tftp +tftp-root=/tftproot +tftp-single-port + +dhcp-vendorclass=BIOS,PXEClient:Arch:00000 +dhcp-vendorclass=UEFI,PXEClient:Arch:00007 +dhcp-vendorclass=UEFI64,PXEClient:Arch:00009 + +# 1st stage: pxe rom boot on ipxe +dhcp-boot=net:BIOS,ipxe.pxe,{{ $tftpIp }},{{ $tftpIp }} +dhcp-boot=net:UEFI,ipxe.efi,{{ $tftpIp }},{{ $tftpIp }} +dhcp-boot=net:UEFI64,ipxe.efi,{{ $tftpIp }},{{ $tftpIp }} + +# Based on logic in https://gist.github.com/robinsmidsrod/4008017 +# iPXE sends a 175 option, checking suboptions +dhcp-match=set:ipxe-http,175,19 +dhcp-match=set:ipxe-https,175,20 +dhcp-match=set:ipxe-menu,175,39 + +dhcp-match=set:ipxe-pxe,175,33 +dhcp-match=set:ipxe-bzimage,175,24 +dhcp-match=set:ipxe-iscsi,175,17 + +dhcp-match=set:ipxe-efi,175,36 + +# set ipxe-ok tag if we have correct combination +tag-if=set:ipxe-ok,tag:ipxe-http,tag:ipxe-https + +# these create option 43 cruft, which is required in proxy mode +# TFTP IP is required on all dhcp-boot lines (unless dnsmasq itself acts as tftp server?) +pxe-service=tag:!ipxe-ok,X86PC,PXE,undionly.kpxe,{{ $tftpIp }} +pxe-service=tag:!ipxe-ok,IA32_EFI,PXE,snponlyx32.efi,{{ $tftpIp }} +pxe-service=tag:!ipxe-ok,BC_EFI,PXE,snponly.efi,{{ $tftpIp }} +pxe-service=tag:!ipxe-ok,X86-64_EFI,PXE,snponly.efi,{{ $tftpIp }} + +# later match overrides previous, keep ipxe script last +# server address must be non zero, but can be anything as long as iPXE script is not fetched over TFTP +dhcp-boot=tag:ipxe-ok,boot.ipxe,,{{ $tftpIp }} diff --git a/tools/merge.py b/tools/merge.py new file mode 100755 index 0000000..0df3d6d --- /dev/null +++ b/tools/merge.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 + +# Adapted from: https://enix.io/en/blog/pxe-talos/ + +import argparse +import functools +import json +import pathlib + +import requests +import yaml + + +@functools.cache +def get_schematic_id(schematic: str): + """Lookup the schematic id associated with a given schematic""" + r = requests.post("https://factory.talos.dev/schematics", data=schematic) + r.raise_for_status() + data = r.json() + return data["id"] + + +def schematic_constructor(directory: pathlib.Path): + """Load specified schematic file and get the assocatied schematic id""" + + def constructor(loader: yaml.SafeLoader, node: yaml.nodes.ScalarNode): + filename = str(loader.construct_scalar(node)) + try: + schematic = directory.joinpath(filename).read_text() + return get_schematic_id(schematic) + except Exception: + raise yaml.MarkedYAMLError("Failed to load schematic", node.start_mark) + + return constructor + + +def get_loader(directory: pathlib.Path): + """Add special constructors to yaml loader""" + loader = yaml.SafeLoader + loader.add_constructor("!schematic", schematic_constructor(directory)) + + return loader + + +@functools.cache +def get_defaults(directory: pathlib.Path, root: pathlib.Path): + """Compute the defaults from the provided directory and parents.""" + try: + with open(directory.joinpath("_defaults.yaml")) as fyaml: + yml_data = yaml.load(fyaml, Loader=get_loader(directory)) + except OSError: + yml_data = {} + + # Stop recursion when reaching root directory + if directory != root: + return get_defaults(directory.parent, root) | yml_data + else: + return yml_data + + +def walk_files(root: pathlib.Path): + """Get all files that do not start with and underscore""" + for dirpath, _dirnames, filenames in root.walk(): + for fn in filenames: + if not fn.startswith("_"): + yield dirpath.joinpath(fn) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("directory", type=pathlib.Path) + parser.add_argument("-f", "--filter") + args = parser.parse_args() + + data = [] + for fullname in walk_files(args.directory): + filename = ( + str(fullname.relative_to(args.directory).parent) + "/" + fullname.stem + ) + + if args.filter is not None and not filename.startswith(args.filter): + continue + + with open(fullname) as fyaml: + yml_data = yaml.load(fyaml, Loader=get_loader(fullname.parent)) + yml_data = get_defaults(fullname.parent, args.directory) | yml_data + yml_data["hostname"] = fullname.stem + yml_data["filename"] = filename + data.append(yml_data) + + # Dump everything to json + print(json.dumps(data)) + + +if __name__ == "__main__": + main() diff --git a/vm/cluster-vm.xml b/vm/cluster-vm.xml new file mode 100644 index 0000000..5806cd6 --- /dev/null +++ b/vm/cluster-vm.xml @@ -0,0 +1,13 @@ + + cluster-vm + + + + + + + + + + + diff --git a/vm/create.sh b/vm/create.sh new file mode 100755 index 0000000..ed31df2 --- /dev/null +++ b/vm/create.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +SCRIPT_DIR=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")") +source ${SCRIPT_DIR}/helper.sh + +if [[ $(virsh --connect="${CONNECTION}" net-list --all | grep -c "${NETWORK}") == "0" ]]; then + virsh --connect="${CONNECTION}" net-define "${SCRIPT_DIR}/${NETWORK}.xml" + virsh --connect="${CONNECTION}" net-start "${NETWORK}" + virsh --connect="${CONNECTION}" net-autostart "${NETWORK}" +fi + +virt-install --connect="${CONNECTION}" --name="${VM_NAME}" --vcpus="${VCPUS}" --memory="${RAM_MB}" \ + --os-variant="linux2022" \ + --disk="size=${DISK_GB}" \ + --pxe \ + --network network="${NETWORK}" diff --git a/vm/destroy.sh b/vm/destroy.sh new file mode 100755 index 0000000..efac38b --- /dev/null +++ b/vm/destroy.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +SCRIPT_DIR=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")") +source ${SCRIPT_DIR}/helper.sh + +virsh --connect="${CONNECTION}" destroy "${VM_NAME}" +virsh --connect="${CONNECTION}" undefine "${VM_NAME}" --remove-all-storage +virsh --connect="${CONNECTION}" net-destroy "${NETWORK}" +virsh --connect="${CONNECTION}" net-undefine "${NETWORK}" diff --git a/vm/helper.sh b/vm/helper.sh new file mode 100644 index 0000000..6f88d97 --- /dev/null +++ b/vm/helper.sh @@ -0,0 +1,10 @@ +set -euxo pipefail +VM_NAME="test" +VCPUS="2" +RAM_MB="2048" +DISK_GB="10" +NETWORK=cluster-vm +CONNECTION="qemu:///system" + +IPXE_VERSION=b41bda4413bf286d7b7a449bc05e1531da1eec2e +IPXE_BIN=bin/ipxe.pxe diff --git a/vm/start.sh b/vm/start.sh new file mode 100755 index 0000000..4023c85 --- /dev/null +++ b/vm/start.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +SCRIPT_DIR=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")") +source ${SCRIPT_DIR}/helper.sh + +virsh --connect="${CONNECTION}" start ${VM_NAME} +virt-viewer --connect="${CONNECTION}" ${VM_NAME} +virsh --connect="${CONNECTION}" shutdown ${VM_NAME} diff --git a/vm/tftp.sh b/vm/tftp.sh new file mode 100755 index 0000000..c43358d --- /dev/null +++ b/vm/tftp.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +SCRIPT_DIR=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")") +source ${SCRIPT_DIR}/helper.sh + +TFTP_DIR=${SCRIPT_DIR}/../tftp +rm -rf "${TFTP_DIR}" +mkdir -p "${TFTP_DIR}" + +IPXE_DIR=${SCRIPT_DIR}/../ipxe +IPXE_FILE=${IPXE_DIR}/ipxe-${IPXE_VERSION}/src/${IPXE_BIN} +if [ ! -f "${IPXE_FILE}" ]; then + mkdir -p "${IPXE_DIR}" + rm -rf "${IPXE_DIR}/ipxe-${IPXE_VERSION}" + curl -L https://github.com/ipxe/ipxe/archive/${IPXE_VERSION}.tar.gz | tar -xz -C "${IPXE_DIR}" + cd "${IPXE_DIR}/ipxe-${IPXE_VERSION}/src" + sed -i 's/^#undef[\t ]DOWNLOAD_PROTO_HTTPS.*$/#define DOWNLOAD_PROTO_HTTPS/g' config/general.h + make -j$(nproc) ${IPXE_BIN} + cd - +fi + +${SCRIPT_DIR}/../generate.sh + +cp ${SCRIPT_DIR}/../rendered/boot.ipxe ${TFTP_DIR} +cp ${IPXE_FILE} ${TFTP_DIR} + +sudo in.tftpd -L --secure ./tftp