diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9a0a26..435645c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
### Hinzugefügt
+- Template-Bau ohne SSH zum Node: über eine Bau-Adresse installiert ProxDeploy den Guest-Agent per SSH in der Vorbereitungs-VM (#021)
- SSH-Schlüssel in den Einstellungen hinterlegen (persönlich und, für Admins, Team-Schlüssel); sie kommen automatisch in jeden Gast – auch per API/CLI ohne Angabe – und sind im Assistenten abwählbar (#015)
- Templates mit eingebackenen Profilen: beim Template-Bau Profile wählen; Ausrollen nimmt das passende Template und überspringt, was schon drin ist (#022)
- Profil-Editor: eigene Profile als YAML mit Live-Prüfung und Skriptvorschau, Katalogprofile als Kopie bearbeiten (#011)
@@ -19,6 +20,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Geändert
### Behoben
+- Verbindungstest meldete „Template-Bau per API“ auch dann, wenn der Guest-Agent mangels SSH gar nicht ins Template käme (#021)
- chrony wird in Containern nicht mehr installiert – ohne CAP_SYS_TIME scheiterte dort das Profil base (#009)
- SSH-Härtung auf Ubuntu 24.04 scheiterte an fehlendem /run/sshd (#009)
diff --git a/docs/issues/021-template-bau-ohne-ssh.md b/docs/issues/021-template-bau-ohne-ssh.md
index 0ba37dc..2815a85 100644
--- a/docs/issues/021-template-bau-ohne-ssh.md
+++ b/docs/issues/021-template-bau-ohne-ssh.md
@@ -1,18 +1,18 @@
# #021 Template-Bau ohne SSH (Bau-Adresse)
-**Status:** offen
+**Status:** umgesetzt, Abnahme auf pve-demo ausstehend
**Meilenstein:** v0.3.0
**Labels:** feature, proxmox
-**Geschätzt:** 3 h **Tatsächlich:** –
+**Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min
## Ziel
Templates auch auf Servern ohne SSH zum Node bauen. Der Guest-Agent wird dann nicht per Snippet,
sondern über SSH **in die Vorbereitungs-VM** eingebaut.
## Aufgaben
-- [ ] Server-Einstellung `bauNetz`: Bridge, VLAN, statische IP/CIDR, Gateway, DNS
-- [ ] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys`
-- [ ] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten
-- [ ] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
+- [x] Server-Einstellung `bauNetz`: statische IP/CIDR, Gateway, DNS (Bridge/VLAN wie beim Template-Bau gewählt)
+- [x] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys`
+- [x] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten
+- [x] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
## Akzeptanzkriterien
- [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH
@@ -21,4 +21,7 @@ sondern über SSH **in die Vorbereitungs-VM** eingebaut.
#007
## Notizen / Entscheidungen
+- **Fähigkeitserkennung war zu optimistisch:** „Template-Bau per API“ stand auch ohne Node-SSH da, obwohl der Guest-Agent dann nicht hineinkam. Jetzt: per API nur mit Snippet-Weg oder Bau-Adresse.
+- Der Bereitstellungsschlüssel kommt per cloud-init an den Standardbenutzer des Images; danach werden alle authorized_keys, ciuser, sshkeys und die feste Adresse entfernt.
+- Host-Key der frischen VM ist vorher unbekannt (TOFU, nur für diese eine Verbindung).
- Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH.
diff --git a/docs/issues/README.md b/docs/issues/README.md
index ef8fadf..78c3b73 100644
--- a/docs/issues/README.md
+++ b/docs/issues/README.md
@@ -24,5 +24,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
-| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | offen | v0.3.0 | 3 h | – |
+| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | umgesetzt, Abnahme offen | v0.3.0 | 3 h | 0 h 25 min |
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 35 min |
diff --git a/src/app/(app)/server/server-formular.tsx b/src/app/(app)/server/server-formular.tsx
index e346929..f81576e 100644
--- a/src/app/(app)/server/server-formular.tsx
+++ b/src/app/(app)/server/server-formular.tsx
@@ -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 }) {
+
+
Promise;
+ /** Nur für Tests: SSH in die Vorbereitungs-VM ersetzen. */
+ gastSsh?: (
+ ziel: { host: string; benutzer: string; schluessel: Bereitstellungsschluessel },
+ skript: string,
+ ) => Promise;
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,
+): 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(`${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(`${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,
+ beiZeile: (z: string) => void,
+): Promise {
+ // 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>(
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(),
diff --git a/src/engine/proxmox/erkundung.ts b/src/engine/proxmox/erkundung.ts
index 5457b8a..8e5ef81 100644
--- a/src/engine/proxmox/erkundung.ts
+++ b/src/engine/proxmox/erkundung.ts
@@ -119,7 +119,7 @@ async function versuchen(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 {
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,
};
diff --git a/src/engine/proxmox/faehigkeiten.ts b/src/engine/proxmox/faehigkeiten.ts
index 41e7622..f6a922b 100644
--- a/src/engine/proxmox/faehigkeiten.ts
+++ b/src/engine/proxmox/faehigkeiten.ts
@@ -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 = {
diff --git a/src/lib/schemas/server.ts b/src/lib/schemas/server.ts
index 140ea33..407b4e8 100644
--- a/src/lib/schemas/server.ts
+++ b/src/lib/schemas/server.ts
@@ -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>;
+
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(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;
diff --git a/src/server/services/ausrollen.ts b/src/server/services/ausrollen.ts
index b30c55b..41ee1e7 100644
--- a/src/server/services/ausrollen.ts
+++ b/src/server/services/ausrollen.ts
@@ -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,
diff --git a/src/server/services/server.ts b/src/server/services/server.ts
index 6ec1cd6..76ad02a 100644
--- a/src/server/services/server.ts
+++ b/src/server/services/server.ts
@@ -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)[feld] !== wert)
+ .filter(
+ ([feld, wert]) =>
+ feld === "tokenGeheimnis" ||
+ vergleichbar((bisher as Record)[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,
diff --git a/src/worker/ablaeufe/template.ts b/src/worker/ablaeufe/template.ts
index 2ff1fe1..83f4976 100644
--- a/src/worker/ablaeufe/template.ts
+++ b/src/worker/ablaeufe/template.ts
@@ -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({
diff --git a/tests/engine/erkundung.test.ts b/tests/engine/erkundung.test.ts
index 1394e8f..2e7b1ce 100644
--- a/tests/engine/erkundung.test.ts
+++ b/tests/engine/erkundung.test.ts
@@ -32,7 +32,7 @@ async function mitSzenario(szenario: Szenario) {
describe("Verbindungstest (Erkundung)", () => {
it("PVE 9.2 Einzelhost: Nodes, Storages, Bridges, keine fehlenden Rechte, Template-Bau per API", async () => {
const { client } = await mitSzenario({ version: "9.2.1", importStorage: true });
- const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
+ const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null, bauNetz: true });
expect(bericht.version).toBe("9.2.1");
expect(bericht.cluster).toBeNull();
expect(bericht.knoten).toHaveLength(1);
diff --git a/tests/engine/faehigkeiten.test.ts b/tests/engine/faehigkeiten.test.ts
index fcbb404..a25cdcc 100644
--- a/tests/engine/faehigkeiten.test.ts
+++ b/tests/engine/faehigkeiten.test.ts
@@ -101,17 +101,27 @@ describe("Strategien", () => {
const mitImport = [storage("local", ["iso", "import", "snippets"])];
const ohneImport = [storage("local", ["iso", "snippets"])];
- it("PVE 9.2 mit import-Storage: Template-Bau per API, auch ohne SSH", () => {
- const f = faehigkeitenAbleiten({
+ it("PVE 9.2 mit import-Storage ohne SSH: Image ginge per API, der Guest-Agent aber nicht – außer mit Bau-Adresse", () => {
+ const ohne = faehigkeitenAbleiten({
version: v("9.2.1"),
storages: mitImport,
ssh: "aus",
rechteFehlen: false,
});
- expect(f.templateBau.weg).toBe("api");
- expect(f.vmProvisionierung.weg).toBe("api");
- expect(f.lxcProvisionierung).toMatchObject({ weg: "ssh", ueber: "gast" });
- expect(f.snippets.weg).toBe("nicht_moeglich");
+ expect(ohne.templateBau.weg).toBe("nicht_moeglich");
+ expect(ohne.templateBau.grund).toMatch(/Bau-Adresse/);
+ const mit = faehigkeitenAbleiten({
+ version: v("9.2.1"),
+ storages: mitImport,
+ ssh: "aus",
+ rechteFehlen: false,
+ bauNetz: true,
+ });
+ expect(mit.templateBau).toMatchObject({ weg: "api" });
+ expect(mit.templateBau.grund).toMatch(/Bau-Adresse/);
+ expect(mit.vmProvisionierung.weg).toBe("api");
+ expect(mit.lxcProvisionierung).toMatchObject({ weg: "ssh", ueber: "gast" });
+ expect(mit.snippets.weg).toBe("nicht_moeglich");
});
it("PVE 8.3: Template-Bau nur per SSH; ohne SSH mit Begründung unmöglich", () => {
diff --git a/tests/engine/template-bau.test.ts b/tests/engine/template-bau.test.ts
index 3d5a6c7..46d8af9 100644
--- a/tests/engine/template-bau.test.ts
+++ b/tests/engine/template-bau.test.ts
@@ -360,3 +360,74 @@ describe("Profile einbacken (#022)", () => {
]);
});
});
+
+describe("Guest-Agent über die Bau-Adresse (#021)", () => {
+ it("ohne Node-SSH: feste Adresse und Bereitstellungsschlüssel per cloud-init, SSH in die VM, danach zurückgesetzt", async () => {
+ const { mock, client } = await aufbauen({ version: "9.2.1" });
+ mock.route("POST", /\/status\/shutdown$/, () => ({ data: UPID("qmshutdown") }));
+ const gast: Array<{ host: string; benutzer: string; skript: string }> = [];
+ const ergebnis = await phasenAusfuehren(
+ templateBauSchritte({
+ client,
+ distribution: await debian13(),
+ knoten: "pve01",
+ storage: "local-zfs",
+ bridge: "vmbr0",
+ ssh: null,
+ auftragKurzId: "t",
+ holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`,
+ vmidReservieren: async () => 9000,
+ warten: async () => {},
+ bauNetz: { ip: "10.0.10.250/24", gateway: "10.0.10.1", dns: ["10.0.10.1"] },
+ bereitstellung: async () => ({
+ privat: "p",
+ oeffentlich: "ssh-ed25519 AAAAbau proxdeploy-bereitstellung",
+ }),
+ gastSsh: async (ziel, skript) => {
+ gast.push({ host: ziel.host, benutzer: ziel.benutzer, skript });
+ return 0;
+ },
+ }),
+ stumm,
+ );
+ expect(ergebnis).toEqual({ ok: true });
+ expect(gast).toEqual([
+ {
+ host: "10.0.10.250",
+ benutzer: "debian",
+ skript: expect.stringContaining("apt-get -y -q -o DPkg::Lock::Timeout=600 install qemu-guest-agent"),
+ },
+ ]);
+ expect(gast[0]!.skript).toContain("rm -f /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys");
+ const configs = mock.anfragen.filter((a) => a.methode === "PUT").map((a) => Object.fromEntries(a.rumpf));
+ expect(configs[0]).toMatchObject({
+ ciuser: "debian",
+ ipconfig0: "ip=10.0.10.250/24,gw=10.0.10.1",
+ nameserver: "10.0.10.1",
+ });
+ expect(configs[0]!["sshkeys"]).toBe("ssh-ed25519%20AAAAbau%20proxdeploy-bereitstellung");
+ expect(configs.at(-1)).toEqual({ ipconfig0: "ip=dhcp", delete: "sshkeys,ciuser,nameserver" });
+ // Kein Snippet ohne Node-SSH.
+ expect(mock.anfragen.some((a) => a.rumpf.get("cicustom"))).toBe(false);
+ });
+
+ it("ohne Node-SSH und ohne Bau-Adresse sagt schon die Prüfung, was fehlt", async () => {
+ const { client } = await aufbauen({ version: "9.2.1" });
+ const ergebnis = await phasenAusfuehren(
+ templateBauSchritte({
+ client,
+ distribution: await debian13(),
+ knoten: "pve01",
+ storage: "local-zfs",
+ bridge: "vmbr0",
+ ssh: null,
+ auftragKurzId: "t",
+ holen: async () => "",
+ vmidReservieren: async () => 9000,
+ }),
+ stumm,
+ );
+ expect(ergebnis).toMatchObject({ ok: false, phase: "pruefungen" });
+ if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/Bau-Adresse/);
+ });
+});