// server/services/postInstall.ts import { getMachineRow, getCreds } from "./machines.js"; import { renderTemplate, type TemplateVars } from "../templates/render.js"; import { runScriptSudo } from "../ssh/client.js"; import { outputHub } from "../ws/outputHub.js"; import type { PostInstallResult, SnapshotError } from "@shared/types.js"; // ---------------------------------------------------------------------------- // Manifestes de profils (registre versionné en code ; templates versionnés sur disque). // ---------------------------------------------------------------------------- export type FieldType = | "string" | "hostname" | "ipv4" | "ipv4_cidr" | "ipv4_list" | "select" | "bool" | "int" | "path" | "secret"; export interface ProfileField { name: string; type: FieldType; required: boolean; label?: string; default?: string | number | boolean; defaultFrom?: string; // valeur détectée par machine_probe (ex. detected.primaryInterface) options?: string[]; // pour select } export interface ProfileManifest { id: string; label: string; description: string; risk: "low" | "medium" | "network_change"; requiresConfirmation: boolean; template: string; // chemin relatif sous templates/ fields: ProfileField[]; } export const PROFILES: Record = { bootstrap_root: { id: "bootstrap_root", label: "Bootstrap (sudo + base)", description: "Installe sudo, ca-certificates, curl et ajoute l'opérateur au groupe sudo.", risk: "low", requiresConfirmation: false, template: "custom/bootstrap-root.sh.tpl", fields: [ { name: "operatorUser", type: "string", required: true, label: "Utilisateur opérateur", defaultFrom: "sshUser" }, ], }, identity_network: { id: "identity_network", label: "Hostname + IP statique", description: "Définit le hostname, le domaine et l'adresse IP statique (sauvegarde des fichiers).", risk: "network_change", requiresConfirmation: true, template: "custom/identity-network.sh.tpl", fields: [ { name: "newHostname", type: "hostname", required: true, label: "Nouveau hostname" }, { name: "domain", type: "string", required: true, label: "Domaine", default: "home" }, { name: "interfaceName", type: "select", required: true, label: "Interface", defaultFrom: "detected.primaryInterface" }, { name: "staticAddress", type: "ipv4_cidr", required: true, label: "Adresse statique (CIDR)" }, { name: "gateway", type: "ipv4", required: true, label: "Passerelle", default: "10.0.0.1" }, { name: "dnsNameservers", type: "ipv4_list", required: true, label: "DNS", default: "10.0.0.1 10.0.0.10" }, { name: "reconnectHost", type: "ipv4", required: true, label: "IP de reconnexion", defaultFrom: "staticAddress.ip" }, { name: "rebootAfterInstall", type: "bool", required: false, label: "Reboot après application" }, ], }, }; // ---------------------------------------------------------------------------- // Validation (pure, testable). // ---------------------------------------------------------------------------- export type ProfileValues = Record; export interface ValidationResult { ok: boolean; errors: { field: string; message: string }[]; } const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; const HOSTNAME = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; function isIpv4(v: string): boolean { const m = IPV4.exec(v.trim()); return !!m && m.slice(1).every((o) => Number(o) >= 0 && Number(o) <= 255); } function isIpv4Cidr(v: string): boolean { const [ip, mask] = v.trim().split("/"); return !!ip && !!mask && isIpv4(ip) && Number(mask) >= 0 && Number(mask) <= 32; } function validateField(field: ProfileField, raw: string | number | boolean): string | null { const v = String(raw).trim(); switch (field.type) { case "hostname": return HOSTNAME.test(v) ? null : "hostname invalide"; case "ipv4": return isIpv4(v) ? null : "adresse IPv4 invalide"; case "ipv4_cidr": return isIpv4Cidr(v) ? null : "adresse CIDR invalide (ex. 10.0.0.50/22)"; case "ipv4_list": return v.split(/[\s,]+/).filter(Boolean).every(isIpv4) ? null : "liste d'IPv4 invalide"; case "int": return /^-?\d+$/.test(v) ? null : "entier attendu"; default: return null; } } export function validateProfileValues(manifest: ProfileManifest, values: ProfileValues): ValidationResult { const errors: { field: string; message: string }[] = []; for (const field of manifest.fields) { const v = values[field.name]; const empty = v === undefined || v === null || String(v).trim() === ""; if (empty) { if (field.required) errors.push({ field: field.name, message: "champ requis" }); continue; } const err = validateField(field, v); if (err) errors.push({ field: field.name, message: err }); } return { ok: errors.length === 0, errors }; } // ---------------------------------------------------------------------------- // Masquage des secrets (jamais en clair vers UI/MCP/preview). // ---------------------------------------------------------------------------- export function maskSecretValues(manifest: ProfileManifest, values: ProfileValues): ProfileValues { const secrets = new Set(manifest.fields.filter((f) => f.type === "secret").map((f) => f.name)); const out: ProfileValues = {}; for (const [k, v] of Object.entries(values)) { out[k] = secrets.has(k) && v !== undefined && v !== "" ? "********" : v; } return out; } /** Valeurs non sensibles uniquement (pour variablesUsed / persistance / Hermes). */ function nonSecretValues(manifest: ProfileManifest, values: ProfileValues): Record { const secrets = new Set(manifest.fields.filter((f) => f.type === "secret").map((f) => f.name)); const out: Record = {}; for (const [k, v] of Object.entries(values)) { if (!secrets.has(k) && v !== undefined) out[k] = v; } return out; } // ---------------------------------------------------------------------------- // Rendu + preview. // ---------------------------------------------------------------------------- function toTemplateVars(values: ProfileValues): TemplateVars { const vars: TemplateVars = {}; for (const [k, v] of Object.entries(values)) vars[k] = v as never; return vars; } export function renderProfile(profileId: string, values: ProfileValues): string { const manifest = PROFILES[profileId]; if (!manifest) throw new Error(`Profil inconnu : ${profileId}`); return renderTemplate(manifest.template, toTemplateVars(values)); } /** Preview du script rendu avec masquage des secrets. */ export function previewProfile(profileId: string, values: ProfileValues): string { const manifest = PROFILES[profileId]; if (!manifest) throw new Error(`Profil inconnu : ${profileId}`); return renderTemplate(manifest.template, toTemplateVars(maskSecretValues(manifest, values))); } // ---------------------------------------------------------------------------- // Parsing du résultat (pure, testable). // ---------------------------------------------------------------------------- function collectPrefixed(raw: string, prefix: string): string[] { const out: string[] = []; for (const line of raw.split("\n")) { const t = line.trim(); if (t.startsWith(prefix)) out.push(t.slice(prefix.length)); } return out; } function firstPrefixed(raw: string, prefix: string): string | null { for (const line of raw.split("\n")) { const t = line.trim(); if (t.startsWith(prefix)) return t.slice(prefix.length); } return null; } export function buildPostInstallResult( raw: string, profilesRun: string[], variablesUsed: Record, ): PostInstallResult { const errors: SnapshotError[] = collectPrefixed(raw, "ERR=").map((kind) => ({ source: "post_install", kind, severity: "error", message: `Échec post-install : ${kind}`, })); const oldEndpoint = firstPrefixed(raw, "OLD_ENDPOINT="); const newEndpoint = firstPrefixed(raw, "NEW_ENDPOINT="); const networkChange = oldEndpoint !== null || newEndpoint !== null ? { oldEndpoint: oldEndpoint || null, newEndpoint: newEndpoint || null, reconnectHost: newEndpoint || null } : undefined; return { profilesRun, variablesUsed, filesModified: collectPrefixed(raw, "FILE_MODIFIED="), packagesInstalled: collectPrefixed(raw, "PKG_INSTALLED="), servicesEnabled: collectPrefixed(raw, "SERVICE_ENABLED="), rebootsRequested: /REBOOT_REQUESTED=1/.test(raw), ...(networkChange ? { networkChange } : {}), ...(errors.length ? { errors } : {}), }; } // ---------------------------------------------------------------------------- // Orchestration (SSH). // ---------------------------------------------------------------------------- export interface PostInstallOutcome { result: PostInstallResult; raw: string; status: "ok" | "error"; } export async function runPostInstall( machineId: string, profileId: string, values: ProfileValues, onData?: (c: string) => void, ): Promise { const m = getMachineRow(machineId); if (!m) throw new Error("Machine introuvable"); const manifest = PROFILES[profileId]; if (!manifest) throw new Error(`Profil inconnu : ${profileId}`); const validation = validateProfileValues(manifest, values); if (!validation.ok) { throw new Error("Champs invalides : " + validation.errors.map((e) => `${e.field} (${e.message})`).join(", ")); } const script = renderProfile(profileId, values); const res = await runScriptSudo(getCreds(m), script, (c) => { onData?.(c); outputHub.publish(machineId, c); }); const raw = res.stdout; const result = buildPostInstallResult(raw, [profileId], nonSecretValues(manifest, values)); const failed = (result.errors?.length ?? 0) > 0 || (/===SU:EXIT=\d+===/.test(raw) && !/===SU:EXIT=0===/.test(raw)); return { result, raw, status: failed ? "error" : "ok" }; }