Netboot installer: TFTP/HTTP servers, initramfs, EEPROM artifacts, plan
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
configs/node-password.txt
|
||||||
|
.synology-password
|
||||||
168
configs/installer-init.sh
Normal file
168
configs/installer-init.sh
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
#!/bin/busybox sh
|
||||||
|
# planck netboot installer - runs from initramfs, wipes /dev/sda, installs
|
||||||
|
# Raspberry Pi OS Lite from HTTP template, then reboots. The bootloader's
|
||||||
|
# self-update then applies the per-node pieeprom-revert.upd we PUT to the
|
||||||
|
# TFTP server, reverting boot order to USB-first, and the fresh OS boots.
|
||||||
|
export PATH=/bin:/sbin:/usr/bin:/usr/sbin
|
||||||
|
/bin/busybox --install -s /bin
|
||||||
|
|
||||||
|
SERVER=192.168.1.157
|
||||||
|
HTTP=http://$SERVER:8000
|
||||||
|
|
||||||
|
mkdir -p /proc /sys /dev /tmp /mnt/root /mnt/boot /mnt/stage /chk /conf
|
||||||
|
mount -t proc proc /proc
|
||||||
|
mount -t sysfs sysfs /sys
|
||||||
|
mount -t devtmpfs devtmpfs /dev 2>/dev/null || true
|
||||||
|
|
||||||
|
log() { echo "[planck-installer] $*"; }
|
||||||
|
fail() { log "FATAL: $*"; sleep 3600; reboot -f; }
|
||||||
|
report() { wget -q -O /dev/null "$HTTP/events/$1" 2>/dev/null; }
|
||||||
|
|
||||||
|
log "planck installer starting"
|
||||||
|
report "started"
|
||||||
|
|
||||||
|
# --- identify this node -------------------------------------------------
|
||||||
|
MAC=$(cat /sys/class/net/eth0/address)
|
||||||
|
SERIAL=$(awk '/Serial/ {print $3}' /proc/cpuinfo)
|
||||||
|
HOST=$(awk -v s="$SERIAL" 'tolower($1)==tolower(s) {print $2}' /conf/hostmap)
|
||||||
|
if [ -z "$HOST" ]; then
|
||||||
|
log "unknown serial $SERIAL (mac $MAC) - not in hostmap, aborting"
|
||||||
|
report "unknown-serial-$SERIAL"
|
||||||
|
sleep 3600
|
||||||
|
reboot -f
|
||||||
|
fi
|
||||||
|
log "identity: $HOST serial=$SERIAL mac=$MAC"
|
||||||
|
report "$HOST-start"
|
||||||
|
|
||||||
|
# --- network ------------------------------------------------------------
|
||||||
|
cat > /etc/udhcpc.script <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
[ "$1" = bound ] || exit 0
|
||||||
|
ifconfig "$interface" "$ip" netmask "$subnet" 2>/dev/null
|
||||||
|
[ -n "$router" ] && route add default gw "$router" 2>/dev/null
|
||||||
|
EOF
|
||||||
|
chmod +x /etc/udhcpc.script
|
||||||
|
ifconfig eth0 up
|
||||||
|
udhcpc -i eth0 -n -q -s /etc/udhcpc.script >/dev/null 2>&1 && log "dhcp ok" || log "dhcp failed (continuing)"
|
||||||
|
IP=$(ip -4 -o addr show eth0 | awk '{print $4}' | cut -d/ -f1)
|
||||||
|
log "ip: ${IP:-none}"
|
||||||
|
report "$HOST-ip-${IP:-none}"
|
||||||
|
|
||||||
|
# --- idempotence: if a planck OS is already installed, just boot it -----
|
||||||
|
if [ -b /dev/sda2 ]; then
|
||||||
|
if mount -t ext4 /dev/sda2 /chk 2>/dev/null; then
|
||||||
|
if [ -f /chk/etc/hostname ] && grep -q "^planck" /chk/etc/hostname 2>/dev/null; then
|
||||||
|
log "planck OS already installed on sda2 - booting it directly"
|
||||||
|
report "$HOST-reentry"
|
||||||
|
mount -t vfat /dev/sda1 /chk/boot/firmware 2>/dev/null
|
||||||
|
cd /
|
||||||
|
exec switch_root /chk /sbin/init
|
||||||
|
fi
|
||||||
|
umount /chk
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- fetch template -----------------------------------------------------
|
||||||
|
log "downloading template (this takes a minute)..."
|
||||||
|
wget -q -O /tmp/rootfs.tar "$HTTP/rootfs.tar" || fail "template download"
|
||||||
|
log "template downloaded: $(du -h /tmp/rootfs.tar | cut -f1)"
|
||||||
|
report "$HOST-template-ok"
|
||||||
|
|
||||||
|
# --- partition + format -------------------------------------------------
|
||||||
|
log "partitioning /dev/sda"
|
||||||
|
dd if=/dev/zero of=/dev/sda bs=1M count=20 2>/dev/null
|
||||||
|
sfdisk /dev/sda >/dev/null 2>&1 <<'EOF'
|
||||||
|
label: dos
|
||||||
|
start=2048, size=524288, type=0c, bootable
|
||||||
|
type=83
|
||||||
|
EOF
|
||||||
|
[ $? -eq 0 ] || fail "sfdisk"
|
||||||
|
mke2fs -F -t ext4 -L ROOTFS /dev/sda2 >/dev/null 2>&1 || fail "mkfs.ext4"
|
||||||
|
mkfs.vfat -F 32 -n BOOTFS /dev/sda1 >/dev/null 2>&1 || fail "mkfs.vfat"
|
||||||
|
mount -t ext4 /dev/sda2 /mnt/root || fail "mount root"
|
||||||
|
mount -t vfat /dev/sda1 /mnt/boot || fail "mount boot"
|
||||||
|
log "filesystems ready"
|
||||||
|
|
||||||
|
# --- extract ------------------------------------------------------------
|
||||||
|
mkdir -p /mnt/stage
|
||||||
|
tar xf /tmp/rootfs.tar -C /mnt/stage || fail "untar"
|
||||||
|
cp -a /mnt/stage/root/. /mnt/root/ || fail "copy root"
|
||||||
|
cp -a /mnt/stage/boot/. /mnt/boot/ || fail "copy boot"
|
||||||
|
rm -f /tmp/rootfs.tar
|
||||||
|
rm -rf /mnt/stage
|
||||||
|
log "rootfs + boot files extracted"
|
||||||
|
report "$HOST-extracted"
|
||||||
|
|
||||||
|
# --- configure ----------------------------------------------------------
|
||||||
|
echo "$HOST" > /mnt/root/etc/hostname
|
||||||
|
grep -q "127.0.1.1" /mnt/root/etc/hosts 2>/dev/null || echo "127.0.1.1 localhost" > /mnt/root/etc/hosts
|
||||||
|
echo "127.0.1.1 $HOST" >> /mnt/root/etc/hosts
|
||||||
|
cat > /mnt/root/etc/fstab <<'EOF'
|
||||||
|
/dev/sda1 /boot/firmware vfat defaults 0 2
|
||||||
|
/dev/sda2 / ext4 defaults,noatime 0 1
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# user + keys (SSH-key-only: password locked, sudo NOPASSWD)
|
||||||
|
PASSWD=$(cat /conf/nodepass)
|
||||||
|
mount -t proc proc /mnt/root/proc 2>/dev/null
|
||||||
|
chroot /mnt/root /bin/sh -c "
|
||||||
|
set -e
|
||||||
|
useradd -m -s /bin/bash -G sudo,adm,dialout,plugdev,input,netdev,render,games,users,video adamcarr
|
||||||
|
echo \"adamcarr:$PASSWD\" | chpasswd
|
||||||
|
mkdir -p /home/adamcarr/.ssh
|
||||||
|
cp /conf/authorized_keys /home/adamcarr/.ssh/authorized_keys
|
||||||
|
chown -R adamcarr:adamcarr /home/adamcarr/.ssh
|
||||||
|
chmod 700 /home/adamcarr/.ssh
|
||||||
|
chmod 600 /home/adamcarr/.ssh/authorized_keys
|
||||||
|
echo 'adamcarr ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/010-adamcarr
|
||||||
|
chmod 440 /etc/sudoers.d/010-adamcarr
|
||||||
|
" || fail "user setup"
|
||||||
|
umount /mnt/root/proc 2>/dev/null
|
||||||
|
|
||||||
|
# ssh: fresh host keys per node, service enabled
|
||||||
|
rm -f /mnt/root/etc/ssh/ssh_host_*
|
||||||
|
chroot /mnt/root /bin/sh -c "ssh-keygen -A" || fail "ssh host keys"
|
||||||
|
ln -sf /usr/lib/systemd/system/ssh.service /mnt/root/etc/systemd/system/multi-user.target.wants/ssh.service
|
||||||
|
|
||||||
|
# firstboot service: catch-up EEPROM updates + tidy markers
|
||||||
|
cat > /mnt/root/usr/local/sbin/planck-firstboot.sh <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
rpi-eeprom-update -a >/dev/null 2>&1 || true
|
||||||
|
rm -f /boot/firmware/pieeprom-revert.upd /boot/firmware/pieeprom-revert.sig
|
||||||
|
rm -f /etc/planck-fresh-install
|
||||||
|
systemctl disable planck-firstboot.service >/dev/null 2>&1 || true
|
||||||
|
EOF
|
||||||
|
chmod +x /mnt/root/usr/local/sbin/planck-firstboot.sh
|
||||||
|
cat > /mnt/root/etc/systemd/system/planck-firstboot.service <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=planck firstboot tidy-up
|
||||||
|
After=multi-user.target
|
||||||
|
ConditionPathExists=/etc/planck-fresh-install
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/planck-firstboot.sh
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
ln -sf /etc/systemd/system/planck-firstboot.service /mnt/root/etc/systemd/system/multi-user.target.wants/planck-firstboot.service
|
||||||
|
touch /mnt/root/etc/planck-fresh-install
|
||||||
|
|
||||||
|
# cmdline for the installed OS
|
||||||
|
printf 'console=serial0,115200 console=tty1 root=/dev/sda2 rootfstype=ext4 fsck.repair=yes rootwait quiet\n' > /mnt/boot/cmdline.txt
|
||||||
|
|
||||||
|
# stage eeprom revert on the new boot partition too (ts-guard prevents reflash)
|
||||||
|
cp /conf/pieeprom-revert.upd /conf/pieeprom-revert.sig /mnt/boot/ 2>/dev/null
|
||||||
|
|
||||||
|
sync
|
||||||
|
report "$HOST-installed"
|
||||||
|
|
||||||
|
# --- hand revert EEPROM to bootloader via TFTP self-update --------------
|
||||||
|
# busybox wget can only POST, so the helper server treats POST as upload.
|
||||||
|
wget -q -O /dev/null --post-file=/conf/pieeprom-revert.upd "$HTTP/tftp/$SERIAL/pieeprom.upd" 2>/dev/null || fail "revert PUT"
|
||||||
|
wget -q -O /dev/null --post-file=/conf/pieeprom-revert.sig "$HTTP/tftp/$SERIAL/pieeprom.sig" 2>/dev/null || fail "revert sig PUT"
|
||||||
|
|
||||||
|
log "install complete - rebooting"
|
||||||
|
sleep 3
|
||||||
|
reboot -f
|
||||||
83
configs/planck-http-server.py
Normal file
83
configs/planck-http-server.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""planck netboot helper: GET template tarball, PUT/GET per-node install artifacts.
|
||||||
|
|
||||||
|
PUTs land under PUT_ROOT (tftp/<serial>/...) for bootloader self-update.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
BASE = "/volume1/plancknetboot"
|
||||||
|
PUT_ROOT = os.path.join(BASE, "tftp")
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def _safe_target(self, sub):
|
||||||
|
# each path component must be [A-Za-z0-9._-], no traversal
|
||||||
|
parts = sub.split("/")
|
||||||
|
if not parts or any(not re.fullmatch(r"[A-Za-z0-9._-]+", p) for p in parts):
|
||||||
|
return None
|
||||||
|
return sub
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.startswith("/events/"):
|
||||||
|
name = self._safe_target(self.path[len("/events/"):].split("?")[0])
|
||||||
|
if not name:
|
||||||
|
self.send_error(403)
|
||||||
|
return
|
||||||
|
with open(os.path.join(PUT_ROOT, "events.log"), "a") as log:
|
||||||
|
log.write(f"{self.log_date_time_string()} EVENT {name}\n")
|
||||||
|
self.send_response(204)
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
name = self._safe_target(self.path.lstrip("/"))
|
||||||
|
if not name:
|
||||||
|
self.send_error(403)
|
||||||
|
return
|
||||||
|
path = os.path.join(BASE, name)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/octet-stream")
|
||||||
|
self.send_header("Content-Length", str(size))
|
||||||
|
self.end_headers()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
while chunk := f.read(1024 * 1024):
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
name = self._safe_target(self.path.lstrip("/"))
|
||||||
|
if not name:
|
||||||
|
self.send_error(403)
|
||||||
|
return
|
||||||
|
path = os.path.join(PUT_ROOT, name)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
if length > 512 * 1024 * 1024:
|
||||||
|
self.send_error(413)
|
||||||
|
return
|
||||||
|
with open(path + ".part", "wb") as f:
|
||||||
|
remaining = length
|
||||||
|
while remaining > 0:
|
||||||
|
chunk = self.rfile.read(min(1024 * 1024, remaining))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
f.write(chunk)
|
||||||
|
remaining -= len(chunk)
|
||||||
|
os.rename(path + ".part", path)
|
||||||
|
self.send_response(201)
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
with open(os.path.join(PUT_ROOT, "events.log"), "a") as log:
|
||||||
|
log.write(f"{self.log_date_time_string()} PUT {name} {length}\n")
|
||||||
|
|
||||||
|
do_POST = do_PUT
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
|
||||||
11
configs/planck-netboot.service
Normal file
11
configs/planck-netboot.service
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=planck cluster netboot helper (template GET / per-node PUT)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/python3 /volume1/plancknetboot/planck-http-server.py
|
||||||
|
Restart=always
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
150
configs/planck-tftp-server.py
Normal file
150
configs/planck-tftp-server.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Minimal TFTP server (RRQ only) for planck netboot. Supports blksize and
|
||||||
|
windowsize options per RFC 2347/2348/2349. Serves files under TFTP_ROOT."""
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
TFTP_ROOT = "/volume1/plancknetboot/tftp"
|
||||||
|
PORT = 69
|
||||||
|
BLKSIZE_MAX = 1468 # avoids IP fragmentation on ethernet
|
||||||
|
DEFAULT_BLKSIZE = 512
|
||||||
|
DEFAULT_WINDOW = 1
|
||||||
|
TIMEOUT = 5
|
||||||
|
RETRIES = 6
|
||||||
|
|
||||||
|
OP_RRQ = 1
|
||||||
|
OP_DATA = 3
|
||||||
|
OP_ACK = 4
|
||||||
|
OP_ERROR = 5
|
||||||
|
OP_OACK = 6
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rrq(data):
|
||||||
|
# opcode, filename, mode, optional opts
|
||||||
|
parts = data[2:].split(b"\x00")
|
||||||
|
filename = parts[0].decode(errors="replace")
|
||||||
|
opts = {}
|
||||||
|
rest = parts[2:]
|
||||||
|
for i in range(0, len(rest) - 1, 2):
|
||||||
|
try:
|
||||||
|
opts[rest[i].decode().lower()] = rest[i + 1].decode()
|
||||||
|
except (IndexError, UnicodeDecodeError):
|
||||||
|
pass
|
||||||
|
return filename, opts
|
||||||
|
|
||||||
|
|
||||||
|
def safe_path(filename):
|
||||||
|
parts = filename.split("/")
|
||||||
|
if any(not p or p.startswith(".") or "/" in p for p in parts):
|
||||||
|
return None
|
||||||
|
if any(not all(c.isalnum() or c in "._-" for c in p) for p in parts):
|
||||||
|
return None
|
||||||
|
path = os.path.join(TFTP_ROOT, *parts)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def send_err(sock, addr, code, msg):
|
||||||
|
sock.sendto(struct.pack("!HH", OP_ERROR, code) + msg.encode() + b"\x00", addr)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_client(sock, data, addr, filepath, opts):
|
||||||
|
blksize = DEFAULT_BLKSIZE
|
||||||
|
window = DEFAULT_WINDOW
|
||||||
|
requested = {}
|
||||||
|
if "blksize" in opts:
|
||||||
|
try:
|
||||||
|
blksize = min(BLKSIZE_MAX, max(512, int(opts["blksize"])))
|
||||||
|
requested["blksize"] = str(blksize)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if "windowsize" in opts:
|
||||||
|
try:
|
||||||
|
window = min(64, max(1, int(opts["windowsize"])))
|
||||||
|
requested["windowsize"] = str(window)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
size = os.path.getsize(filepath)
|
||||||
|
if requested:
|
||||||
|
payload = b"\x00".join(
|
||||||
|
k.encode() + b"\x00" + v.encode() for k, v in requested.items()
|
||||||
|
)
|
||||||
|
sock.sendto(struct.pack("!H", OP_OACK) + payload + b"\x00", addr)
|
||||||
|
else:
|
||||||
|
blksize = 512
|
||||||
|
window = 1
|
||||||
|
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
block = 0
|
||||||
|
eof = False
|
||||||
|
while not eof:
|
||||||
|
# send one window
|
||||||
|
for _ in range(window):
|
||||||
|
block = (block % 65536) + 1
|
||||||
|
chunk = f.read(blksize)
|
||||||
|
pkt = struct.pack("!HH", OP_DATA, block) + chunk
|
||||||
|
sock.sendto(pkt, addr)
|
||||||
|
if len(chunk) < blksize:
|
||||||
|
eof = True
|
||||||
|
# wait for the ACK of the last block of this window
|
||||||
|
expect = block
|
||||||
|
retries = 0
|
||||||
|
while True:
|
||||||
|
sock.settimeout(TIMEOUT)
|
||||||
|
try:
|
||||||
|
rdata, raddr = sock.recvfrom(4 + blksize)
|
||||||
|
except socket.timeout:
|
||||||
|
retries += 1
|
||||||
|
if retries > RETRIES:
|
||||||
|
return
|
||||||
|
# retransmit window
|
||||||
|
f.seek((expect - window) * blksize if expect >= window else 0)
|
||||||
|
block = expect - window if expect >= window else 0
|
||||||
|
eof = os.path.getsize(filepath) <= f.tell() + window * blksize
|
||||||
|
break
|
||||||
|
if len(rdata) >= 4 and struct.unpack("!H", rdata[:2])[0] == OP_ACK:
|
||||||
|
acked = struct.unpack("!H", rdata[2:4])[0]
|
||||||
|
if acked == expect:
|
||||||
|
break
|
||||||
|
elif acked < expect:
|
||||||
|
continue # stale ack
|
||||||
|
else:
|
||||||
|
continue # shouldn't happen
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
srv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
srv.bind(("0.0.0.0", PORT))
|
||||||
|
print(f"planck-tftp: serving {TFTP_ROOT} on :{PORT}", flush=True)
|
||||||
|
while True:
|
||||||
|
data, addr = srv.recvfrom(1024)
|
||||||
|
if len(data) < 4 or struct.unpack("!H", data[:2])[0] != OP_RRQ:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
filename, opts = parse_rrq(data)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
filepath = safe_path(filename)
|
||||||
|
if filepath is None:
|
||||||
|
errsock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
send_err(errsock, addr, 1, "file not found")
|
||||||
|
errsock.close()
|
||||||
|
print(f"planck-tftp: RRQ denied {filename} from {addr}", flush=True)
|
||||||
|
continue
|
||||||
|
csock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
csock.bind(("0.0.0.0", 0))
|
||||||
|
print(f"planck-tftp: RRQ {filename} from {addr}", flush=True)
|
||||||
|
threading.Thread(
|
||||||
|
target=handle_client, args=(csock, data, addr, filepath, opts),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
11
configs/planck-tftp.service
Normal file
11
configs/planck-tftp.service
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=planck cluster TFTP server (netboot firmware)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/python3 /volume1/plancknetboot/planck-tftp-server.py
|
||||||
|
Restart=always
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
30
docs/PLAN.md
30
docs/PLAN.md
@@ -28,6 +28,36 @@
|
|||||||
- Synology NAS on same LAN — shared storage target (NFS) for cluster
|
- Synology NAS on same LAN — shared storage target (NFS) for cluster
|
||||||
workloads
|
workloads
|
||||||
|
|
||||||
|
## Rebuild architecture (decided 2026-09-19)
|
||||||
|
|
||||||
|
Fully unattended network reimage, no per-node physical access:
|
||||||
|
|
||||||
|
- **Synology** (`192.168.1.157`) hosts three services (all auto-start, see
|
||||||
|
`configs/`):
|
||||||
|
- `planck-tftp.service` — custom python TFTP server on :69 serving
|
||||||
|
`/volume1/plancknetboot/tftp/` (firmware, kernel, installer initramfs)
|
||||||
|
- `planck-netboot.service` — python HTTP helper on :8000 (GET `rootfs.tar`
|
||||||
|
template, GET `/events/<name>` progress log, POST = upload)
|
||||||
|
- NFS read-only export of the share for future use
|
||||||
|
- **Installer initramfs** (`configs/installer-init.sh`): busybox + sfdisk +
|
||||||
|
e2fsprogs. Identifies the node by CPU serial (map in
|
||||||
|
`configs/hostmap`), wipes `/dev/sda`, downloads the Debian 13 Raspberry Pi
|
||||||
|
OS Lite template over HTTP, installs it, sets hostname/ssh keys/user,
|
||||||
|
POSTs `pieeprom-revert.upd/.sig` to `tftp/<serial>/`, reboots.
|
||||||
|
- **Bootloader flow**: nodes' EEPROM is flashed (via staged `pieeprom.upd`
|
||||||
|
from the old OS) to install-mode: `BOOT_ORDER=0xf142` (network first),
|
||||||
|
`TFTP_IP=192.168.1.157`, `TFTP_PREFIX=1` (per-serial dirs),
|
||||||
|
`ENABLE_SELF_UPDATE=1`. After install, the TFTP-served
|
||||||
|
`bootloader_update=1` config + `<serial>/pieeprom.upd` reverts the node to
|
||||||
|
`BOOT_ORDER=0xf14` (USB first). Fresh OS boots from SSD; firstboot service
|
||||||
|
tidies up.
|
||||||
|
- **Reinstall a node anytime**: stage install-mode EEPROM again + reboot
|
||||||
|
(delete `tftp/<serial>/pieeprom.*` on the Synology first if it exists).
|
||||||
|
- Node password: see `configs/node-password.txt` (gitignored). SSH is
|
||||||
|
key-based; password exists for console/sudo recovery.
|
||||||
|
- UniFi MFA blocks API access; DHCP reservations deferred — mDNS
|
||||||
|
(`planck0NN.local`) works across subnets today.
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
- [x] Phase 0 — Access + inventory (SSH keys on all 20, docs/)
|
- [x] Phase 0 — Access + inventory (SSH keys on all 20, docs/)
|
||||||
|
|||||||
Reference in New Issue
Block a user