feat(templates): Template-Bau ohne SSH über eine Bau-Adresse

Ohne SSH zum Node kommt der Guest-Agent jetzt über eine feste
Bau-Adresse ins Template: cloud-init setzt Adresse und den
Bereitstellungsschlüssel für den Standardbenutzer, ProxDeploy meldet
sich per SSH in der VM an, installiert den Agent und entfernt danach
Schlüssel, Adresse und cloud-init-Angaben.

Dabei korrigiert: der Verbindungstest meldete „Template-Bau per API“
auch ohne Weg, den Agent einzubauen. Im Protokoll erscheinen
unveränderte JSON-Felder nicht mehr als geändert.

Refs #021

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 14:36:38 +02:00
co-authored by Claude Opus 5.5
parent 426d3ec95a
commit 21edff2141
14 changed files with 366 additions and 28 deletions
+49 -1
View File
@@ -43,6 +43,9 @@ type Werte = {
templateVmidVon: string;
templateVmidBis: string;
gastVmidVon: string;
bauIp: string;
bauGateway: string;
bauDns: string;
};
function anfangswerte(server?: ServerSicht): Werte {
@@ -60,6 +63,9 @@ function anfangswerte(server?: ServerSicht): Werte {
templateVmidVon: String(server?.templateVmidVon ?? 9000),
templateVmidBis: String(server?.templateVmidBis ?? 9999),
gastVmidVon: String(server?.gastVmidVon ?? 100),
bauIp: server?.bauNetz?.ip ?? "",
bauGateway: server?.bauNetz?.gateway ?? "",
bauDns: server?.bauNetz?.dns.join(", ") ?? "",
};
}
@@ -117,12 +123,16 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
setSpeichert(true);
setFelder({});
setMeldung(null);
const { bauIp, bauGateway, bauDns, ...ohneBau } = werte;
const rumpf = {
...werte,
...ohneBau,
sshPort: Number(werte.sshPort),
templateVmidVon: Number(werte.templateVmidVon),
templateVmidBis: Number(werte.templateVmidBis),
gastVmidVon: Number(werte.gastVmidVon),
bauNetz: bauIp.trim()
? { ip: bauIp.trim(), gateway: bauGateway.trim(), dns: bauDns.split(/[\s,;]+/).filter(Boolean) }
: null,
};
try {
const gespeichert = bearbeiten
@@ -361,6 +371,44 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
</Feld>
</div>
<fieldset className="flex flex-col gap-3">
<legend className="text-sm font-medium">Bau-Adresse (optional)</legend>
<p className="text-muted text-sm">
Feste Adresse für den Vorbereitungsstart beim Template-Bau. Nur nötig ohne SSH zum Node:
ProxDeploy meldet sich darüber kurz in der VM an und installiert den Guest-Agent. Braucht
Internetzugang.
</p>
<div className="grid gap-4 sm:grid-cols-3">
<Feld id="bauIp" label="IP/Präfix" fehler={felder["bauNetz.ip"]}>
<input
id="bauIp"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.bauIp}
onChange={(e) => setze("bauIp", e.target.value)}
placeholder="10.0.10.250/24"
/>
</Feld>
<Feld id="bauGateway" label="Gateway" fehler={felder["bauNetz.gateway"]}>
<input
id="bauGateway"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.bauGateway}
onChange={(e) => setze("bauGateway", e.target.value)}
placeholder="10.0.10.1"
/>
</Feld>
<Feld id="bauDns" label="DNS" fehler={felder["bauNetz.dns"]}>
<input
id="bauDns"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.bauDns}
onChange={(e) => setze("bauDns", e.target.value)}
placeholder="10.0.10.1"
/>
</Feld>
</div>
</fieldset>
<Schalter
id="sshAktiv"
label="SSH zu den Nodes nutzen"
+146 -7
View File
@@ -6,6 +6,9 @@ import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
import { mindestens, versionLesen } from "@/engine/proxmox/faehigkeiten";
import { q } from "@/engine/shell";
import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
import type { BauNetz } from "@/lib/schemas/server";
import type { Bereitstellungsschluessel } from "./lxc-ausrollen";
import { adapterFuer } from "@/engine/adapter";
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { profileRendern } from "@/engine/profile/rendern";
@@ -36,6 +39,14 @@ export type TemplateBauEingabe = {
distribution: Distribution;
/** Eingebackene Profile (#022), aufgelöst mit Standardwerten; leer = nur Guest-Agent. */
profile?: AufgeloestesProfil[];
/** Bau-Adresse (#021): Guest-Agent per SSH in die VM einbauen, wenn es kein Node-SSH gibt. */
bauNetz?: BauNetz | null;
bereitstellung?: () => Promise<Bereitstellungsschluessel>;
/** Nur für Tests: SSH in die Vorbereitungs-VM ersetzen. */
gastSsh?: (
ziel: { host: string; benutzer: string; schluessel: Bereitstellungsschluessel },
skript: string,
) => Promise<number | null>;
knoten: string;
/** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */
storage: string;
@@ -56,6 +67,8 @@ export type TemplateBauEingabe = {
export type TemplateBauStand = {
vmid?: number;
/** Wie der Guest-Agent hineinkommt: Snippet (Node-SSH) oder SSH in die VM über die Bau-Adresse. */
agentWeg?: "snippet" | "baunetz";
weg?: "api" | "ssh";
importStorage?: string;
snippetStorage?: string;
@@ -131,6 +144,126 @@ power_state:
`;
}
/**
* Guest-Agent ohne Node-SSH (#021): Vorbereitungsstart mit fester Bau-Adresse und dem
* Bereitstellungsschlüssel für den Standardbenutzer des Images, dann SSH in die VM. Danach wird
* der Schlüssel entfernt und die cloud-init-Angaben zurückgesetzt – im Template bleibt nichts davon.
*/
export const BAUNETZ_SKRIPT = `set -e
export DEBIAN_FRONTEND=noninteractive
cloud-init status --wait >/dev/null 2>&1 || true
apt-get -q update
apt-get -y -q -o DPkg::Lock::Timeout=600 install qemu-guest-agent
systemctl enable qemu-guest-agent
rm -f /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys
truncate -s 0 /etc/machine-id
rm -f /var/lib/dbus/machine-id
cloud-init clean --logs
sync
`;
async function agentUeberBauNetz(
e: TemplateBauEingabe,
vmPfad: string,
benutzer: string,
k: PhasenKontext,
warten: (ms: number) => Promise<void>,
): Promise<{ detail: string }> {
const { client } = e;
const netz = e.bauNetz!;
const schluessel = await e.bereitstellung!();
const host = netz.ip.split("/")[0]!;
await client.put(`${vmPfad}/config`, {
ciuser: benutzer,
sshkeys: encodeURIComponent(schluessel.oeffentlich).replace(
/[!'()*]/g,
(z) => `%${z.charCodeAt(0).toString(16).toUpperCase()}`,
),
ipconfig0: `ip=${netz.ip},gw=${netz.gateway}`,
...(netz.dns.length > 0 ? { nameserver: netz.dns.join(" ") } : {}),
});
try {
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/start`), {
zeitlimitMs: 5 * 60 * 1000,
});
if (client.trocken) return { detail: `über Bau-Adresse ${host}` };
await k.detail(`Vorbereitungsstart über Bau-Adresse ${host} …`);
const code = await (e.gastSsh ?? gastSshBau)(
{ host, benutzer, schluessel },
BAUNETZ_SKRIPT,
warten,
(z) => void k.log(z, "debug"),
);
if (code !== 0) {
throw new AblaufFehler(
`Einbau des Guest-Agents über ${host} mit Code ${code} abgebrochen.`,
"Hat die Bau-Adresse Internetzugang (Gateway, DNS)? Detail-Log ansehen.",
);
}
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/shutdown`, { timeout: 180 }), {
zeitlimitMs: 5 * 60 * 1000,
});
for (let i = 0; i < 60; i += 1) {
if ((await client.get<{ status: string }>(`${vmPfad}/status/current`)).status === "stopped") break;
await warten(3000);
}
} finally {
// Bau-Adresse und Schlüssel gehören nicht ins Template.
await client
.put(`${vmPfad}/config`, { ipconfig0: "ip=dhcp", delete: "sshkeys,ciuser,nameserver" })
.catch(() => undefined);
}
return { detail: `qemu-guest-agent über Bau-Adresse ${host} installiert · Maschinen-ID geleert` };
}
async function gastSshBau(
ziel: { host: string; benutzer: string; schluessel: Bereitstellungsschluessel },
skript: string,
warten: (ms: number) => Promise<void>,
beiZeile: (z: string) => void,
): Promise<number | null> {
// Den Host-Key der frischen VM kann niemand vorher kennen (TOFU, nur für diese Verbindung).
const beginn = Date.now();
for (;;) {
try {
const ergebnis = await sshAusfuehren(
{
host: ziel.host,
port: 22,
benutzer: ziel.benutzer,
privatSchluessel: ziel.schluessel.privat,
hostSchluessel: null,
},
"sudo -n bash -s",
{
erkunden: true,
eingabe: skript,
zeitlimitMs: 20 * 60 * 1000,
beiAusgabe: (t) => t.split("\n").filter(Boolean).forEach(beiZeile),
},
);
return ergebnis.code;
} catch (fehler) {
if (
fehler instanceof SshFehler &&
(fehler.art === "verbindung" || fehler.art === "anmeldung" || fehler.art === "zeitueberschreitung") &&
Date.now() - beginn < 4 * 60 * 1000
) {
// Bootet noch oder cloud-init hat den Schlüssel noch nicht eingetragen.
await warten(5000);
continue;
}
if (fehler instanceof SshFehler) {
throw new AblaufFehler(
`SSH in die Vorbereitungs-VM (${ziel.host}) fehlgeschlagen: ${fehler.ursache}`,
"Ist die Bau-Adresse aus dem Netz von ProxDeploy erreichbar? Stimmen Bridge/VLAN?",
);
}
throw fehler;
}
}
}
export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauStand = {}): PhasenSchritt[] {
const { client, distribution: d, knoten } = e;
const vm = d.vm;
@@ -215,13 +348,17 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
: "SSH zum Node einrichten oder auf PVE 8.4+ aktualisieren.",
);
}
if (!vm.guest_agent_preinstalled && !(e.ssh && stand.snippetStorage)) {
throw new AblaufFehler(
"Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node und einen Storage mit Inhaltstyp „Snippets“.",
e.ssh
? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben."
: "SSH in den Server-Einstellungen einrichten.",
);
if (!vm.guest_agent_preinstalled) {
if (e.ssh && stand.snippetStorage) stand.agentWeg = "snippet";
else if (e.bauNetz && e.bereitstellung) stand.agentWeg = "baunetz";
else {
throw new AblaufFehler(
"Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node (mit einem Snippets-Storage) oder eine Bau-Adresse.",
e.ssh
? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben oder in den Server-Einstellungen eine Bau-Adresse eintragen."
: "SSH in den Server-Einstellungen einrichten oder dort eine Bau-Adresse (feste IP für die Vorbereitungs-VM) eintragen.",
);
}
}
const netze = await client.get<Array<{ iface: string; type: string }>>(
pfad`/nodes/${knoten}/network`,
@@ -375,6 +512,8 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
if (vm.guest_agent_preinstalled) return { uebersprungen: true, detail: "im Image enthalten" };
const vmid = stand.vmid!;
const vmPfad = pfad`/nodes/${knoten}/qemu/${vmid}`;
if (stand.agentWeg === "baunetz")
return await agentUeberBauNetz(e, vmPfad, vm.default_user, k, warten);
const snippet = `${stand.snippetStorage}:snippets/${snippetDatei}`;
await ssh(`set -e; ziel="$(pvesm path ${q(snippet)})"; install -D -m 0644 /dev/stdin "$ziel"`, k, {
eingabe: vorbereitungsSnippet(),
+2 -1
View File
@@ -119,7 +119,7 @@ async function versuchen<T>(warnungen: string[], text: string, aufruf: () => Pro
export async function erkunden(
client: ProxmoxClient,
optionen: { serverHost: string; ssh: SshZugang | null; jetzt?: () => Date },
optionen: { serverHost: string; ssh: SshZugang | null; bauNetz?: boolean; jetzt?: () => Date },
): Promise<VerbindungsBericht> {
const warnungen: string[] = [];
const version = await client.get<{ version: string; release: string }>("/version");
@@ -313,6 +313,7 @@ export async function erkunden(
storages: alleStorages,
ssh: sshGesamt,
rechteFehlen: fehlendeRechte.some((r) => r.pflicht),
bauNetz: optionen.bauNetz,
}),
warnungen,
};
+13 -2
View File
@@ -230,6 +230,8 @@ export function faehigkeitenAbleiten(eingabe: {
storages: StorageInfo[];
ssh: SshZustand;
rechteFehlen: boolean;
/** Bau-Adresse gesetzt (#021) – Guest-Agent einbauen geht dann auch ohne SSH zum Node. */
bauNetz?: boolean;
}): Faehigkeiten {
const { version, storages, ssh } = eingabe;
const sshOk = ssh === "ok";
@@ -237,10 +239,19 @@ export function faehigkeitenAbleiten(eingabe: {
const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets"));
let templateBau: Strategie;
if (mindestens(version, 8, 4) && importSpeicher.length > 0) {
const agentEinbauen = (sshOk && snippetSpeicher.length > 0) || Boolean(eingabe.bauNetz);
if (mindestens(version, 8, 4) && importSpeicher.length > 0 && !agentEinbauen) {
templateBau = {
weg: "nicht_moeglich",
grund:
"Image-Import ginge per API, aber der Guest-Agent muss ins Template: SSH zum Node (mit Snippets-Storage) oder eine Bau-Adresse in den Server-Einstellungen.",
};
} else if (mindestens(version, 8, 4) && importSpeicher.length > 0) {
templateBau = {
weg: "api",
grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}`,
grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}${
sshOk && snippetSpeicher.length > 0 ? "" : "; Guest-Agent über die Bau-Adresse"
}`,
};
} else if (sshOk) {
templateBau = {
+31
View File
@@ -30,6 +30,35 @@ export const ServerUrl = z
return `${url.protocol}//${url.host}`;
});
/**
* Bau-Adresse (#021): eine feste Adresse für den Vorbereitungsstart beim Template-Bau, wenn es
* kein SSH zum Node gibt. ProxDeploy meldet sich darüber per SSH in der VM an.
*/
export const BauNetz = z
.object({
ip: z
.string()
.trim()
.regex(/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, "Format: 10.0.10.250/24"),
gateway: z
.string()
.trim()
.regex(/^(\d{1,3}\.){3}\d{1,3}$/, "Format: 10.0.10.1"),
dns: z
.array(
z
.string()
.trim()
.regex(/^(\d{1,3}\.){3}\d{1,3}$/),
)
.max(3)
.default([]),
})
.nullable()
.default(null);
export type BauNetz = NonNullable<z.infer<typeof BauNetz>>;
const Vmid = z.coerce.number().int("Ganze Zahl erwartet.").min(100, "Mindestens 100.").max(999_999_999);
const Basis = z.object({
@@ -52,6 +81,7 @@ const Basis = z.object({
templateVmidVon: Vmid.default(9000),
templateVmidBis: Vmid.default(9999),
gastVmidVon: Vmid.default(100),
bauNetz: BauNetz,
});
function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) {
@@ -112,6 +142,7 @@ export type ServerSicht = {
templateVmidVon: number;
templateVmidBis: number;
gastVmidVon: number;
bauNetz: BauNetz | null;
erreichbar: boolean | null;
pveVersion: string | null;
geprueftAm: string | null;
+8 -1
View File
@@ -8,6 +8,7 @@ import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufl
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
import { prisma } from "@/lib/db";
import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen";
import type { BauNetz } from "@/lib/schemas/server";
import { vorlageAnwenden } from "@/lib/schemas/vorlage";
import { vorlageHolen } from "@/server/services/vorlagen";
import type { Aufrufer } from "@/server/auth/zugang";
@@ -162,7 +163,13 @@ export function ausrollSchritte(
profile: v.profile,
vmidReservieren: o.vmidReservieren,
templateBau: v.anfrage.templateBauen
? { sshFuer, auftragKurzId: o.auftragKurzId, vmidReservieren: o.templateVmidReservieren }
? {
sshFuer,
auftragKurzId: o.auftragKurzId,
vmidReservieren: o.templateVmidReservieren,
bauNetz: (v.server.bauNetz as BauNetz | null) ?? null,
bereitstellung: bereitstellungsschluessel,
}
: undefined,
angelegt: o.angelegt,
auftragKurzId: o.auftragKurzId,
+13 -2
View File
@@ -1,5 +1,5 @@
import ssh2 from "ssh2";
import type { Server } from "@/generated/prisma/client";
import { Prisma, type Server } from "@/generated/prisma/client";
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
@@ -11,6 +11,7 @@ import {
ServerAendern,
ServerAnlegen,
ServerPruefen,
type BauNetz,
type ServerSicht,
} from "@/lib/schemas/server";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
@@ -32,6 +33,7 @@ export function serverSicht(server: Server): ServerSicht {
templateVmidVon: server.templateVmidVon,
templateVmidBis: server.templateVmidBis,
gastVmidVon: server.gastVmidVon,
bauNetz: (server.bauNetz as BauNetz | null) ?? null,
erreichbar: server.erreichbar,
pveVersion: server.pveVersion,
geprueftAm: server.geprueftAm?.toISOString() ?? null,
@@ -83,6 +85,7 @@ export async function serverAnlegen(
const angelegt = await tx.server.create({
data: {
...eingabe,
bauNetz: eingabe.bauNetz ?? Prisma.JsonNull,
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis),
},
@@ -117,12 +120,19 @@ export async function serverAendern(
const { tokenGeheimnis, ...rest } = eingabe;
const daten = {
...rest,
bauNetz: rest.bauNetz ?? Prisma.JsonNull,
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}),
};
// Welche Felder sich geändert haben, steht im Protokoll – die Werte von Geheimnissen nie.
// JSON-Felder (bauNetz) vergleichen nach Inhalt; Prisma.JsonNull steht für „leer“ wie null.
const vergleichbar = (wert: unknown) => JSON.stringify(wert === Prisma.JsonNull ? null : (wert ?? null));
const geaendert = Object.entries(daten)
.filter(([feld, wert]) => feld === "tokenGeheimnis" || (bisher as Record<string, unknown>)[feld] !== wert)
.filter(
([feld, wert]) =>
feld === "tokenGeheimnis" ||
vergleichbar((bisher as Record<string, unknown>)[feld]) !== vergleichbar(wert),
)
.map(([feld]) => feld);
const server = await prisma.$transaction(async (tx) => {
@@ -287,6 +297,7 @@ export async function serverTesten(
try {
const bericht = await erkunden(client, {
serverHost: new URL(server.url).hostname,
bauNetz: Boolean(server.bauNetz),
ssh: server.sshAktiv
? {
benutzer: server.sshBenutzer,
+4
View File
@@ -3,7 +3,9 @@ import { templateBauSchritte, templateName, type TemplateBauStand } from "@/engi
import type { Distribution } from "@/engine/katalog/schema";
import { ProxmoxClient } from "@/engine/proxmox/client";
import { prisma } from "@/lib/db";
import type { BauNetz } from "@/lib/schemas/server";
import { TemplateBauAnfrage } from "@/lib/schemas/template";
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
import { melden } from "@/server/ereignisse/senke";
import { verbindungAus } from "@/server/services/server";
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
@@ -43,6 +45,8 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht,
bridge: anfrage.bridge,
vlan: anfrage.vlan,
ssh: nodeBefehlFuer(server, anfrage.knoten),
bauNetz: (server.bauNetz as BauNetz | null) ?? null,
bereitstellung: bereitstellungsschluessel,
auftragKurzId: auftrag.kurzId,
vmidReservieren: async (belegt) => {
const vmid = await vmidReservieren({