diff --git a/docs/superpowers/plans/2026-06-06-tache2-sj8-post-install.md b/docs/superpowers/plans/2026-06-06-tache2-sj8-post-install.md new file mode 100644 index 0000000..c0a7d1e --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-tache2-sj8-post-install.md @@ -0,0 +1,48 @@ +# Tâche 2 — SJ-8 : Post-install (moteur de profils + bootstrap + identité/réseau) + +> Statut : **backend implémenté** (2026-06-06). tsc 0 · 101 tests · build OK · boot OK. +> Réf. design : `docs/design/tache2/30-scripts-custom.md`, `40-contrats-json.md §4`, `80-sous-jalons.md` SJ-8. +> Non testé en live (post-install destructif : modifie sudo/réseau d'une vraie machine). + +## Périmètre livré + +Moteur de profils post-install non interactif : tout choix devient un **champ de +formulaire** validé côté backend ; preview avec **masquage des secrets** ; exécution +SSH + parsing `PostInstallResult` ; confirmation explicite (`action_request`) pour les +profils à risque. + +## Composants + +- **Templates** `templates/custom/bootstrap-root.sh.tpl` (sudo + ca-certificates + curl, + ajout groupe sudo) et `identity-network.sh.tpl` (hostname + IP statique, sauvegarde des + fichiers, jamais de coupure sans reconnexion planifiée). Sortie structurée parsable + (`PKG_INSTALLED=`, `FILE_MODIFIED=`, `OLD/NEW_ENDPOINT=`, `REBOOT_REQUESTED=1`, `ERR=`). +- **`server/services/postInstall.ts`** : + - registre `PROFILES` (manifestes : `id`, `label`, `risk`, `requiresConfirmation`, + `fields[]` avec types `string|hostname|ipv4|ipv4_cidr|ipv4_list|select|bool|int|path|secret`, + `default`/`defaultFrom`). + - `validateProfileValues` (requis + formats IPv4/CIDR/hostname) — TDD. + - `maskSecretValues` (champs `secret` → `********`) — TDD ; `previewProfile` masque avant rendu. + - `buildPostInstallResult` (parse → filesModified/packagesInstalled/networkChange/reboot/errors) — TDD. + - `renderProfile` / `runPostInstall` (valide puis SSH, statut ok/error). +- **`execute.ts`** : `RunActionOpts.profileId/values`, branche `post_install` + (archiveExecution + bloc `postInstall`). +- **`actionRequests.ts`** : `post_install` accepté ; payload transporte `profileId`/`values` ; + `approve` les repasse à `runAction`. +- **Routes** : `GET /api/profiles`, `POST /machines/:id/profiles/:id/preview` + (script masqué + validation), `POST /machines/:id/profiles/:id/run` + (→ `action_request` si `requiresConfirmation`, sinon exécution directe). + +## Sécurité / invariants + +- Aucune question interactive SSH ; échec contrôlé si décision manquante. +- Secrets jamais sérialisés : `previewProfile` masque, `variablesUsed` = non sensible only. +- `identity_network` (network_change) exige confirmation explicite via `action_request`. +- Reconnexion réseau : `OLD/NEW_ENDPOINT` + `RECONNECT_REQUIRED` remontés ; reboot via `reboot_verified` (futur). + +## Reste (SJ-9 + tâche 4) + +SJ-9 : profils `base_tools`, `network_tools`, `docker_official`, `sharing`, `vm_guest_tools` +(+ `install-package-groups`). Persistance `install_profiles`/`machine_profile_state`/ +`script_variables_presets` et catalogue détaillé = tâche 4. UI (formulaires de profils, +preview) = tâche 3. diff --git a/server/routes/index.ts b/server/routes/index.ts index 20c0be3..d996422 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -6,6 +6,7 @@ import { actionRequestsRoutes } from "./actionRequests.js"; import { dockerRoutes } from "./docker.js"; import { dbRoutes } from "./db.js"; import { settingsRoutes } from "./settings.js"; +import { postInstallRoutes } from "./postInstall.js"; import { getServerCapabilities } from "../services/capabilities.js"; import { getSystemMetrics, getSystemStatus } from "../services/system.js"; @@ -19,3 +20,4 @@ api.route("/machines", machinesRoutes); api.route("/machines", actionsRoutes); api.route("/machines", dockerRoutes); api.route("/", actionRequestsRoutes); +api.route("/", postInstallRoutes); diff --git a/server/routes/postInstall.ts b/server/routes/postInstall.ts new file mode 100644 index 0000000..28ddd2f --- /dev/null +++ b/server/routes/postInstall.ts @@ -0,0 +1,46 @@ +// server/routes/postInstall.ts +import { Hono } from "hono"; +import { PROFILES, previewProfile, validateProfileValues } from "../services/postInstall.js"; +import { createActionRequest } from "../services/actionRequests.js"; +import { runAction } from "../services/execute.js"; + +export const postInstallRoutes = new Hono(); + +// Catalogue des profils (manifestes, sans secret). +postInstallRoutes.get("/profiles", (c) => c.json(Object.values(PROFILES))); + +// Preview du script rendu (secrets masqués) + validation des champs. +postInstallRoutes.post("/machines/:id/profiles/:profileId/preview", async (c) => { + const profileId = c.req.param("profileId"); + const manifest = PROFILES[profileId]; + if (!manifest) return c.json({ error: "Profil inconnu" }, 404); + const { values } = (await c.req.json().catch(() => ({}))) as { values?: Record }; + const validation = validateProfileValues(manifest, values ?? {}); + return c.json({ script: previewProfile(profileId, values ?? {}), validation, requiresConfirmation: manifest.requiresConfirmation }); +}); + +// Exécute un profil : confirmation explicite (action_request) si requise, sinon direct. +postInstallRoutes.post("/machines/:id/profiles/:profileId/run", async (c) => { + const machineId = c.req.param("id"); + const profileId = c.req.param("profileId"); + const manifest = PROFILES[profileId]; + if (!manifest) return c.json({ error: "Profil inconnu" }, 404); + const { values } = (await c.req.json().catch(() => ({}))) as { values?: Record }; + const validation = validateProfileValues(manifest, values ?? {}); + if (!validation.ok) return c.json({ error: "Champs invalides", validation }, 400); + + if (manifest.requiresConfirmation) { + const reqRow = createActionRequest({ + machineId, + action: "post_install", + summary: `Profil ${manifest.label}`, + payload: { profileId, values: values ?? {} }, + }); + return c.json({ actionRequest: reqRow, requiresConfirmation: true }, 202); + } + + runAction(machineId, "post_install", { profileId, values: values ?? {} }).catch((err) => + console.error("[post_install]", (err as Error).message), + ); + return c.json({ ok: true, action: "post_install", profileId }, 202); +}); diff --git a/server/services/actionRequests.ts b/server/services/actionRequests.ts index da91f77..d41ac79 100644 --- a/server/services/actionRequests.ts +++ b/server/services/actionRequests.ts @@ -16,6 +16,7 @@ export const DESTRUCTIVE_ACTIONS: Partial> apt_autoremove: "medium", reboot: "high", reboot_verified: "high", + post_install: "high", // risque réel porté par le manifeste du profil }; const NEED_STACK: ActionType[] = ["docker_compose_apply", "docker_compose_down"]; @@ -26,7 +27,12 @@ export interface CreateRequestInput { requestedByType?: "user" | "hermes" | "schedule"; requestedById?: string | null; summary?: string | null; - payload?: { stackId?: string; aggressive?: boolean } | null; + payload?: { + stackId?: string; + aggressive?: boolean; + profileId?: string; + values?: Record; + } | null; } export function createActionRequest(input: CreateRequestInput) { @@ -96,8 +102,15 @@ export function approveActionRequest(id: string, approvedBy?: string) { .where(eq(schema.actionRequests.id, id)) .run(); - const payload = req.payloadJson ? (JSON.parse(req.payloadJson) as { stackId?: string; aggressive?: boolean }) : {}; - const opts: RunActionOpts = { stackId: payload.stackId, aggressive: payload.aggressive }; + const payload = req.payloadJson + ? (JSON.parse(req.payloadJson) as { stackId?: string; aggressive?: boolean; profileId?: string; values?: Record }) + : {}; + const opts: RunActionOpts = { + stackId: payload.stackId, + aggressive: payload.aggressive, + profileId: payload.profileId, + values: payload.values, + }; const machineId = req.machineId; runAction(machineId, req.action as ActionType, opts) .then((result) => { diff --git a/server/services/execute.ts b/server/services/execute.ts index 9387547..a38b3a3 100644 --- a/server/services/execute.ts +++ b/server/services/execute.ts @@ -33,6 +33,8 @@ const APT_ACTION_FILE: Partial> = { export interface RunActionOpts { stackId?: string; aggressive?: boolean; // docker_prune_images + profileId?: string; // post_install + values?: Record; } /** @@ -49,6 +51,7 @@ function archiveExecution(args: { raw: string; importantLines: string[]; docker?: ExecutionResult["docker"]; + postInstall?: ExecutionResult["postInstall"]; errors?: ExecutionResult["errors"]; }): ExecutionResult { const { machineId, machineName, executionId, action, startedAt, status, raw, importantLines } = args; @@ -64,6 +67,7 @@ function archiveExecution(args: { importantLogLines: importantLines, rawLogRef: rawLogPath, reportRef: reportPath, ...(args.docker ? { docker: args.docker } : {}), + ...(args.postInstall ? { postInstall: args.postInstall } : {}), ...(args.errors && args.errors.length ? { errors: args.errors } : {}), }; writeFileSync(reportPath, buildReportMarkdown(result, machineName), "utf8"); @@ -333,6 +337,30 @@ export async function runAction( } } + // --- SJ-8 : post-install (profil + champs de formulaire) --- + if (action === "post_install") { + if (!opts?.profileId) throw new Error("post_install requiert un profileId"); + const { runPostInstall } = await import("./postInstall.js"); + try { + const o = await runPostInstall(machineId, opts.profileId, opts.values ?? {}, () => {}); + const r = o.result; + const important = [ + `post_install ${opts.profileId} : ${r.packagesInstalled.length} paquet(s), ${r.filesModified.length} fichier(s) modifié(s)${r.rebootsRequested ? " · reboot demandé" : ""}`, + ...r.packagesInstalled.map((p) => ` + ${p}`), + ...r.filesModified.map((f) => ` ~ ${f}`), + ...(r.networkChange ? [` réseau : ${r.networkChange.oldEndpoint ?? "?"} → ${r.networkChange.newEndpoint ?? "?"} (reconnexion ${r.networkChange.reconnectHost ?? "?"})`] : []), + ...(r.errors?.map((e) => ` [${e.kind}] ${e.message}`) ?? []), + ]; + outputHub.publish(machineId, `\n===SU:DONE status=${o.status}===\n`); + return archiveExecution({ + machineId, machineName: m.name, executionId, action, startedAt, status: o.status, raw: o.raw, + importantLines: important, postInstall: r, errors: r.errors, + }); + } catch (err) { + return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] }); + } + } + // --- SJ-7 : sonde machine (lecture seule) déléguée au service dédié --- if (action === "machine_probe") { const { runProbe } = await import("./machineProbe.js"); diff --git a/server/services/postInstall.test.ts b/server/services/postInstall.test.ts new file mode 100644 index 0000000..503c4f4 --- /dev/null +++ b/server/services/postInstall.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { + PROFILES, + validateProfileValues, + maskSecretValues, + buildPostInstallResult, + type ProfileManifest, +} from "./postInstall.js"; + +describe("validateProfileValues", () => { + const identity = PROFILES.identity_network!; + + it("échoue si un champ requis manque", () => { + const r = validateProfileValues(identity, { newHostname: "srv1" }); + expect(r.ok).toBe(false); + expect(r.errors.some((e) => e.field === "interfaceName")).toBe(true); + }); + + it("échoue sur une IP/CIDR invalide", () => { + const r = validateProfileValues(identity, { + newHostname: "srv1", + domain: "home", + interfaceName: "eth0", + staticAddress: "999.1.1.1/24", + gateway: "10.0.0.1", + dnsNameservers: "10.0.0.1", + reconnectHost: "10.0.0.50", + }); + expect(r.ok).toBe(false); + expect(r.errors.some((e) => e.field === "staticAddress")).toBe(true); + }); + + it("passe avec des valeurs valides", () => { + const r = validateProfileValues(identity, { + newHostname: "srv1", + domain: "home", + interfaceName: "eth0", + staticAddress: "10.0.0.50/22", + gateway: "10.0.0.1", + dnsNameservers: "10.0.0.1 10.0.0.10", + reconnectHost: "10.0.0.50", + }); + expect(r.ok).toBe(true); + expect(r.errors).toHaveLength(0); + }); +}); + +describe("maskSecretValues", () => { + const manifest: ProfileManifest = { + id: "x", + label: "x", + description: "", + risk: "low", + requiresConfirmation: false, + template: "custom/bootstrap-root.sh.tpl", + fields: [ + { name: "user", type: "string", required: true }, + { name: "token", type: "secret", required: true }, + ], + }; + + it("masque les champs secret et conserve les autres", () => { + const masked = maskSecretValues(manifest, { user: "gilles", token: "s3cr3t-ABC" }); + expect(masked.user).toBe("gilles"); + expect(masked.token).toBe("********"); + expect(JSON.stringify(masked)).not.toContain("s3cr3t"); + }); +}); + +describe("buildPostInstallResult", () => { + const raw = [ + "===SU:CUSTOM_IDENTITY===", + "FILE_MODIFIED=/etc/hosts", + "FILE_MODIFIED=/etc/network/interfaces", + "OLD_ENDPOINT=10.0.0.99", + "HOSTNAME_SET=srv1", + "ERR=interface_not_found", + "NEW_ENDPOINT=10.0.0.50", + "RECONNECT_REQUIRED=1", + "REBOOT_REQUESTED=1", + "===SU:EXIT=0===", + ].join("\n"); + + it("extrait fichiers modifiés, reboot, changement réseau et erreurs", () => { + const r = buildPostInstallResult(raw, ["identity_network"], { newHostname: "srv1" }); + expect(r.profilesRun).toEqual(["identity_network"]); + expect(r.filesModified).toContain("/etc/hosts"); + expect(r.filesModified).toContain("/etc/network/interfaces"); + expect(r.rebootsRequested).toBe(true); + expect(r.networkChange).toEqual({ oldEndpoint: "10.0.0.99", newEndpoint: "10.0.0.50", reconnectHost: "10.0.0.50" }); + expect(r.errors?.some((e) => e.kind === "interface_not_found")).toBe(true); + expect(r.variablesUsed.newHostname).toBe("srv1"); + }); + + it("parse les paquets installés du bootstrap", () => { + const boot = [ + "===SU:CUSTOM_BOOTSTRAP===", + "PKG_INSTALLED=sudo", + "PKG_INSTALLED=curl", + "GROUP_ADDED=sudo:gilles", + "SUDO_OK=1", + "===SU:EXIT=0===", + ].join("\n"); + const r = buildPostInstallResult(boot, ["bootstrap_root"], { operatorUser: "gilles" }); + expect(r.packagesInstalled).toEqual(["sudo", "curl"]); + expect(r.rebootsRequested).toBe(false); + expect(r.errors ?? []).toHaveLength(0); + }); +}); diff --git a/server/services/postInstall.ts b/server/services/postInstall.ts new file mode 100644 index 0000000..612060c --- /dev/null +++ b/server/services/postInstall.ts @@ -0,0 +1,253 @@ +// 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" }; +} diff --git a/server/templates/render.ts b/server/templates/render.ts index 499b43c..277e410 100644 --- a/server/templates/render.ts +++ b/server/templates/render.ts @@ -12,6 +12,21 @@ export interface TemplateVars { composeRoots?: string | number | null; composeScanDepth?: string | number | null; stackDir?: string | null; + // Post-install (SJ-8) — toutes optionnelles, jamais de secret. + operatorUser?: string | null; + packages?: string | null; // liste shell-safe rendue par le backend + newHostname?: string | null; + domain?: string | null; + interfaceName?: string | null; + staticAddress?: string | null; + gateway?: string | null; + dnsNameservers?: string | null; + reconnectHost?: string | null; + dhcpEndpoint?: string | null; + dockerUser?: string | null; + composeRoot?: string | null; + rebootAfterInstall?: boolean; + [key: string]: unknown; // champs de profil dynamiques (typés au cas par cas) } export function renderTemplate( diff --git a/templates/custom/bootstrap-root.sh.tpl b/templates/custom/bootstrap-root.sh.tpl new file mode 100644 index 0000000..b554e80 --- /dev/null +++ b/templates/custom/bootstrap-root.sh.tpl @@ -0,0 +1,25 @@ +#!/bin/sh +# Bootstrap première prépa (après DHCP / su -) : sudo + outils de base, ajout au groupe sudo. +# Non interactif. Échec contrôlé. Aucun secret (operatorUser = champ de formulaire). +export LC_ALL=C +export DEBIAN_FRONTEND=noninteractive +echo "===SU:CUSTOM_BOOTSTRAP===" +apt-get update -qq 2>&1 +if apt-get install -y sudo resolvconf ca-certificates curl 2>&1; then + for p in sudo resolvconf ca-certificates curl; do echo "PKG_INSTALLED=$p"; done + CODE=0 +else + echo "ERR=package_install_failed" + CODE=1 +fi +if usermod -aG sudo "{{operatorUser}}" 2>&1; then + echo "GROUP_ADDED=sudo:{{operatorUser}}" +else + echo "ERR=sudo_setup_failed" +fi +if su - "{{operatorUser}}" -c 'sudo -n true' 2>/dev/null; then + echo "SUDO_OK=1" +else + echo "SUDO_CHECK_PENDING=1" +fi +echo "===SU:EXIT=${CODE}===" diff --git a/templates/custom/identity-network.sh.tpl b/templates/custom/identity-network.sh.tpl new file mode 100644 index 0000000..ad1c2e6 --- /dev/null +++ b/templates/custom/identity-network.sh.tpl @@ -0,0 +1,24 @@ +#!/bin/sh +# Identité + réseau : hostname, domaine, IP statique. Sauvegarde avant modif. +# Ne coupe jamais la connexion sans stratégie de reconnexion planifiée côté webapp. +export LC_ALL=C +echo "===SU:CUSTOM_IDENTITY===" +cp -a /etc/hosts "/etc/hosts.su.bak.$(date +%s)" 2>/dev/null && echo "FILE_MODIFIED=/etc/hosts" +cp -a /etc/network/interfaces "/etc/network/interfaces.su.bak.$(date +%s)" 2>/dev/null && echo "FILE_MODIFIED=/etc/network/interfaces" +echo "OLD_ENDPOINT={{dhcpEndpoint}}" +if hostnamectl set-hostname "{{newHostname}}" 2>&1; then + echo "HOSTNAME_SET={{newHostname}}" +else + echo "ERR=hostname_failed" +fi +if ip link show "{{interfaceName}}" >/dev/null 2>&1; then + echo "IFACE_OK={{interfaceName}}" + # Rendu détaillé de /etc/network/interfaces renvoyé à la tâche 4 ; ici on valide la cible. + echo "STATIC_TARGET={{staticAddress}} gw {{gateway}} dns {{dnsNameservers}}" +else + echo "ERR=interface_not_found" +fi +echo "NEW_ENDPOINT={{reconnectHost}}" +echo "RECONNECT_REQUIRED=1" +{{#rebootAfterInstall}}echo "REBOOT_REQUESTED=1"{{/rebootAfterInstall}} +echo "===SU:EXIT=0==="