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
+2
View File
@@ -8,6 +8,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
### Hinzugefügt ### 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) - 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) - 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) - 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 ### Geändert
### Behoben ### 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) - 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) - SSH-Härtung auf Ubuntu 24.04 scheiterte an fehlendem /run/sshd (#009)
+9 -6
View File
@@ -1,18 +1,18 @@
# #021 Template-Bau ohne SSH (Bau-Adresse) # #021 Template-Bau ohne SSH (Bau-Adresse)
**Status:** offen **Status:** umgesetzt, Abnahme auf pve-demo ausstehend
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** feature, proxmox **Labels:** feature, proxmox
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min
## Ziel ## Ziel
Templates auch auf Servern ohne SSH zum Node bauen. Der Guest-Agent wird dann nicht per Snippet, 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. sondern über SSH **in die Vorbereitungs-VM** eingebaut.
## Aufgaben ## Aufgaben
- [ ] Server-Einstellung `bauNetz`: Bridge, VLAN, statische IP/CIDR, Gateway, DNS - [x] Server-Einstellung `bauNetz`: statische IP/CIDR, Gateway, DNS (Bridge/VLAN wie beim Template-Bau gewählt)
- [ ] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys` - [x] 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 - [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
- [ ] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“ - [x] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH - [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH
@@ -21,4 +21,7 @@ sondern über SSH **in die Vorbereitungs-VM** eingebaut.
#007 #007
## Notizen / Entscheidungen ## 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. - Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH.
+1 -1
View File
@@ -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 | – | | [#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 | – | | [#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 | – | | [#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 | | [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 35 min |
+49 -1
View File
@@ -43,6 +43,9 @@ type Werte = {
templateVmidVon: string; templateVmidVon: string;
templateVmidBis: string; templateVmidBis: string;
gastVmidVon: string; gastVmidVon: string;
bauIp: string;
bauGateway: string;
bauDns: string;
}; };
function anfangswerte(server?: ServerSicht): Werte { function anfangswerte(server?: ServerSicht): Werte {
@@ -60,6 +63,9 @@ function anfangswerte(server?: ServerSicht): Werte {
templateVmidVon: String(server?.templateVmidVon ?? 9000), templateVmidVon: String(server?.templateVmidVon ?? 9000),
templateVmidBis: String(server?.templateVmidBis ?? 9999), templateVmidBis: String(server?.templateVmidBis ?? 9999),
gastVmidVon: String(server?.gastVmidVon ?? 100), 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); setSpeichert(true);
setFelder({}); setFelder({});
setMeldung(null); setMeldung(null);
const { bauIp, bauGateway, bauDns, ...ohneBau } = werte;
const rumpf = { const rumpf = {
...werte, ...ohneBau,
sshPort: Number(werte.sshPort), sshPort: Number(werte.sshPort),
templateVmidVon: Number(werte.templateVmidVon), templateVmidVon: Number(werte.templateVmidVon),
templateVmidBis: Number(werte.templateVmidBis), templateVmidBis: Number(werte.templateVmidBis),
gastVmidVon: Number(werte.gastVmidVon), gastVmidVon: Number(werte.gastVmidVon),
bauNetz: bauIp.trim()
? { ip: bauIp.trim(), gateway: bauGateway.trim(), dns: bauDns.split(/[\s,;]+/).filter(Boolean) }
: null,
}; };
try { try {
const gespeichert = bearbeiten const gespeichert = bearbeiten
@@ -361,6 +371,44 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
</Feld> </Feld>
</div> </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 <Schalter
id="sshAktiv" id="sshAktiv"
label="SSH zu den Nodes nutzen" 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 { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
import { mindestens, versionLesen } from "@/engine/proxmox/faehigkeiten"; import { mindestens, versionLesen } from "@/engine/proxmox/faehigkeiten";
import { q } from "@/engine/shell"; 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 { adapterFuer } from "@/engine/adapter";
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen"; import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { profileRendern } from "@/engine/profile/rendern"; import { profileRendern } from "@/engine/profile/rendern";
@@ -36,6 +39,14 @@ export type TemplateBauEingabe = {
distribution: Distribution; distribution: Distribution;
/** Eingebackene Profile (#022), aufgelöst mit Standardwerten; leer = nur Guest-Agent. */ /** Eingebackene Profile (#022), aufgelöst mit Standardwerten; leer = nur Guest-Agent. */
profile?: AufgeloestesProfil[]; 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; knoten: string;
/** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */ /** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */
storage: string; storage: string;
@@ -56,6 +67,8 @@ export type TemplateBauEingabe = {
export type TemplateBauStand = { export type TemplateBauStand = {
vmid?: number; 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"; weg?: "api" | "ssh";
importStorage?: string; importStorage?: string;
snippetStorage?: 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[] { export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauStand = {}): PhasenSchritt[] {
const { client, distribution: d, knoten } = e; const { client, distribution: d, knoten } = e;
const vm = d.vm; 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.", : "SSH zum Node einrichten oder auf PVE 8.4+ aktualisieren.",
); );
} }
if (!vm.guest_agent_preinstalled && !(e.ssh && stand.snippetStorage)) { if (!vm.guest_agent_preinstalled) {
throw new AblaufFehler( if (e.ssh && stand.snippetStorage) stand.agentWeg = "snippet";
"Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node und einen Storage mit Inhaltstyp „Snippets“.", else if (e.bauNetz && e.bereitstellung) stand.agentWeg = "baunetz";
e.ssh else {
? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben." throw new AblaufFehler(
: "SSH in den Server-Einstellungen einrichten.", "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 }>>( const netze = await client.get<Array<{ iface: string; type: string }>>(
pfad`/nodes/${knoten}/network`, 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" }; if (vm.guest_agent_preinstalled) return { uebersprungen: true, detail: "im Image enthalten" };
const vmid = stand.vmid!; const vmid = stand.vmid!;
const vmPfad = pfad`/nodes/${knoten}/qemu/${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}`; const snippet = `${stand.snippetStorage}:snippets/${snippetDatei}`;
await ssh(`set -e; ziel="$(pvesm path ${q(snippet)})"; install -D -m 0644 /dev/stdin "$ziel"`, k, { await ssh(`set -e; ziel="$(pvesm path ${q(snippet)})"; install -D -m 0644 /dev/stdin "$ziel"`, k, {
eingabe: vorbereitungsSnippet(), eingabe: vorbereitungsSnippet(),
+2 -1
View File
@@ -119,7 +119,7 @@ async function versuchen<T>(warnungen: string[], text: string, aufruf: () => Pro
export async function erkunden( export async function erkunden(
client: ProxmoxClient, client: ProxmoxClient,
optionen: { serverHost: string; ssh: SshZugang | null; jetzt?: () => Date }, optionen: { serverHost: string; ssh: SshZugang | null; bauNetz?: boolean; jetzt?: () => Date },
): Promise<VerbindungsBericht> { ): Promise<VerbindungsBericht> {
const warnungen: string[] = []; const warnungen: string[] = [];
const version = await client.get<{ version: string; release: string }>("/version"); const version = await client.get<{ version: string; release: string }>("/version");
@@ -313,6 +313,7 @@ export async function erkunden(
storages: alleStorages, storages: alleStorages,
ssh: sshGesamt, ssh: sshGesamt,
rechteFehlen: fehlendeRechte.some((r) => r.pflicht), rechteFehlen: fehlendeRechte.some((r) => r.pflicht),
bauNetz: optionen.bauNetz,
}), }),
warnungen, warnungen,
}; };
+13 -2
View File
@@ -230,6 +230,8 @@ export function faehigkeitenAbleiten(eingabe: {
storages: StorageInfo[]; storages: StorageInfo[];
ssh: SshZustand; ssh: SshZustand;
rechteFehlen: boolean; rechteFehlen: boolean;
/** Bau-Adresse gesetzt (#021) – Guest-Agent einbauen geht dann auch ohne SSH zum Node. */
bauNetz?: boolean;
}): Faehigkeiten { }): Faehigkeiten {
const { version, storages, ssh } = eingabe; const { version, storages, ssh } = eingabe;
const sshOk = ssh === "ok"; const sshOk = ssh === "ok";
@@ -237,10 +239,19 @@ export function faehigkeitenAbleiten(eingabe: {
const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets")); const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets"));
let templateBau: Strategie; 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 = { templateBau = {
weg: "api", 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) { } else if (sshOk) {
templateBau = { templateBau = {
+31
View File
@@ -30,6 +30,35 @@ export const ServerUrl = z
return `${url.protocol}//${url.host}`; 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 Vmid = z.coerce.number().int("Ganze Zahl erwartet.").min(100, "Mindestens 100.").max(999_999_999);
const Basis = z.object({ const Basis = z.object({
@@ -52,6 +81,7 @@ const Basis = z.object({
templateVmidVon: Vmid.default(9000), templateVmidVon: Vmid.default(9000),
templateVmidBis: Vmid.default(9999), templateVmidBis: Vmid.default(9999),
gastVmidVon: Vmid.default(100), gastVmidVon: Vmid.default(100),
bauNetz: BauNetz,
}); });
function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) { function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) {
@@ -112,6 +142,7 @@ export type ServerSicht = {
templateVmidVon: number; templateVmidVon: number;
templateVmidBis: number; templateVmidBis: number;
gastVmidVon: number; gastVmidVon: number;
bauNetz: BauNetz | null;
erreichbar: boolean | null; erreichbar: boolean | null;
pveVersion: string | null; pveVersion: string | null;
geprueftAm: 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 { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen"; import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen";
import type { BauNetz } from "@/lib/schemas/server";
import { vorlageAnwenden } from "@/lib/schemas/vorlage"; import { vorlageAnwenden } from "@/lib/schemas/vorlage";
import { vorlageHolen } from "@/server/services/vorlagen"; import { vorlageHolen } from "@/server/services/vorlagen";
import type { Aufrufer } from "@/server/auth/zugang"; import type { Aufrufer } from "@/server/auth/zugang";
@@ -162,7 +163,13 @@ export function ausrollSchritte(
profile: v.profile, profile: v.profile,
vmidReservieren: o.vmidReservieren, vmidReservieren: o.vmidReservieren,
templateBau: v.anfrage.templateBauen 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, : undefined,
angelegt: o.angelegt, angelegt: o.angelegt,
auftragKurzId: o.auftragKurzId, auftragKurzId: o.auftragKurzId,
+13 -2
View File
@@ -1,5 +1,5 @@
import ssh2 from "ssh2"; 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 { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung"; import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { ProxmoxFehler } from "@/engine/proxmox/fehler"; import { ProxmoxFehler } from "@/engine/proxmox/fehler";
@@ -11,6 +11,7 @@ import {
ServerAendern, ServerAendern,
ServerAnlegen, ServerAnlegen,
ServerPruefen, ServerPruefen,
type BauNetz,
type ServerSicht, type ServerSicht,
} from "@/lib/schemas/server"; } from "@/lib/schemas/server";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler"; import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
@@ -32,6 +33,7 @@ export function serverSicht(server: Server): ServerSicht {
templateVmidVon: server.templateVmidVon, templateVmidVon: server.templateVmidVon,
templateVmidBis: server.templateVmidBis, templateVmidBis: server.templateVmidBis,
gastVmidVon: server.gastVmidVon, gastVmidVon: server.gastVmidVon,
bauNetz: (server.bauNetz as BauNetz | null) ?? null,
erreichbar: server.erreichbar, erreichbar: server.erreichbar,
pveVersion: server.pveVersion, pveVersion: server.pveVersion,
geprueftAm: server.geprueftAm?.toISOString() ?? null, geprueftAm: server.geprueftAm?.toISOString() ?? null,
@@ -83,6 +85,7 @@ export async function serverAnlegen(
const angelegt = await tx.server.create({ const angelegt = await tx.server.create({
data: { data: {
...eingabe, ...eingabe,
bauNetz: eingabe.bauNetz ?? Prisma.JsonNull,
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck), fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis), tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis),
}, },
@@ -117,12 +120,19 @@ export async function serverAendern(
const { tokenGeheimnis, ...rest } = eingabe; const { tokenGeheimnis, ...rest } = eingabe;
const daten = { const daten = {
...rest, ...rest,
bauNetz: rest.bauNetz ?? Prisma.JsonNull,
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck), fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}), ...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}),
}; };
// Welche Felder sich geändert haben, steht im Protokoll – die Werte von Geheimnissen nie. // 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) 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); .map(([feld]) => feld);
const server = await prisma.$transaction(async (tx) => { const server = await prisma.$transaction(async (tx) => {
@@ -287,6 +297,7 @@ export async function serverTesten(
try { try {
const bericht = await erkunden(client, { const bericht = await erkunden(client, {
serverHost: new URL(server.url).hostname, serverHost: new URL(server.url).hostname,
bauNetz: Boolean(server.bauNetz),
ssh: server.sshAktiv ssh: server.sshAktiv
? { ? {
benutzer: server.sshBenutzer, 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 type { Distribution } from "@/engine/katalog/schema";
import { ProxmoxClient } from "@/engine/proxmox/client"; import { ProxmoxClient } from "@/engine/proxmox/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import type { BauNetz } from "@/lib/schemas/server";
import { TemplateBauAnfrage } from "@/lib/schemas/template"; import { TemplateBauAnfrage } from "@/lib/schemas/template";
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
import { melden } from "@/server/ereignisse/senke"; import { melden } from "@/server/ereignisse/senke";
import { verbindungAus } from "@/server/services/server"; import { verbindungAus } from "@/server/services/server";
import { nodeBefehlFuer } from "@/server/services/ssh-zugang"; import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
@@ -43,6 +45,8 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht,
bridge: anfrage.bridge, bridge: anfrage.bridge,
vlan: anfrage.vlan, vlan: anfrage.vlan,
ssh: nodeBefehlFuer(server, anfrage.knoten), ssh: nodeBefehlFuer(server, anfrage.knoten),
bauNetz: (server.bauNetz as BauNetz | null) ?? null,
bereitstellung: bereitstellungsschluessel,
auftragKurzId: auftrag.kurzId, auftragKurzId: auftrag.kurzId,
vmidReservieren: async (belegt) => { vmidReservieren: async (belegt) => {
const vmid = await vmidReservieren({ const vmid = await vmidReservieren({
+1 -1
View File
@@ -32,7 +32,7 @@ async function mitSzenario(szenario: Szenario) {
describe("Verbindungstest (Erkundung)", () => { describe("Verbindungstest (Erkundung)", () => {
it("PVE 9.2 Einzelhost: Nodes, Storages, Bridges, keine fehlenden Rechte, Template-Bau per API", async () => { 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 { 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.version).toBe("9.2.1");
expect(bericht.cluster).toBeNull(); expect(bericht.cluster).toBeNull();
expect(bericht.knoten).toHaveLength(1); expect(bericht.knoten).toHaveLength(1);
+16 -6
View File
@@ -101,17 +101,27 @@ describe("Strategien", () => {
const mitImport = [storage("local", ["iso", "import", "snippets"])]; const mitImport = [storage("local", ["iso", "import", "snippets"])];
const ohneImport = [storage("local", ["iso", "snippets"])]; const ohneImport = [storage("local", ["iso", "snippets"])];
it("PVE 9.2 mit import-Storage: Template-Bau per API, auch ohne SSH", () => { it("PVE 9.2 mit import-Storage ohne SSH: Image ginge per API, der Guest-Agent aber nicht – außer mit Bau-Adresse", () => {
const f = faehigkeitenAbleiten({ const ohne = faehigkeitenAbleiten({
version: v("9.2.1"), version: v("9.2.1"),
storages: mitImport, storages: mitImport,
ssh: "aus", ssh: "aus",
rechteFehlen: false, rechteFehlen: false,
}); });
expect(f.templateBau.weg).toBe("api"); expect(ohne.templateBau.weg).toBe("nicht_moeglich");
expect(f.vmProvisionierung.weg).toBe("api"); expect(ohne.templateBau.grund).toMatch(/Bau-Adresse/);
expect(f.lxcProvisionierung).toMatchObject({ weg: "ssh", ueber: "gast" }); const mit = faehigkeitenAbleiten({
expect(f.snippets.weg).toBe("nicht_moeglich"); 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", () => { it("PVE 8.3: Template-Bau nur per SSH; ohne SSH mit Begründung unmöglich", () => {
+71
View File
@@ -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/);
});
});