Add fully automated Debian 13 LUKS encrypted Template build
This commit is contained in:
parent
40a0623ad0
commit
f76fd4a95a
5 changed files with 215 additions and 3 deletions
188
_scripts/unlock-luks-after-install.py
Executable file
188
_scripts/unlock-luks-after-install.py
Executable file
|
|
@ -0,0 +1,188 @@
|
||||||
|
#!/usr/bin/env -S uv run
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.15"
|
||||||
|
# dependencies = [
|
||||||
|
# "python-hcl2==4.*",
|
||||||
|
# "requests==2.*",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
|
||||||
|
# See https://discuss.hashicorp.com/t/luks-encryption-key-on-initial-reboot/45459/2 for reference
|
||||||
|
# https://pve.proxmox.com/pve-docs/api-viewer/index.html#/nodes/{node}/qemu/{vmid}/sendkey
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import random
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import hcl2
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
def load_hcl(path: Path) -> dict:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
return hcl2.load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def get_variable_default(hcl_data: dict, name: str) -> str | None:
|
||||||
|
for variable_block in hcl_data.get("variable", []):
|
||||||
|
if name in variable_block:
|
||||||
|
return variable_block[name].get("default")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Unlock LUKS after install via Proxmox API (setup stage)."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-t",
|
||||||
|
"--template",
|
||||||
|
required=True,
|
||||||
|
help="Path to directory containing variables.pkr.hcl (also passed to mise build).",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
script_root = Path(__file__).resolve().parents[1]
|
||||||
|
variables_common_path = script_root / "variables-common.pkr.hcl"
|
||||||
|
credentials_path = script_root / "debian/13-trixie/credentials.auto.pkrvars.hcl"
|
||||||
|
vars_dir = Path(args.template)
|
||||||
|
if not vars_dir.is_absolute():
|
||||||
|
vars_dir = script_root / vars_dir
|
||||||
|
variables_path = vars_dir / "variables.pkr.hcl"
|
||||||
|
|
||||||
|
variables_common = load_hcl(variables_common_path)
|
||||||
|
credentials = load_hcl(credentials_path)
|
||||||
|
variables = load_hcl(variables_path)
|
||||||
|
|
||||||
|
proxmox_api_url = get_variable_default(variables_common, "proxmox_api_url")
|
||||||
|
proxmox_skip_tls_verify = (
|
||||||
|
get_variable_default(variables_common, "proxmox_skip_tls_verify") or False
|
||||||
|
)
|
||||||
|
proxmox_node = get_variable_default(variables, "proxmox_node")
|
||||||
|
template_vm_id = get_variable_default(variables, "template_vm_id")
|
||||||
|
|
||||||
|
_ = proxmox_api_url, proxmox_node, template_vm_id, credentials
|
||||||
|
|
||||||
|
server_event = threading.Event()
|
||||||
|
|
||||||
|
class InstallFinishedHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self) -> None: # noqa: N802 - required by BaseHTTPRequestHandler
|
||||||
|
if self.path != "/install_finished":
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"Not Found")
|
||||||
|
return
|
||||||
|
|
||||||
|
_ = self.rfile.read(int(self.headers.get("Content-Length", "0") or "0"))
|
||||||
|
server_event.set()
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"OK")
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def find_random_port() -> tuple[int, HTTPServer]:
|
||||||
|
ports = list(range(10000, 11000))
|
||||||
|
random.SystemRandom().shuffle(ports)
|
||||||
|
for port in ports:
|
||||||
|
try:
|
||||||
|
server = HTTPServer(("0.0.0.0", port), InstallFinishedHandler)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return port, server
|
||||||
|
raise RuntimeError("No free port found in range 10000-10999")
|
||||||
|
|
||||||
|
port, httpd = find_random_port()
|
||||||
|
|
||||||
|
def serve() -> None:
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
server_thread = threading.Thread(target=serve, daemon=True)
|
||||||
|
server_thread.start()
|
||||||
|
|
||||||
|
print(f"Listening for POST on /install_finished at port {port}")
|
||||||
|
|
||||||
|
build_cmd = ["mise", "build", args.template, "-i", str(port)]
|
||||||
|
build_proc = subprocess.Popen(build_cmd)
|
||||||
|
|
||||||
|
notified = False
|
||||||
|
action_started = False
|
||||||
|
|
||||||
|
def proxmox_request(method: str, path: str, **kwargs) -> requests.Response:
|
||||||
|
if not proxmox_api_url:
|
||||||
|
raise RuntimeError("proxmox_api_url not set")
|
||||||
|
token_id = credentials.get("proxmox_api_token_id")
|
||||||
|
token_secret = credentials.get("proxmox_api_token_secret")
|
||||||
|
if not token_id or not token_secret:
|
||||||
|
raise RuntimeError("Proxmox API token credentials missing")
|
||||||
|
|
||||||
|
headers = kwargs.pop("headers", {})
|
||||||
|
headers["Authorization"] = f"PVEAPIToken={token_id}={token_secret}"
|
||||||
|
url = f"{proxmox_api_url.rstrip('/')}/{path.lstrip('/')}"
|
||||||
|
return requests.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
verify=not proxmox_skip_tls_verify,
|
||||||
|
timeout=30,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_key(key: str) -> None:
|
||||||
|
if not proxmox_node or not template_vm_id:
|
||||||
|
raise RuntimeError("proxmox_node or template_vm_id not set")
|
||||||
|
path = f"/nodes/{proxmox_node}/qemu/{template_vm_id}/sendkey"
|
||||||
|
response = proxmox_request("PUT", path, data={"key": key})
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
def handle_install_finished() -> None:
|
||||||
|
retry_delay = 1
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
response = proxmox_request("GET", "/version")
|
||||||
|
response.raise_for_status()
|
||||||
|
print("Authenticated to Proxmox VE API.")
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Proxmox auth failed: {exc}. Retrying in {retry_delay}s.")
|
||||||
|
time.sleep(retry_delay)
|
||||||
|
retry_delay += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Waiting 45 seconds before sending LUKS password.")
|
||||||
|
time.sleep(45)
|
||||||
|
for char in "packer":
|
||||||
|
send_key(char)
|
||||||
|
time.sleep(0.1)
|
||||||
|
send_key("ret")
|
||||||
|
print("Sent LUKS password and Enter.")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Post-install actions failed after auth: {exc}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if server_event.is_set() and not notified:
|
||||||
|
print("Installation finished.\nRestarting.")
|
||||||
|
notified = True
|
||||||
|
if server_event.is_set() and not action_started:
|
||||||
|
action_started = True
|
||||||
|
threading.Thread(target=handle_install_finished, daemon=True).start()
|
||||||
|
if build_proc.poll() is not None:
|
||||||
|
break
|
||||||
|
server_event.wait(0.2)
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
httpd.server_close()
|
||||||
|
|
||||||
|
return build_proc.returncode or 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
1
debian/13-trixie/debian-trixie.pkr.hcl
vendored
1
debian/13-trixie/debian-trixie.pkr.hcl
vendored
|
|
@ -74,6 +74,7 @@ source "proxmox-iso" "debian-13-trixie" {
|
||||||
boot_command = [
|
boot_command = [
|
||||||
"<wait3>c<wait3>",
|
"<wait3>c<wait3>",
|
||||||
"linux /install.amd/vmlinuz auto-install/enable=true priority=critical ",
|
"linux /install.amd/vmlinuz auto-install/enable=true priority=critical ",
|
||||||
|
"INSTALL_FINISHED_INFORM_URL='http://{{ .HTTPIP }}:${var.install_finished_inform_port}/install_finished' ",
|
||||||
"DEBIAN_FRONTEND=text preseed/url=http://{{ .HTTPIP }}:{{ .HTTPPort }}/preseed.cfg noprompt<enter>",
|
"DEBIAN_FRONTEND=text preseed/url=http://{{ .HTTPIP }}:{{ .HTTPPort }}/preseed.cfg noprompt<enter>",
|
||||||
"initrd /install.amd/initrd.gz<enter>",
|
"initrd /install.amd/initrd.gz<enter>",
|
||||||
"DEBCONF_DEBUG=5<enter>",
|
"DEBCONF_DEBUG=5<enter>",
|
||||||
|
|
|
||||||
7
debian/13-trixie/http/preseed.cfg
vendored
7
debian/13-trixie/http/preseed.cfg
vendored
|
|
@ -153,7 +153,12 @@ d-i grub-installer/bootdev string default
|
||||||
# 2) Enable root ssh login (same intent as your original)
|
# 2) Enable root ssh login (same intent as your original)
|
||||||
d-i preseed/late_command string \
|
d-i preseed/late_command string \
|
||||||
lvremove -f /dev/vg0/reserved || true; \
|
lvremove -f /dev/vg0/reserved || true; \
|
||||||
in-target sed -i 's/^#PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || true
|
in-target sed -i 's/^#PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || true; \
|
||||||
|
in-target curl -X POST "$INSTALL_FINISHED_INFORM_URL"
|
||||||
|
|
||||||
|
# Eject the installation media before rebooting
|
||||||
|
d-i cdrom-detect/eject boolean true
|
||||||
|
d-i cdrom-detect/eject seen true
|
||||||
|
|
||||||
### Finish
|
### Finish
|
||||||
d-i finish-install/reboot_in_progress note
|
d-i finish-install/reboot_in_progress note
|
||||||
|
|
|
||||||
6
debian/13-trixie/variables.pkr.hcl
vendored
6
debian/13-trixie/variables.pkr.hcl
vendored
|
|
@ -68,6 +68,12 @@ variable "proxmox_node" {
|
||||||
description = "The Proxmox node to use for building the image"
|
description = "The Proxmox node to use for building the image"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "install_finished_inform_port" {
|
||||||
|
type = string
|
||||||
|
default = "10000"
|
||||||
|
description = "The server port to inform when installation is finished"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# local values
|
# local values
|
||||||
|
|
|
||||||
16
mise.toml
16
mise.toml
|
|
@ -17,10 +17,22 @@ run = "packer fmt --recursive ${usage_dir?}"
|
||||||
[tasks.build]
|
[tasks.build]
|
||||||
usage = '''
|
usage = '''
|
||||||
arg "<dir>" help="Directory containing the Packer template to build e.g. debian/13-trixie"
|
arg "<dir>" help="Directory containing the Packer template to build e.g. debian/13-trixie"
|
||||||
|
flag "-i --install-finished-inform-port <port>" help="Server port to inform when installation is finished" hide=#true
|
||||||
'''
|
'''
|
||||||
run = '''
|
run = '''
|
||||||
mise run lint ${usage_dir?} \
|
mise run lint ${usage_dir?}
|
||||||
&& packer build ${usage_dir?}
|
[[ -z "${usage_install_finished_inform_port}" ]] && packer build ${usage_dir?}
|
||||||
|
[[ -n "${usage_install_finished_inform_port}" ]] && packer build \
|
||||||
|
-var "install_finished_inform_port=${usage_install_finished_inform_port?}" \
|
||||||
|
${usage_dir?}
|
||||||
|
'''
|
||||||
|
|
||||||
|
[tasks.build-luks]
|
||||||
|
usage = '''
|
||||||
|
arg "<dir>" help="Directory containing the Packer template to build e.g. debian/13-trixie"
|
||||||
|
'''
|
||||||
|
run = '''
|
||||||
|
_scripts/unlock-luks-after-install.py -t ${usage_dir?}
|
||||||
'''
|
'''
|
||||||
|
|
||||||
[tasks.init]
|
[tasks.init]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue