diff --git a/README.md b/README.md index cbcbfc9c..854aed3a 100644 --- a/README.md +++ b/README.md @@ -499,7 +499,7 @@ sudo python3 /opt/fedeo/scripts/selfhost-update.py status Beim ersten Update die erzeugte Compose-Differenz prüfen, eigene Anpassungen in eine Override-Datei übertragen und den Austausch mit `--accept-compose-replacement` bestätigen. Bestehende Installationen müssen das Update-Skript zunächst aus einem passenden Release übernehmen. -Optional führt `fedeo-updater.service` die Aufträge unabhängig von der anfragenden Verbindung aus. Nach Bereitstellung der zugehörigen Dienstdateien installiert `sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh` den systemd-Dienst. Die lokale Unix-Socket-API bietet Prüfung, Update-Start und Status; Backend-Anbindung und Frontend-Button folgen separat. Einrichtung und API-Beispiele stehen in der folgenden Anleitung. +Unter **Administration → Systemstatus → FEDEO-Updates** können Systemadministratoren neue Releases prüfen und nach Bestätigung installieren. Dazu führt `fedeo-updater.service` die Aufträge unabhängig von Browser und Backend-Neustarts aus. Nach Bereitstellung der zugehörigen Dienstdateien installiert `sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh` den systemd-Dienst. Die aktuelle Selfhost-Compose-Datei bindet dessen lokales Socket-Verzeichnis im Backend ein. Bestehende Installationen benötigen einmalig die aktuellen Hostskripte und ein CLI-Update auf einen Release mit der Oberfläche. Einrichtung und Schritte stehen in der folgenden Anleitung. Voraussetzungen, unterstützte Konfigurationen und Wiederherstellung stehen in [Vollständige Selfhost-Updates](docs/selfhost-updates.md). Für ältere Releases steht der manuelle Ablauf in [Versionierung und Releases](docs/releases.md). diff --git a/agents/fedeo-device-agent/README.md b/agents/fedeo-device-agent/README.md index b65df957..94a03673 100644 --- a/agents/fedeo-device-agent/README.md +++ b/agents/fedeo-device-agent/README.md @@ -26,6 +26,19 @@ Die Installation ist je Agent getrennt: Die entsprechenden XDG-Verzeichnisse werden berücksichtigt. Der Autostart beginnt bei der Benutzeranmeldung. Ohne Autostart muss das gestartete Terminal geöffnet bleiben. Protokolle: `journalctl --user -u fedeo-device-agent-.service -f`. Eine erneute Installation ersetzt nur die Dateien dieses Agenten. +### Scannerverwaltung meldet HTTP 500 nach einem Serverupdate + +Die älteren Scanner-SQL-Dateien `0051` bis `0053` waren nicht im Drizzle-Migrationsjournal eingetragen und wurden dadurch bei regulären Updates übersprungen. Die Vorwärtsmigration `0072_restore_instance_scan_agents` legt fehlende Tabellen und Spalten nachträglich an. Vorhandene Agenten, Tokens, Einstellungen und Scanaufträge bleiben erhalten. + +Zuerst einen Backend-Stand mit dieser Migration ausrollen. Bei aktiviertem `FEDEO_RUN_MIGRATIONS` wird sie beim Start automatisch ausgeführt. Bei bewusst deaktivierten Startmigrationen muss anschließend im aktualisierten Backend `npm run migrate` ausgeführt werden. Die alten Journal-Zeitstempel nachträglich einzufügen genügt bei bereits aktualisierten Datenbanken nicht. + +Der Datenbanktest läuft isoliert gegen ein temporäres PostgreSQL-Image und verwendet keine Anwendungskonfiguration: + +```bash +cd backend +FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine node --import tsx tests/instanceAgentMigration.test.ts +``` + ## Voraussetzungen ### macOS diff --git a/backend/db/migrations/0072_restore_instance_scan_agents.sql b/backend/db/migrations/0072_restore_instance_scan_agents.sql new file mode 100644 index 00000000..d3ba67fa --- /dev/null +++ b/backend/db/migrations/0072_restore_instance_scan_agents.sql @@ -0,0 +1,51 @@ +-- Forward repair: 0051-0053 were shipped without migration journal entries. +-- Keep existing agents/jobs when these SQL files were applied manually. +CREATE TABLE IF NOT EXISTS "instance_agents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "name" text NOT NULL, + "description" text, + "token_prefix" text NOT NULL, + "token_hash" text NOT NULL, + "active" boolean DEFAULT true NOT NULL, + "capabilities" jsonb DEFAULT '{"scan":true,"print":false}'::jsonb NOT NULL, + "scanner_names" jsonb DEFAULT '[]'::jsonb NOT NULL, + "printer_names" jsonb DEFAULT '[]'::jsonb NOT NULL, + "last_seen_at" timestamp with time zone, + "last_debug_info" jsonb, + CONSTRAINT "instance_agents_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "instance_agent_scan_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "tenant_id" bigint NOT NULL, + "agent_id" uuid NOT NULL, + "requested_by" uuid, + "status" text DEFAULT 'pending' NOT NULL, + "scanner_name" text, + "requested_filename" text, + "settings" jsonb DEFAULT '{}'::jsonb NOT NULL, + "target" jsonb DEFAULT '{}'::jsonb NOT NULL, + "agent_message" text, + "attempts" integer DEFAULT 0 NOT NULL, + "claimed_at" timestamp with time zone, + "finished_at" timestamp with time zone, + "file_id" uuid, + CONSTRAINT "instance_agent_scan_jobs_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade, + CONSTRAINT "instance_agent_scan_jobs_agent_id_instance_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."instance_agents"("id") ON DELETE cascade ON UPDATE cascade, + CONSTRAINT "instance_agent_scan_jobs_requested_by_auth_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade, + CONSTRAINT "instance_agent_scan_jobs_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE cascade +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "instance_agent_scan_jobs_agent_status_idx" ON "instance_agent_scan_jobs" USING btree ("agent_id","status","created_at"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "instance_agent_scan_jobs_tenant_idx" ON "instance_agent_scan_jobs" USING btree ("tenant_id","created_at"); +--> statement-breakpoint +ALTER TABLE "instance_agents" ADD COLUMN IF NOT EXISTS "preferred_scanner_name" text; +--> statement-breakpoint +ALTER TABLE "instance_agents" ADD COLUMN IF NOT EXISTS "scan_defaults" jsonb DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null}'::jsonb NOT NULL; +--> statement-breakpoint +ALTER TABLE "instance_agents" ALTER COLUMN "scan_defaults" SET DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null,"postprocess":false,"postprocessProfile":"document"}'::jsonb; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index ab6d3bcd..3e3ce195 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -484,6 +484,13 @@ "when": 1790121600000, "tag": "0071_statement_allocation_costcentre", "breakpoints": true + }, + { + "idx": 69, + "version": "7", + "when": 1790121600001, + "tag": "0072_restore_instance_scan_agents", + "breakpoints": true } ] } diff --git a/backend/src/index.ts b/backend/src/index.ts index 7f28ad17..b1828a9d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,7 @@ import authRoutes from "./routes/auth/auth"; import authRoutesAuthenticated from "./routes/auth/auth-authenticated"; import authPlugin from "./plugins/auth"; import adminRoutes from "./routes/admin"; +import selfhostUpdateRoutes from "./routes/selfhostUpdates"; import corsPlugin from "./plugins/cors"; import queryConfigPlugin from "./plugins/queryconfig"; import dbPlugin from "./plugins/db"; @@ -143,6 +144,7 @@ async function main() { await subApp.register(meRoutes); await subApp.register(tenantRoutes); await subApp.register(adminRoutes); + await subApp.register(selfhostUpdateRoutes); await subApp.register(resourceRoutesSpecial); await subApp.register(historyRoutes); await subApp.register(fileRoutes); diff --git a/backend/src/modules/selfhost-updater.ts b/backend/src/modules/selfhost-updater.ts new file mode 100644 index 00000000..394ca160 --- /dev/null +++ b/backend/src/modules/selfhost-updater.ts @@ -0,0 +1,91 @@ +import { request } from "node:http"; +import { z } from "zod"; + +const version = z.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/); +export const requestId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/); +export const checkBody = z.object({ requestId, version: version.optional() }).strict(); +export const applyBody = z.object({ requestId, version, checkRequestId: requestId }).strict(); +const errors = { + INVALID_REQUEST: "Ungültige Anfrage.", NOT_FOUND: "Auftrag nicht gefunden.", + BUSY: "Eine Prüfung oder ein Update läuft bereits.", SHUTTING_DOWN: "Der Update-Dienst wird beendet.", + REQUEST_CONFLICT: "Diese Auftrags-ID wurde bereits für eine andere Anfrage verwendet.", + CHECK_REQUIRED: "Bitte die Zielversion erneut prüfen.", + COMPOSE_REVIEW_REQUIRED: "Die Compose-Datei muss zuerst auf dem Host geprüft und übernommen werden.", + RECOVERY_REQUIRED: "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.", + PLAN_CHANGED: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen.", + UPDATE_FAILED: "Auftrag fehlgeschlagen. Geschützte Protokolle auf dem Host prüfen.", + INTERRUPTED: "Der Auftrag wurde unterbrochen. Status auf dem Host prüfen.", + UPDATER_UNAVAILABLE: "Der Host-Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung prüfen.", + UPDATER_PROTOCOL: "Der Host-Update-Dienst hat eine ungültige Antwort geliefert.", +}; +type ErrorCode = keyof typeof errors; +const knownError = (code: unknown): code is ErrorCode => typeof code === "string" && Object.prototype.hasOwnProperty.call(errors, code); +export class UpdaterError extends Error { + constructor(public code: ErrorCode, public status = 503) { super(errors[code]); } +} +const state = z.object({ + status: z.enum(["idle", "queued", "running", "checked", "up_to_date", "succeeded", "failed", "interrupted", "recovered"]), + phase: z.enum(["preparing", "validating", "checked", "pulling", "maintenance", "backed_up", "applying", + "retiring_matrix", "migrating", "starting", "verifying", "complete", "resolved"]).optional(), + command: z.enum(["check", "apply"]).optional(), requestId: requestId.optional(), + version: version.optional(), previousVersion: version.optional(), revision: z.string().regex(/^[a-f0-9]{40}$/).optional(), + recoveryRequired: z.boolean().optional(), maintenance: z.boolean().optional(), mutationStarted: z.boolean().optional(), + startedAt: z.string().regex(/^[0-9T:+.Z-]{10,40}$/).optional(), updatedAt: z.string().regex(/^[0-9T:+.Z-]{10,40}$/).optional(), +}); +const plan = state.omit({ status: true }).extend({ + updateAvailable: z.boolean(), requiresComposeAdoption: z.boolean(), + estimatedBackupBytes: z.number().nonnegative().finite().optional(), + retiredServices: z.array(z.enum(["matrix-db", "matrix-synapse", "matrix-redis", "matrix-element", + "matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"])), +}); +const job = state.extend({ plan: plan.optional(), progress: state.optional(), + error: z.object({ code: z.string() }).transform(({ code }) => ({ + code: knownError(code) ? code : "UPDATE_FAILED", + message: knownError(code) ? errors[code] : errors.UPDATE_FAILED, + })).optional(), +}); +const status = z.object({ apiVersion: z.literal(1), acceptingRequests: z.boolean(), engine: state, + installed: state.pick({ version: true, revision: true }), request: job.optional(), +}); + +// No caller-controlled host, URL, socket, headers or command can reach the privileged service. +export function createUpdaterClient(socketPath = "/run/fedeo-updater/control.sock", timeoutMs = 5000) { + return (method: "GET" | "POST", path: string, body?: unknown): Promise<{ status: number; body: unknown }> => { + if (!((method === "GET" && (path === "/v1/status" || /^\/v1\/requests\/[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/.test(path))) + || (method === "POST" && ["/v1/check", "/v1/apply"].includes(path)))) { + return Promise.reject(new UpdaterError("INVALID_REQUEST", 400)); + } + return new Promise((resolve, reject) => { + const payload = body === undefined ? undefined : JSON.stringify(body); + const req = request({ socketPath, path, method, agent: false, headers: payload ? { + "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload), + } : {} }, res => { + const chunks: Buffer[] = []; + let bytes = 0; + res.on("data", chunk => { + bytes += chunk.length; + if (bytes > 65536) req.destroy(new UpdaterError("UPDATER_PROTOCOL", 502)); + else chunks.push(chunk); + }); + res.on("error", () => reject(new UpdaterError("UPDATER_UNAVAILABLE"))); + res.on("end", () => { + try { + const data = JSON.parse(Buffer.concat(chunks).toString("utf8")); + const code = data?.error?.code; + if (![200, 202].includes(res.statusCode || 0)) { + throw knownError(code) && [400, 404, 409, 503].includes(res.statusCode!) + ? new UpdaterError(code, res.statusCode) : new UpdaterError("UPDATER_PROTOCOL", 502); + } + resolve({ status: res.statusCode!, body: (path === "/v1/status" ? status : job).parse(data) }); + } catch (error) { + reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_PROTOCOL", 502)); + } + }); + }); + const timer = setTimeout(() => req.destroy(new UpdaterError("UPDATER_UNAVAILABLE")), timeoutMs); + req.on("close", () => clearTimeout(timer)); + req.on("error", error => reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_UNAVAILABLE"))); + req.end(payload); + }); + }; +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 2c376368..58e3cec4 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -29,6 +29,7 @@ import { importTenantFullExportArchive, } from "../utils/tenantFullExport"; import type { TenantFullExport } from "../utils/tenantFullExport"; +import { requireSystemAdmin } from "../utils/requireSystemAdmin"; import { buildSystemStatus } from "../modules/system-status.service"; import { s3 } from "../utils/s3"; import { secrets } from "../utils/secrets"; @@ -563,29 +564,7 @@ export default async function adminRoutes(server: FastifyInstance) { return job; }; - const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => { - if (!req.user?.user_id) { - reply.code(401).send({ error: "Unauthorized" }); - return null; - } - - const [currentUser] = await server.db - .select({ - id: authUsers.id, - email: authUsers.email, - is_admin: authUsers.is_admin, - }) - .from(authUsers) - .where(eq(authUsers.id, req.user.user_id)) - .limit(1); - - if (!currentUser?.is_admin) { - reply.code(403).send({ error: "Admin access required" }); - return null; - } - - return currentUser; - }; + const requireAdmin = (req: FastifyRequest, reply: FastifyReply) => requireSystemAdmin(server, req, reply); const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => { const existingRoles = await server.db diff --git a/backend/src/routes/selfhostUpdates.ts b/backend/src/routes/selfhostUpdates.ts new file mode 100644 index 00000000..04a7202b --- /dev/null +++ b/backend/src/routes/selfhostUpdates.ts @@ -0,0 +1,38 @@ +import type { FastifyInstance } from "fastify"; +import { applyBody, checkBody, createUpdaterClient, requestId, UpdaterError } from "../modules/selfhost-updater"; +import { requireSystemAdmin } from "../utils/requireSystemAdmin"; +import { getBuildInfo } from "../utils/buildInfo"; + +export default async function selfhostUpdateRoutes(server: FastifyInstance, options: { client?: ReturnType }) { + const client = options.client || createUpdaterClient(); + server.addHook("preHandler", async (req, reply) => { + reply.header("Cache-Control", "no-store"); + await requireSystemAdmin(server, req, reply); + }); + const forward = async (reply: any, method: "GET" | "POST", path: string, body?: unknown) => { + try { + const result = await client(method, path, body); + return reply.code(result.status).send(path === "/v1/status" + ? { ...result.body as object, backend: getBuildInfo() } : result.body); + } catch (error) { + const safe = error instanceof UpdaterError ? error : new UpdaterError("UPDATER_UNAVAILABLE"); + return reply.code(safe.status).send({ error: { code: safe.code, message: safe.message } }); + } + }; + server.get("/admin/updates/status", async (_req, reply) => forward(reply, "GET", "/v1/status")); + server.get<{ Params: { id: string } }>("/admin/updates/requests/:id", async (req, reply) => { + if (!requestId.safeParse(req.params.id).success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } }); + return forward(reply, "GET", `/v1/requests/${req.params.id}`); + }); + for (const command of ["check", "apply"] as const) { + server.post(`/admin/updates/${command}`, { bodyLimit: 4096 }, async (req, reply) => { + // The auth plugin verifies this JWT. Cookies alone cannot trigger host operations (CSRF). + if (!/^Bearer \S{11,}$/.test(req.headers.authorization || "")) { + return reply.code(403).send({ error: { code: "BEARER_REQUIRED", message: "Bitte erneut anmelden." } }); + } + const parsed = (command === "apply" ? applyBody : checkBody).safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } }); + return forward(reply, "POST", `/v1/${command}`, parsed.data); + }); + } +} diff --git a/backend/src/utils/requireSystemAdmin.ts b/backend/src/utils/requireSystemAdmin.ts new file mode 100644 index 00000000..2c322e51 --- /dev/null +++ b/backend/src/utils/requireSystemAdmin.ts @@ -0,0 +1,18 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { eq } from "drizzle-orm"; +import { authUsers } from "../../db/schema/auth_users"; + +export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) { + if (!req.user?.user_id) { + reply.code(401).send({ error: "Unauthorized" }); + return null; + } + // Read the current global role; tenant roles and stale JWT claims do not grant access. + const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin }) + .from(authUsers).where(eq(authUsers.id, req.user.user_id)).limit(1); + if (!user?.is_admin) { + reply.code(403).send({ error: "Admin access required" }); + return null; + } + return user; +} diff --git a/backend/tests/instanceAgentMigration.test.ts b/backend/tests/instanceAgentMigration.test.ts new file mode 100644 index 00000000..d7815cba --- /dev/null +++ b/backend/tests/instanceAgentMigration.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { readFile, mkdir, mkdtemp, writeFile, rm } from "node:fs/promises" +import { readFileSync } from "node:fs" +import path from "node:path" +import os from "node:os" +import { randomUUID } from "node:crypto" +import { Pool } from "pg" +import { drizzle } from "drizzle-orm/node-postgres" +import { migrate } from "drizzle-orm/node-postgres/migrator" +import { readMigrationFiles } from "drizzle-orm/migrator" +import Fastify from "fastify" +import routes from "../src/routes/instanceAgents" + +const migrationsFolder = path.resolve(__dirname, "../db/migrations") + path.sep +const repairTag = "0072_restore_instance_scan_agents" +const journal = JSON.parse(readFileSync(migrationsFolder + "meta/_journal.json", "utf8")).entries +const repair = journal.find((entry: any) => entry.tag === repairTag) +const previousWatermark = Math.max(...journal.filter((entry: any) => entry.idx < repair?.idx).map((entry: any) => entry.when)) +const execute = promisify(execFile) + +test("the scanner repair is registered after every previously shipped migration", () => { + assert.ok(repair, "scanner repair must be in the Drizzle journal") + assert.ok(repair.when > previousWatermark) + const pending = readMigrationFiles({ migrationsFolder }).filter(entry => entry.folderMillis > previousWatermark) + assert.ok(pending.some(entry => entry.sql.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS "instance_agents"')))) +}) + +// Opt in with FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine. This creates its own +// disposable database container; it never reads DATABASE_URL or application secrets. +test("migration repairs upgraded databases and preserves manually installed scanners", { + skip: !process.env.FEDEO_TEST_POSTGRES_IMAGE, +}, async t => { + // Isolate this repair from unrelated future migrations. Registration in the + // actual shipped journal is checked above; its timestamp remains unchanged. + const testMigrations = await mkdtemp(path.join(os.tmpdir(), "fedeo-scan-migrations-")) + t.after(() => rm(testMigrations, { recursive: true, force: true })) + await mkdir(path.join(testMigrations, "meta")) + await writeFile(path.join(testMigrations, "meta/_journal.json"), JSON.stringify({ version: "7", dialect: "postgresql", entries: [repair] })) + await writeFile(path.join(testMigrations, repairTag + ".sql"), await readFile(migrationsFolder + repairTag + ".sql")) + const container = "fedeo-scan-migration-" + randomUUID() + const password = randomUUID() + await execute("docker", ["run", "--rm", "-d", "--name", container, + "-e", "POSTGRES_PASSWORD=" + password, "-p", "127.0.0.1::5432", + process.env.FEDEO_TEST_POSTGRES_IMAGE!]) + let admin: Pool | undefined + t.after(async () => { + try { await admin?.end() } + finally { await execute("docker", ["rm", "-f", container]) } + }) + const { stdout } = await execute("docker", ["port", container, "5432/tcp"]) + const port = Number(stdout.trim().split(":").at(-1)) + admin = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database: "postgres", connectionTimeoutMillis: 1000 }) + for (let attempt = 0; ; attempt++) { + try { await admin.query("SELECT 1"); break } catch (error) { + if (attempt >= 80) throw error + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + + for (const state of ["absent", "0051", "0052", "0053"]) { + await t.test(state, async () => { + const database = "scan_" + state + await admin.query('CREATE DATABASE "' + database + '"') + const pool = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database, max: 1 }) + const app = Fastify() + try { + await pool.query(` + CREATE TABLE tenants (id bigint PRIMARY KEY); + CREATE TABLE auth_users (id uuid PRIMARY KEY); + CREATE TABLE files (id uuid PRIMARY KEY); + INSERT INTO tenants VALUES (1); + CREATE SCHEMA drizzle; + CREATE TABLE drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint); + `) + // An existing installation already passed 0071. Re-inserting old + // journal entries 0051-0053 would therefore NOT fix this database. + await pool.query("INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('previous-release', $1)", [previousWatermark]) + if (state !== "absent") { + for (const file of ["0051_instance_scan_agents", "0052_instance_agent_scan_defaults", "0053_instance_agent_postprocess_defaults"]) { + if (file.slice(0, 4) <= state) await pool.query(await readFile(migrationsFolder + file + ".sql", "utf8")) + } + await pool.query(` + INSERT INTO instance_agents (id, name, token_prefix, token_hash) + VALUES ('00000000-0000-4000-8000-000000000001', 'Existing scanner', 'prefix', 'existing-hash'); + INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id, status, settings) + VALUES (1, '00000000-0000-4000-8000-000000000001', 'completed', '{"resolution":600}'); + `) + if (state !== "0051") { + await pool.query(`UPDATE instance_agents SET preferred_scanner_name='existing-device', scan_defaults='{"resolution":600}'`) + } + } + + const db = drizzle(pool) + await migrate(db, { migrationsFolder: testMigrations }) + await migrate(db, { migrationsFolder: testMigrations }) + // Also prove the repair SQL itself is safe after manual application. + await pool.query(await readFile(migrationsFolder + repairTag + ".sql", "utf8")) + app.decorate("db", db) + app.addHook("onRequest", async req => { req.user = { is_admin: true } as any }) + await app.register(routes) + const listing = await app.inject({ method: "GET", url: "/instance-agents" }) + assert.equal(listing.statusCode, 200, listing.body) + const agents = listing.json().agents + assert.equal(agents.length, state === "absent" ? 0 : 1) + if (state !== "absent") { + assert.equal(agents[0].name, "Existing scanner") + assert.equal((await pool.query("SELECT token_hash FROM instance_agents")).rows[0].token_hash, "existing-hash") + assert.equal((await pool.query("SELECT status, settings FROM instance_agent_scan_jobs")).rows[0].settings.resolution, 600) + if (state !== "0051") { + assert.equal(agents[0].preferredScannerName, "existing-device") + assert.deepEqual(agents[0].scanDefaults, { resolution: 600 }) + } + } + const created = await app.inject({ method: "POST", url: "/instance-agents", payload: { name: "New scanner" } }) + assert.equal(created.statusCode, 200, created.body) + assert.equal(created.json().agent.scanDefaults.postprocess, false) + assert.equal(created.json().agent.scanDefaults.postprocessProfile, "document") + assert.equal(created.json().agent.scanDefaults.format, "pdf") + assert.equal((await pool.query("SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations WHERE created_at = $1", [repair.when])).rows[0].count, 1) + const indexes = (await pool.query("SELECT indexname FROM pg_indexes WHERE tablename='instance_agent_scan_jobs'")).rows.map(row => row.indexname) + assert.ok(indexes.includes("instance_agent_scan_jobs_agent_status_idx")) + assert.ok(indexes.includes("instance_agent_scan_jobs_tenant_idx")) + await assert.rejects(pool.query("INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id) VALUES (999, $1)", [created.json().agent.id]), { code: "23503" }) + } finally { + await app.close() + await pool.end() + } + }) + } +}) diff --git a/backend/tests/selfhostUpdates.test.ts b/backend/tests/selfhostUpdates.test.ts new file mode 100644 index 00000000..713c8609 --- /dev/null +++ b/backend/tests/selfhostUpdates.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createServer } from "node:http"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import Fastify from "fastify"; +import routes from "../src/routes/selfhostUpdates"; +import { createUpdaterClient } from "../src/modules/selfhost-updater"; +import authPlugin from "../src/plugins/auth"; +import { secrets } from "../src/utils/secrets"; +import jwt from "jsonwebtoken"; + +const id = "12345678-1234-4123-a123-123456789abc"; +const headers = { authorization: "Bearer verified-jwt-token" }; +async function fixture(t: any, admin = true, authenticated = true) { + const app = Fastify(); + const calls: unknown[] = []; + let currentAdmin = admin; + // Mirror the parent auth plugin's preHandler order, not an earlier onRequest hook. + app.addHook("preHandler", async req => { if (authenticated) req.user = { user_id: id, is_admin: true } as any; }); + app.decorate("db", { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, is_admin: currentAdmin }] }) }) }) }); + await app.register(routes, { client: async (...args) => { + calls.push(args); return { status: 202, body: { status: "queued", requestId: id } }; + } }); + t.after(() => app.close()); + return { app, calls, revoke: () => { currentAdmin = false; } }; +} +test("all update endpoints require a current global admin, including after revocation", async t => { + const f = await fixture(t); + assert.equal((await f.app.inject({ url: "/admin/updates/status" })).statusCode, 202); + f.revoke(); + for (const [method, url] of [["GET", "/admin/updates/status"], ["GET", `/admin/updates/requests/${id}`], + ["POST", "/admin/updates/check"], ["POST", "/admin/updates/apply"]] as const) { + assert.equal((await f.app.inject({ method, url, headers, payload: method === "POST" ? { requestId: id } : undefined })).statusCode, 403); + } + assert.equal(f.calls.length, 1); +}); +test("unauthenticated and cookie-only requests cannot start jobs", async t => { + const anonymous = await fixture(t, true, false); + assert.equal((await anonymous.app.inject({ url: "/admin/updates/status" })).statusCode, 401); + const f = await fixture(t); + for (const authorization of [undefined, "Bearer short", "bearer long-but-not-used-by-auth"]) { + assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/check", + headers: authorization ? { authorization } : {}, payload: { requestId: id } })).statusCode, 403); + } + assert.equal(f.calls.length, 0); +}); +test("fixed routes reject injected options, malformed IDs and unreviewed apply requests", async t => { + const f = await fixture(t); + for (const payload of [{ requestId: id, directory: "/root" }, { requestId: id, version: "latest" }, + { requestId: "../../etc/passwd" }, { requestId: id, acceptComposeReplacement: true }]) { + assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/check", headers, payload })).statusCode, 400); + } + assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/apply", headers, + payload: { requestId: id, version: "3.2.0" } })).statusCode, 400); + assert.equal(f.calls.length, 0); + const response = await f.app.inject({ method: "POST", url: "/admin/updates/apply", headers, + payload: { requestId: id, version: "3.2.0", checkRequestId: "checked-request" } }); + assert.equal(response.statusCode, 202); + assert.equal(response.headers["cache-control"], "no-store"); + assert.deepEqual(f.calls[0], ["POST", "/v1/apply", { requestId: id, version: "3.2.0", checkRequestId: "checked-request" }]); +}); + +test("real authentication requires a valid header JWT even when a valid cookie exists", async t => { + const app = Fastify(); + t.after(() => app.close()); + const previous = secrets.JWT_SECRET; + secrets.JWT_SECRET = "update-route-test-secret"; + t.after(() => { secrets.JWT_SECRET = previous; }); + const token = jwt.sign({ user_id: id, tenant_id: null, is_admin: true }, secrets.JWT_SECRET, { expiresIn: "5m" }); + app.addHook("onRequest", async req => { req.cookies = { token }; }); + app.decorate("db", { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, is_admin: true }] }) }) }) }); + await app.register(authPlugin); + let forwarded = 0; + await app.register(routes, { client: async () => { forwarded++; return { status: 202, body: { status: "queued" } }; } }); + for (const authorization of ["Bearer x", "bearer ignored-token-with-valid-cookie", "Bearer invalid-long-jwt-token"]) { + const result = await app.inject({ method: "POST", url: "/admin/updates/check", headers: { authorization }, payload: { requestId: id } }); + assert.ok([401, 403].includes(result.statusCode)); + } + assert.equal(forwarded, 0); + assert.equal((await app.inject({ method: "POST", url: "/admin/updates/check", + headers: { authorization: `Bearer ${token}` }, payload: { requestId: id } })).statusCode, 202); + assert.equal(forwarded, 1); +}); + +async function socketFixture(t: any, handler: Parameters[0]) { + const directory = await mkdtemp(`${tmpdir()}/fedeo-api-`); + const socket = `${directory}/control.sock`; + const server = createServer(handler); + await new Promise(resolve => server.listen(socket, resolve)); + t.after(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); await rm(directory, { recursive: true, force: true }); }); + return createUpdaterClient(socket, 150); +} +test("Unix client strips private fields and never forwards credentials or raw error messages", async t => { + const client = await socketFixture(t, (req, res) => { + assert.equal(req.headers.authorization, undefined); + assert.equal(req.headers.cookie, undefined); + if (req.url === "/v1/status") res.end(JSON.stringify({ apiVersion: 1, acceptingRequests: true, + engine: { status: "idle", job: "/secret/path", error: "password" }, installed: {}, secret: "token" })); + else { res.writeHead(409); res.end(JSON.stringify({ error: { code: "PLAN_CHANGED", message: "password" } })); } + }); + const response = await client("GET", "/v1/status"); + assert.deepEqual(response.body, { apiVersion: 1, acceptingRequests: true, engine: { status: "idle" }, installed: {} }); + await assert.rejects(client("POST", "/v1/apply", {}), { code: "PLAN_CHANGED", status: 409, + message: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen." }); + await assert.rejects(client("GET", "http://example.com"), { code: "INVALID_REQUEST" }); +}); +test("Unix client bounds response size, deadline, and protocol shape", async t => { + for (const scenario of ["oversize", "timeout", "invalid"] as const) { + await t.test(scenario, async t => { + const client = await socketFixture(t, (_req, res) => { + if (scenario === "oversize") res.end("x".repeat(70000)); + else if (scenario === "invalid") res.end('{"apiVersion":2}'); + }); + await assert.rejects(client("GET", "/v1/status"), (error: any) => + ["UPDATER_UNAVAILABLE", "UPDATER_PROTOCOL"].includes(error.code) && !error.message.includes("ENOENT")); + }); + } +}); diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 30121951..cdfe8f62 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -86,6 +86,9 @@ services: image: git.federspiel.tech/flfeders/fedeo/backend:${FEDEO_VERSION:-dev} container_name: fedeo-backend restart: unless-stopped + volumes: + # Mount the directory so a service restart can replace the Unix socket. + - /run/fedeo-updater:/run/fedeo-updater:ro depends_on: db: condition: service_healthy diff --git a/docs/selfhost-updates.md b/docs/selfhost-updates.md index d9673461..c6d41e1e 100644 --- a/docs/selfhost-updates.md +++ b/docs/selfhost-updates.md @@ -1,6 +1,6 @@ # Vollständige Selfhost-Updates -`scripts/selfhost-update.py` ist der gemeinsame Update-Ablauf für die Kommandozeile und den optionalen Host-Dienst `fedeo-updater.service`. Beide laufen auf dem Docker-Host außerhalb der FEDEO-Container und verwenden dieselbe exklusive Sperre. Der Dienst bietet bereits eine lokale Schnittstelle; Backend-Anbindung und Update-Button im Frontend folgen separat. +`scripts/selfhost-update.py` ist der gemeinsame Update-Ablauf für die Kommandozeile und den optionalen Host-Dienst `fedeo-updater.service`. Beide laufen auf dem Docker-Host außerhalb der FEDEO-Container und verwenden dieselbe exklusive Sperre. Das Backend verbindet diese lokale Schnittstelle mit dem Update-Bereich unter Administration → Systemstatus. ## Voraussetzungen und Umfang @@ -75,10 +75,34 @@ Der Installer legt die Python-Module root-eigen unter `/usr/local/lib/fedeo-upda bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo --print-unit ``` -Der Dienst läuft wegen Docker und der Backups als root. Seine HTTP-Schnittstelle lauscht ausschließlich auf `/run/fedeo-updater/control.sock`, nicht auf einem TCP-Port. Verzeichnis und Socket gehören `root:fedeo-updater` mit `0750` bzw. `0660`. Zugriff auf diese Gruppe erlaubt instanzweite Updates; nur vertrauenswürdigen Host-Prozessen Zugriff geben. Die spätere Backend-Anbindung muss Systemadministratoren prüfen und darf nur diese kontrollierte Schnittstelle verwenden. Es wird kein Docker-Socket in Backend oder Frontend eingebunden. Dieser Stand richtet noch keine Backend-Route und keinen Frontend-Button ein. +Der Dienst läuft wegen Docker und der Backups als root. Seine HTTP-Schnittstelle lauscht ausschließlich auf `/run/fedeo-updater/control.sock`, nicht auf einem TCP-Port. Verzeichnis und Socket gehören `root:fedeo-updater` mit `0750` bzw. `0660`. Zugriff auf diese Gruppe erlaubt instanzweite Updates; nur vertrauenswürdigen Host-Prozessen Zugriff geben. Die Selfhost-Compose-Datei bindet `/run/fedeo-updater` schreibgeschützt unter demselben Pfad im Backend ein. Das Verzeichnis statt der einzelnen Socket-Datei bleibt auch bei einem Socket-Austausch durch Dienstneustarts nutzbar. Die Standard-Backend-Container laufen als root; bei einer eigenen unprivilegierten Konfiguration benötigt der Backend-Prozess zusätzlich die Host-GID von `fedeo-updater`. Es wird kein Docker-Socket in Backend oder Frontend eingebunden. Ohne installierten Dienst bleibt die Anwendung nutzbar; der Update-Bereich meldet den Dienst als nicht erreichbar. Registry-Anmeldung bei privaten Images als root durchführen. Für private Releases kann `/etc/fedeo/updater.env` root-lesbar (`0600`) `FEDEO_RELEASE_TOKEN=...` enthalten; danach den Dienst neu starten. Dieser Token wird nicht an API-Clients ausgegeben. Die Unit verwendet `/root` als Home-Verzeichnis für die Docker-Anmeldung. Der Dienst ersetzt sich bei einem FEDEO-Update nicht selbst; neue Dienstdateien zusammen bereitstellen und den Dienstinstaller erneut ausführen. +## Updates im Frontend und einmaliger Übergang + +Systemadministratoren öffnen **Administration → Systemstatus → FEDEO-Updates**. „Nach Updates suchen“ prüft den neuesten stabilen Release. Die Oberfläche zeigt den laufenden Backend-Stand, die geprüfte Zielversion, den Commit, den geschätzten Backup-Platz und gegebenenfalls stillzulegende Matrix-Dienste. „Update auf … installieren“ öffnet die Bestätigung für die Wartungsunterbrechung aller Mandanten. Erst „Jetzt installieren“ nach Bestätigung startet den Auftrag. + +Der Fortschritt wird automatisch abgefragt. Während des Stack-Neustarts ist die Verbindung vorübergehend unterbrochen; der Host arbeitet weiter. Nach Erfolg lädt „Neue Version laden“ die Oberfläche neu. Auftrags-IDs bleiben im Browser gespeichert und der Dienst liefert auch nach seinem Neustart den letzten Auftrag. Geht die Startantwort verloren, fragt die Oberfläche zuerst dieselbe ID ab. „Auftrag erneut senden“ verwendet dieselbe ID und denselben Inhalt; es wird kein zweites Update gestartet. Nach einer erneuten Anmeldung die Systemstatus-Seite wieder öffnen. Auch ohne Browser-Speicher lässt sich der aktuelle Auftrag über den Dienststatus verfolgen. + +Für eine bestehende Instanz auf `3.1.0` ist noch ein einmaliger Übergang nötig, **nachdem ein Release mit diesem Update-Bereich veröffentlicht wurde**: + +1. Die vier zusammengehörigen Host-Dateien aus dessen geprüften Release-Assets nach `/opt/fedeo/scripts/` übernehmen: `selfhost-update.py`, `selfhost-update-service.py`, `selfhost-update-service-install.sh`, `fedeo-updater.service`. +2. Den Dienstinstaller erneut ausführen, damit auch `/usr/local/lib/fedeo-updater/` den aktuellen Stand erhält. +3. Einmal per CLI auf diesen Release aktualisieren. `X.Y.Z` durch die veröffentlichte Version ersetzen: + + ```bash + sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo + sudo python3 /opt/fedeo/scripts/selfhost-update.py check --version X.Y.Z + sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version X.Y.Z + ``` + +Der aktuelle Updater erlaubt dabei gezielt das zusätzliche, schreibgeschützte Standard-Socket-Verzeichnis am Backend. Änderungen an bestehenden Daten-Mounts sowie abweichende oder schreibbare neue Kontroll-Mounts bleiben gesperrt. Ältere Updater besitzen diese Übergangsregel noch nicht. Ein bereits erfolgreich verwalteter Compose-Stand benötigt keine erneute `--accept-compose-replacement`-Freigabe. Falls der Prüfplan sie verlangt, zunächst die Compose-Differenz auf dem Host prüfen wie oben beschrieben. + +Danach laufen weitere Anwendungs-Releases einschließlich Compose und Migrationen über den Button. Änderungen am Host-Dienst selbst benötigen weiterhin die gemeinsame Aktualisierung der Host-Dateien und den Dienstinstaller. + +Die Backend-Routen `/api/admin/updates/status`, `/api/admin/updates/requests/:id`, `/api/admin/updates/check` und `/api/admin/updates/apply` prüfen bei jeder Anfrage die aktuellen globalen Administratorrechte aus der Datenbank. Mandantenadministratoren haben keinen Zugriff. Startanfragen benötigen zusätzlich den bereits von der Authentifizierung geprüften Bearer-JWT; Cookie-Anmeldung allein kann keine Hostaktion auslösen. Das Backend verwendet ausschließlich den festen Unix-Socket, begrenzt Antwortgröße und Wartezeit und liefert nur erlaubte Status-/Planfelder aus. CLI-Wiederherstellung, Übernahme fremder Compose-Dateien, Shell-Befehle und frei wählbare Download-Adressen bleiben auf dem Host. + ## Lokale Dienst-API Alle Antworten sind JSON mit `Cache-Control: no-store`. Mutationen benötigen `Content-Type: application/json`, höchstens 4096 Byte und eine vom Client erzeugte `requestId` (8–80 Zeichen, Buchstaben/Ziffern/Unterstrich/Bindestrich). Für jede neue Aktion eine neue ID verwenden; bei einem Verbindungsfehler dieselbe ID und denselben Inhalt wiederholen. Das liefert denselben Auftrag statt eines zweiten Updates, auch nach einem Dienstneustart. Eine andere Anfrage mit derselben ID wird mit `409 REQUEST_CONFLICT` abgelehnt. @@ -167,6 +191,8 @@ Bei Rückkehr zum alten Matrix-Stack dessen ursprüngliche Compose-Datei aus dem ```bash npm test npm run commits:check +cd backend && node --import tsx --test tests/selfhostUpdates.test.ts +cd ../frontend && node --test tests/selfhost-updates.test.cjs ``` Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Die Matrix-Tests prüfen Stilllegung, Daten-/Container-Erhalt, Backup vor Änderung der Neustartrichtlinien, Fehler und Unterbrechungen sowie die weitere Ablehnung unbekannter Dienste. Ein vollständiger Test mit realem PostgreSQL-/MinIO-/Matrix-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich. diff --git a/frontend/components/DashboardHome.vue b/frontend/components/DashboardHome.vue new file mode 100644 index 00000000..1baaa508 --- /dev/null +++ b/frontend/components/DashboardHome.vue @@ -0,0 +1,595 @@ + + + + + diff --git a/frontend/components/SelfhostUpdateCard.vue b/frontend/components/SelfhostUpdateCard.vue new file mode 100644 index 00000000..d7c44f06 --- /dev/null +++ b/frontend/components/SelfhostUpdateCard.vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend/composables/useSelfhostUpdates.ts b/frontend/composables/useSelfhostUpdates.ts new file mode 100644 index 00000000..4619aebf --- /dev/null +++ b/frontend/composables/useSelfhostUpdates.ts @@ -0,0 +1,149 @@ +export type UpdateState = { + status: string; requestId?: string; command?: 'check' | 'apply'; phase?: string; + version?: string; revision?: string; recoveryRequired?: boolean; updatedAt?: string; +} +export type UpdateJob = UpdateState & { + progress?: UpdateState; error?: { code: string; message: string }; + plan?: { version: string; revision: string; previousVersion?: string; updateAvailable: boolean; + requiresComposeAdoption: boolean; estimatedBackupBytes?: number; retiredServices: string[] }; +} +type Overview = { + acceptingRequests: boolean; engine: UpdateState; request?: UpdateJob; + installed: { version?: string }; backend: { version: string; revision: string }; +} +type Submission = { command: 'check' | 'apply'; body: { requestId: string; version?: string; checkRequestId?: string } } + +export const useSelfhostUpdates = () => { + const { $api } = useNuxtApp() + const auth = useAuthStore() + const overview = ref(null) + const job = ref(null) + const pending = ref(null) + const connected = ref(false) + const submitting = ref(false) + const refreshing = ref(false) + const message = ref('') + const accessDenied = ref(false) + let stopped = false + let generation = 0 + let timer: ReturnType | undefined + const storageKey = `fedeo:update:${auth.user?.user_id || auth.user?.id || auth.user?.email}` + const active = (value?: UpdateState | null) => ['queued', 'running'].includes(value?.status || '') + const recovery = computed(() => !!overview.value?.engine.recoveryRequired && !active(overview.value?.engine)) + const busy = computed(() => submitting.value || !!pending.value || active(job.value) || active(overview.value?.engine)) + const available = computed(() => connected.value && !accessDenied.value && overview.value?.acceptingRequests && !busy.value && !recovery.value) + const canApply = computed(() => !!(available.value && job.value?.command === 'check' && job.value.status === 'checked' + && job.value.plan?.updateAvailable && !job.value.plan.requiresComposeAdoption)) + + const persist = () => { + // Losing localStorage must not lose the durable host job; status also returns the latest request. + try { localStorage.setItem(storageKey, JSON.stringify({ requestId: job.value?.requestId, pending: pending.value })) } catch {} + } + const api = (path: string, options = {}) => $api(`/api/admin/updates/${path}`, { + retry: 0, timeout: 8000, onRequestError: () => {}, onResponseError: () => {}, ...options, + }) + const errorStatus = (error: any) => error?.statusCode || error?.response?.status + const report = (error: any) => { + if ([401, 403].includes(errorStatus(error))) { + accessDenied.value = true + message.value = 'Bitte erneut als Systemadministrator anmelden.' + } else if (pending.value) { + message.value = 'Der Start ist noch nicht bestätigt. Der Auftragsstatus wird weiter geprüft.' + } else if (job.value?.command === 'apply' && active(job.value)) { + message.value = 'Verbindung unterbrochen. Der Host führt das Update weiter aus; die Verbindung wird automatisch erneut geprüft.' + } else { + message.value = error?.data?.error?.message || 'Der Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung auf dem Host prüfen.' + } + } + const refresh = async () => { + if (refreshing.value || stopped || accessDenied.value) return + refreshing.value = true + const currentGeneration = generation + try { + const latest = await api('status') + if (currentGeneration !== generation || stopped) return + overview.value = latest + // A durable submission may have succeeded even when its HTTP response was lost. + const ownId = pending.value?.body.requestId || job.value?.requestId + if (ownId) { + try { + const result = await api(`requests/${encodeURIComponent(ownId)}`) + if (currentGeneration !== generation || stopped) return + job.value = result + pending.value = null + } catch (error) { + if (currentGeneration !== generation || stopped) return + if (errorStatus(error) !== 404) throw error + if (!pending.value) job.value = null + } + } + if (!pending.value && latest.request && (active(latest.request) || !job.value)) job.value = latest.request + connected.value = true + message.value = pending.value ? 'Start noch nicht bestätigt. Du kannst denselben Auftrag erneut senden.' : '' + persist() + } catch (error) { + if (currentGeneration !== generation || stopped) return + connected.value = false + report(error) + } finally { refreshing.value = false } + } + const sendPending = async () => { + if (!pending.value || submitting.value || accessDenied.value) return + generation++ + submitting.value = true + const submission = pending.value + try { + job.value = await api(submission.command, { method: 'POST', body: submission.body }) + pending.value = null + connected.value = true + message.value = '' + } catch (error: any) { + // Only a definite rejection releases the ID. Timeouts/5xx keep it for idempotent recovery. + if ([400, 401, 403, 404, 409, 413, 415].includes(errorStatus(error))) { + pending.value = null + job.value = null + message.value = error?.data?.error?.message || 'Der Auftrag wurde abgelehnt. Bitte erneut prüfen.' + if ([401, 403].includes(errorStatus(error))) report(error) + } else { + connected.value = false + report(error) + } + } finally { submitting.value = false; persist() } + } + const check = async () => { + if (!available.value) return + pending.value = { command: 'check', body: { requestId: crypto.randomUUID() } } + job.value = null + persist() + await sendPending() + } + const apply = async () => { + if (!canApply.value) return + pending.value = { command: 'apply', body: { + requestId: crypto.randomUUID(), version: job.value!.plan!.version, checkRequestId: job.value!.requestId!, + } } + persist() + await sendPending() + } + const poll = async () => { + await refresh() + if (!stopped && !accessDenied.value) timer = setTimeout(poll, busy.value || !connected.value ? 3000 : 15000) + } + onMounted(() => { + try { + const saved = JSON.parse(localStorage.getItem(storageKey) || 'null') + const validId = (value: unknown) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/.test(value) + if (validId(saved?.requestId)) job.value = { status: 'unknown', requestId: saved.requestId } + const p = saved?.pending + if (p && ['check', 'apply'].includes(p.command) && validId(p.body?.requestId) + && (p.command === 'check' || (validId(p.body?.checkRequestId) && /^\d+\.\d+\.\d+$/.test(p.body?.version)))) { + pending.value = { command: p.command, body: { requestId: p.body.requestId, + ...(p.command === 'apply' ? { version: p.body.version, checkRequestId: p.body.checkRequestId } : {}) } } + } + } catch {} + void poll() + }) + onBeforeUnmount(() => { stopped = true; clearTimeout(timer) }) + return { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply, + check, apply, refresh, sendPending } +} diff --git a/frontend/pages/administration/system.vue b/frontend/pages/administration/system.vue index 22a96feb..cfd6e13d 100644 --- a/frontend/pages/administration/system.vue +++ b/frontend/pages/administration/system.vue @@ -96,6 +96,7 @@ onMounted(async () => {
+ import { setPageLayout } from "#app" -import "gridstack/dist/gridstack.min.css" - -const DisplayIncomeAndExpenditure = defineAsyncComponent(() => import("~/components/displayIncomeAndExpenditure.vue")) -const DisplayOpenBalances = defineAsyncComponent(() => import("~/components/displayOpenBalances.vue")) -const DisplayBankaccounts = defineAsyncComponent(() => import("~/components/displayBankaccounts.vue")) -const DisplayProjectsInPhases = defineAsyncComponent(() => import("~/components/displayProjectsInPhases.vue")) -const DisplayOpenTasks = defineAsyncComponent(() => import("~/components/displayOpenTasks.vue")) -const DisplayTaxSummary = defineAsyncComponent(() => import("~/components/displayTaxSummary.vue")) -const DisplayBWASummary = defineAsyncComponent(() => import("~/components/displayBWASummary.vue")) +import DashboardHome from "~/components/DashboardHome.vue" setPageLayout("default") - -const toast = useToast() -const tempStore = useTempStore() - -const gridElement = ref(null) -const grid = shallowRef(null) -const isEditMode = ref(false) -const manageCardsOpen = ref(false) -const widgets = ref([]) - -let gridStackClass = null -let persistTimeout = null -const isSyncingGrid = ref(false) - -const DASHBOARD_WIDGETS = [ - { - id: "income-expense", - title: "Einnahmen und Ausgaben", - description: "Umsatz- und Ausgabenentwicklung", - component: markRaw(DisplayIncomeAndExpenditure), - defaultLayout: { x: 0, y: 0, w: 12, h: 4 }, - minW: 4, - minH: 4 - }, - { - id: "open-balances", - title: "Buchhaltung", - description: "Offene Rechnungen und Entwurfsstatus", - component: markRaw(DisplayOpenBalances), - defaultLayout: { x: 0, y: 4, w: 4, h: 3 }, - minW: 3, - minH: 3 - }, - { - id: "bankaccounts", - title: "Bank", - description: "Kontostand und offene Zuordnungen", - component: markRaw(DisplayBankaccounts), - defaultLayout: { x: 4, y: 4, w: 4, h: 3 }, - minW: 3, - minH: 3 - }, - { - id: "projects", - title: "Projekte", - description: "Aktuelle Projektphasen", - component: markRaw(DisplayProjectsInPhases), - defaultLayout: { x: 8, y: 4, w: 4, h: 3 }, - minW: 3, - minH: 3 - }, - { - id: "tasks", - title: "Aufgaben", - description: "Offene Aufgaben des aktuellen Nutzers", - component: markRaw(DisplayOpenTasks), - defaultLayout: { x: 0, y: 7, w: 4, h: 3 }, - minW: 3, - minH: 3 - }, - { - id: "tax-summary", - title: "USt aktuell", - description: "USt, Vorsteuer und Saldo des aktuellen Zeitraums", - component: markRaw(DisplayTaxSummary), - defaultLayout: { x: 4, y: 7, w: 4, h: 3 }, - minW: 3, - minH: 3 - }, - { - id: "bwa-summary", - title: "BWA aktuell", - description: "Einnahmen, Ausgaben und Ergebnis des aktuellen Monats", - component: markRaw(DisplayBWASummary), - defaultLayout: { x: 8, y: 7, w: 4, h: 3 }, - minW: 3, - minH: 3 - } -] - -const widgetDefinitions = Object.fromEntries(DASHBOARD_WIDGETS.map((widget) => [widget.id, widget])) - -function getDefaultDashboardWidgets() { - return DASHBOARD_WIDGETS.map((definition) => ({ - id: definition.id, - x: definition.defaultLayout.x, - y: definition.defaultLayout.y, - w: definition.defaultLayout.w, - h: definition.defaultLayout.h, - visible: true - })) -} - -function normalizeNumber(value, fallback) { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : fallback -} - -function serializeWidgets(input) { - return input.map((widget) => ({ - id: widget.id, - x: widget.x, - y: widget.y, - w: widget.w, - h: widget.h, - visible: widget.visible - })) -} - -function normalizeDashboardWidgets(storedWidgets) { - const storedById = new Map( - (Array.isArray(storedWidgets) ? storedWidgets : []) - .filter((widget) => widget?.id && widgetDefinitions[widget.id]) - .map((widget) => [widget.id, widget]) - ) - - return DASHBOARD_WIDGETS.map((definition) => { - const stored = storedById.get(definition.id) || {} - - return { - id: definition.id, - x: normalizeNumber(stored.x, definition.defaultLayout.x), - y: normalizeNumber(stored.y, definition.defaultLayout.y), - w: normalizeNumber(stored.w, definition.defaultLayout.w), - h: normalizeNumber(stored.h, definition.defaultLayout.h), - visible: typeof stored.visible === "boolean" ? stored.visible : true - } - }) -} - -function widgetsSignature(input) { - return JSON.stringify(serializeWidgets(input)) -} - -function getWidgetLayout(id) { - return widgets.value.find((widget) => widget.id === id) -} - -function updateWidget(id, patch) { - widgets.value = widgets.value.map((widget) => { - if (widget.id !== id) return widget - return { ...widget, ...patch } - }) -} - -function persistWidgets() { - tempStore.modifySettings("dashboard", { - version: 1, - widgets: serializeWidgets(widgets.value) - }) -} - -function schedulePersistWidgets() { - clearTimeout(persistTimeout) - persistTimeout = setTimeout(() => { - persistWidgets() - }, 120) -} - -async function ensureGridStack() { - if (gridStackClass) return gridStackClass - const gridStackModule = await import("gridstack") - gridStackClass = gridStackModule.GridStack - return gridStackClass -} - -function gridOptionsFor(widget) { - const definition = widgetDefinitions[widget.id] - - return { - x: widget.x, - y: widget.y, - w: widget.w, - h: widget.h, - minW: definition.minW, - minH: definition.minH, - id: widget.id - } -} - -function handleGridChange(_event, items = []) { - if (isSyncingGrid.value || !isEditMode.value) return - - let hasChanges = false - - items.forEach((item) => { - const widgetId = item.el?.dataset?.widgetId || item.id - if (!widgetId) return - - const current = getWidgetLayout(widgetId) - if (!current) return - - const nextPatch = { - x: normalizeNumber(item.x, current.x), - y: normalizeNumber(item.y, current.y), - w: normalizeNumber(item.w, current.w), - h: normalizeNumber(item.h, current.h) - } - - if (current.x === nextPatch.x && current.y === nextPatch.y && current.w === nextPatch.w && current.h === nextPatch.h) { - return - } - - updateWidget(widgetId, nextPatch) - hasChanges = true - }) - - if (hasChanges) schedulePersistWidgets() -} - -function syncGridInteractivity() { - if (!grid.value) return - grid.value.enableMove(isEditMode.value) - grid.value.enableResize(isEditMode.value) -} - -async function syncGridWithDom() { - if (!import.meta.client) return - - await nextTick() - - if (!gridElement.value) return - - const GridStack = await ensureGridStack() - - if (!grid.value) { - isSyncingGrid.value = true - grid.value = GridStack.init( - { - column: 12, - float: true, - margin: 16, - cellHeight: 96, - disableOneColumnMode: false, - handle: ".dashboard-widget-drag-handle", - resizable: { - handles: "all" - } - }, - gridElement.value - ) - grid.value.on("change", handleGridChange) - syncGridInteractivity() - isSyncingGrid.value = false - } - - const visibleIds = new Set(visibleWidgets.value.map((widget) => widget.id)) - - isSyncingGrid.value = true - grid.value.batchUpdate() - - ;[...grid.value.engine.nodes].forEach((node) => { - const widgetId = node.el?.dataset?.widgetId || node.id - if (!node.el || !node.el.isConnected || !visibleIds.has(widgetId)) { - if (node.el) grid.value.removeWidget(node.el, false, false) - } - }) - - const domWidgets = [...gridElement.value.querySelectorAll(".grid-stack-item")] - - domWidgets.forEach((element) => { - const widget = visibleWidgets.value.find((item) => item.id === element.dataset.widgetId) - if (!widget) return - - if (!element.gridstackNode) { - grid.value.makeWidget(element) - } - - grid.value.update(element, gridOptionsFor(widget)) - }) - - grid.value.batchUpdate(false) - isSyncingGrid.value = false -} - -function addWidget(id) { - const widget = getWidgetLayout(id) - if (!widget || widget.visible) return - - updateWidget(id, { visible: true }) - persistWidgets() -} - -function toggleEditMode() { - isEditMode.value = !isEditMode.value - if (!isEditMode.value) manageCardsOpen.value = false -} - -function removeWidget(id) { - if (visibleWidgets.value.length <= 1) { - toast.add({ - title: "Letzte Karte", - description: "Mindestens eine Dashboard-Karte sollte sichtbar bleiben.", - color: "orange" - }) - return - } - - updateWidget(id, { visible: false }) - persistWidgets() -} - -function resetDashboard() { - widgets.value = getDefaultDashboardWidgets() - persistWidgets() - toast.add({ - title: "Dashboard zurückgesetzt", - description: "Das Standardlayout wurde wiederhergestellt.", - color: "primary" - }) -} - -const visibleWidgets = computed(() => - widgets.value - .filter((widget) => widget.visible) - .map((widget) => ({ - ...widgetDefinitions[widget.id], - ...widget - })) -) - -const hiddenWidgets = computed(() => - DASHBOARD_WIDGETS.filter((definition) => !getWidgetLayout(definition.id)?.visible) -) - -watch( - () => tempStore.settings?.dashboard, - (storedDashboard) => { - const nextWidgets = normalizeDashboardWidgets(storedDashboard?.widgets) - if (widgetsSignature(nextWidgets) === widgetsSignature(widgets.value)) return - widgets.value = nextWidgets - }, - { immediate: true, deep: true } -) - -watch( - visibleWidgets, - async () => { - await syncGridWithDom() - }, - { deep: true } -) - -watch(isEditMode, () => { - syncGridInteractivity() -}) - -onMounted(async () => { - if (!widgets.value.length) widgets.value = normalizeDashboardWidgets() - await syncGridWithDom() -}) - -onBeforeUnmount(() => { - clearTimeout(persistTimeout) - if (grid.value) { - grid.value.off("change", handleGridChange) - grid.value.destroy(false) - grid.value = null - } -}) +const auth = useAuthStore() - - diff --git a/frontend/stores/auth.ts b/frontend/stores/auth.ts index 538c096f..2fb797b3 100644 --- a/frontend/stores/auth.ts +++ b/frontend/stores/auth.ts @@ -280,7 +280,6 @@ export const useAuthStore = defineStore("auth", { body: { email, password, rememberMe } }) - console.log("Token: " + token) // Tokens abhängig von „Angemeldet bleiben“ dauerhaft oder nur für diese Sitzung speichern this.setSession(token, refreshToken, rememberMe) @@ -289,7 +288,6 @@ export const useAuthStore = defineStore("auth", { // 2. User Daten laden await this.fetchMe(token) - console.log(this.user) // 3. WICHTIG: Jetzt explizit weiterleiten! // Prüfen, ob der User geladen wurde @@ -380,7 +378,6 @@ export const useAuthStore = defineStore("auth", { }) // ... (Deine Logik für tenants, sorting etc. bleibt gleich) ... - console.log(me) this.user = me.user this.permissions = me.permissions this.tenants = me.tenants @@ -394,6 +391,9 @@ export const useAuthStore = defineStore("auth", { if(this.profile?.temp_config) tempStore.setStoredTempConfig(this.profile.temp_config) + this.activeTenant = null + this.activeTenantData = null + if(me.activeTenant > 0) { const normalizedActiveTenant = String(me.activeTenant) this.activeTenant = normalizedActiveTenant @@ -417,7 +417,6 @@ export const useAuthStore = defineStore("auth", { this.scheduleSessionTimers(tokenToUse) - console.log(this) } catch (err: any) { // WICHTIG: Hier NICHT this.logout() aufrufen, weil das navigiert! @@ -477,7 +476,6 @@ export const useAuthStore = defineStore("auth", { refreshToken: this.getStoredRefreshToken() } }) - console.log(res) const {token, refreshToken} = res this.setSession(token, refreshToken, this.sessionPersistent) diff --git a/frontend/tests/dashboard-session.test.cjs b/frontend/tests/dashboard-session.test.cjs new file mode 100644 index 00000000..5ab8be67 --- /dev/null +++ b/frontend/tests/dashboard-session.test.cjs @@ -0,0 +1,132 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const vm = require('node:vm') +const ts = require('typescript') +const vue = require('vue') +const { parse } = require('@vue/compiler-sfc') +const { compile } = require('@vue/compiler-dom') + +function mountHome(auth) { + const requests = [] + const { descriptor } = parse(fs.readFileSync(__dirname + '/../pages/index.client.vue', 'utf8')) + const context = vm.createContext({ useAuthStore: () => auth, setPageLayout() {} }) + vm.runInContext(descriptor.scriptSetup.content.replace(/^import .*$/gm, ''), context) + const remove = node => { + if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1) + node.parent = null + } + const renderer = vue.createRenderer({ + createElement: type => ({ type, children: [], parent: null }), + createText: text => ({ text, children: [], parent: null }), + createComment: text => ({ text, children: [], parent: null }), + setElementText: (node, text) => { node.text = text }, + setText: (node, text) => { node.text = text }, + parentNode: node => node.parent, + nextSibling: node => node.parent?.children[node.parent.children.indexOf(node) + 1] || null, + insert(node, parent, anchor) { + remove(node) + node.parent = parent + const index = anchor ? parent.children.indexOf(anchor) : -1 + if (index < 0) parent.children.push(node) + else parent.children.splice(index, 0, node) + }, + remove, patchProp() {}, + }) + const component = { + setup: () => vm.runInContext('({ auth })', context), + render: new Function('Vue', compile(descriptor.template.content, { mode: 'function', prefixIdentifiers: true }).code)(vue), + } + const app = renderer.createApp(component) + app.component('DashboardHome', { + setup() { + requests.push(auth.activeTenant) + return () => vue.h('div', 'Dashboard ' + auth.activeTenant) + }, + }) + for (const name of ['UDashboardNavbar', 'UDashboardPanelContent', 'UCard', 'UButton', 'TenantDropdown']) { + app.component(name, { setup: (_, { slots }) => () => vue.h('div', slots.default?.()) }) + } + const root = { children: [] } + app.mount(root) + const content = node => [node.text || '', ...node.children.map(content)].join(' ') + return { requests, close: () => app.unmount(), text: () => content(root) } +} + +test('dashboard data only mounts with a tenant and remounts when the tenant changes', async t => { + const auth = vue.reactive({ activeTenant: null, tenants: [{ id: '1' }, { id: '2' }], user: {} }) + const ui = mountHome(auth) + t.after(ui.close) + assert.deepEqual(ui.requests, []) + assert.match(ui.text(), /Mandant auswählen/) + auth.activeTenant = '1' + await vue.nextTick() + assert.deepEqual(ui.requests, ['1']) + auth.activeTenant = '2' + await vue.nextTick() + assert.deepEqual(ui.requests, ['1', '2']) + auth.activeTenant = null + await vue.nextTick() + assert.deepEqual(ui.requests, ['1', '2']) + assert.match(ui.text(), /Mandant auswählen/) +}) + +test('an account without assigned tenants receives an explanation without loading widgets', t => { + const ui = mountHome(vue.reactive({ activeTenant: null, tenants: [], user: { is_admin: true } })) + t.after(ui.close) + assert.deepEqual(ui.requests, []) + assert.match(ui.text(), /noch kein Mandant zugeordnet/) + assert.match(ui.text(), /Mandanten verwalten/) +}) + +function authFixture() { + const logs = [], calls = [], sessions = [] + const token = 'test-access-token' + const refreshToken = 'test-refresh-token' + let me = { user: { email: 'test@example.com' }, activeTenant: null, tenants: [], permissions: [], profile: null } + const context = vm.createContext({ + exports: {}, process: { client: false }, + defineStore: (_, definition) => definition, + console: { log: (...args) => logs.push(args), error: (...args) => logs.push(args) }, + useTempStore: () => ({ setStoredTempConfig() {} }), + navigateTo() {}, + useNuxtApp: () => ({ $api: async (url, options) => { + calls.push({ url, options }) + if (url === '/api/me') return me + if (url === '/auth/login' || url === '/api/tenant/switch') return { token, refreshToken } + throw Error('Unexpected API request: ' + url) + } }), + }) + const source = fs.readFileSync(__dirname + '/../stores/auth.ts', 'utf8').replace(/^import .*$/gm, '') + vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), context) + const definition = context.exports.useAuthStore + const store = definition.state() + for (const [name, action] of Object.entries(definition.actions)) store[name] = action.bind(store) + store.getStoredToken = () => token + store.getStoredRefreshToken = () => refreshToken + store.scheduleSessionTimers = () => {} + store.setSession = (...args) => sessions.push(args) + return { store, logs, calls, sessions, token, refreshToken, setMe: value => { me = value } } +} + +test('refreshing a session without an active tenant clears the previous tenant state', async () => { + const f = authFixture() + f.store.activeTenant = '12' + f.store.activeTenantData = { id: '12', name: 'Old tenant' } + await f.store.fetchMe() + assert.equal(f.store.activeTenant, null) + assert.equal(f.store.activeTenantData, null) + assert.deepEqual(f.calls.map(call => call.url), ['/api/me']) +}) + +test('login and tenant switching store credentials without writing them to the console', async () => { + const f = authFixture() + await f.store.login('test@example.com', 'test-password') + await f.store.switchTenant('1') + assert.equal(f.sessions.length, 2) + assert.equal(f.sessions[0][0], f.token) + assert.equal(f.sessions[1][1], f.refreshToken) + const output = JSON.stringify(f.logs) + assert.ok(!output.includes(f.token)) + assert.ok(!output.includes(f.refreshToken)) +}) diff --git a/frontend/tests/selfhost-updates.test.cjs b/frontend/tests/selfhost-updates.test.cjs new file mode 100644 index 00000000..65252618 --- /dev/null +++ b/frontend/tests/selfhost-updates.test.cjs @@ -0,0 +1,101 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const vm = require('node:vm') +const ts = require('typescript') +const vue = require('vue') + +const check = { status: 'checked', command: 'check', requestId: 'checked-request', plan: { + version: '3.2.0', revision: 'a'.repeat(40), updateAvailable: true, requiresComposeAdoption: false, retiredServices: [], +} } +const overview = (job = check, engine = { status: 'checked' }) => ({ acceptingRequests: true, engine, request: job, installed: {}, backend: { version: '3.1.0' } }) +async function setup(t, api, saved = null) { + const mounted = [], unmounted = [], calls = [], storage = new Map(saved ? [['fedeo:update:admin', saved]] : []) + let nextId = 0 + const context = vm.createContext({ + ref: vue.ref, computed: vue.computed, onMounted: fn => mounted.push(fn), onBeforeUnmount: fn => unmounted.push(fn), + useAuthStore: () => ({ user: { id: 'admin' } }), + useNuxtApp: () => ({ $api: async (path, options) => { calls.push({ path, options }); return api(path, options) } }), + localStorage: { getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value) }, + crypto: { randomUUID: () => `request-id-${++nextId}` }, setTimeout: () => 1, clearTimeout() {}, + }) + const source = fs.readFileSync(__dirname + '/../composables/useSelfhostUpdates.ts', 'utf8').replace(/export /g, '') + vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }) + '\nglobalThis.ui = useSelfhostUpdates()', context) + mounted.forEach(fn => fn()); await new Promise(setImmediate) + t.after(() => unmounted.forEach(fn => fn())) + return { ui: context.ui, calls, storage } +} +test('checked version enables one apply; duplicate click is blocked and polling survives restart', async t => { + let state = overview(), offline = false, finish + const f = await setup(t, async (path, options) => { + if (offline) throw new Error('network') + if (options.method === 'POST') return new Promise(resolve => { finish = resolve }) + return path.endsWith('/status') ? state : state.request + }) + assert.equal(f.ui.canApply.value, true) + const start = f.ui.apply(); await f.ui.apply() + assert.equal(f.calls.filter(c => c.options.method === 'POST').length, 1) + const body = f.calls.at(-1).options.body + assert.equal(body.version, '3.2.0'); assert.equal(body.checkRequestId, 'checked-request') + state = overview({ status: 'running', command: 'apply', requestId: body.requestId }, { status: 'running', recoveryRequired: true }) + finish(state.request); await start + await f.ui.refresh(); assert.equal(f.ui.recovery.value, false) + offline = true; await f.ui.refresh() + assert.match(f.ui.message.value, /Host führt das Update weiter/) + assert.equal(f.ui.canApply.value, false) + offline = false; state = overview({ ...state.request, status: 'succeeded' }, { status: 'succeeded' }) + await f.ui.refresh(); assert.equal(f.ui.job.value.status, 'succeeded') +}) +test('lost start response is recovered after reload without replaying an update', async t => { + const first = await setup(t, async (path, options) => { + if (options.method === 'POST') throw new Error('response lost') + return path.endsWith('/status') ? overview() : check + }) + await first.ui.apply() + const id = first.ui.pending.value.body.requestId + const saved = first.storage.get('fedeo:update:admin') + const running = { status: 'running', command: 'apply', requestId: id } + const second = await setup(t, async path => path.endsWith('/status') ? overview(running) : running, saved) + assert.equal(second.ui.pending.value, null) + assert.equal(second.ui.job.value.requestId, id) + assert.equal(second.calls.some(c => c.options.method === 'POST'), false) +}) +test('unconfirmed submission retains its original ID for explicit retry', async t => { + const saved = JSON.stringify({ pending: { command: 'apply', body: { requestId: 'original-request', version: '3.2.0', checkRequestId: 'checked-request' } } }) + const f = await setup(t, async (path, options) => { + if (options.method === 'POST') return { status: 'queued', command: 'apply', requestId: options.body.requestId } + if (path.endsWith('/status')) return overview() + throw { statusCode: 404 } + }, saved) + assert.equal(f.ui.canApply.value, false) + await f.ui.sendPending() + assert.equal(f.calls.at(-1).options.body.requestId, 'original-request') +}) +test('recovery, up-to-date, unmanaged compose and other active jobs prevent applying', async t => { + for (const state of [overview(check, { status: 'failed', recoveryRequired: true }), + overview({ ...check, status: 'up_to_date' }), + overview({ ...check, plan: { ...check.plan, requiresComposeAdoption: true } }), + overview({ status: 'running', command: 'apply', requestId: 'someone-else' })]) { + const f = await setup(t, async () => state) + assert.equal(f.ui.canApply.value, false) + await f.ui.apply(); assert.equal(f.calls.some(c => c.options.method === 'POST'), false) + } +}) + +test('an older polling response cannot erase a new submission whose response was lost', async t => { + let hold = false, release + const f = await setup(t, async (path, options) => { + if (options.method === 'POST') throw new Error('response lost') + if (path.endsWith('/status')) return overview() + if (hold) return new Promise(resolve => { release = resolve }) + return check + }) + hold = true + const polling = f.ui.refresh() + await new Promise(setImmediate) + await f.ui.apply() + const id = f.ui.pending.value.body.requestId + release(check); await polling + assert.equal(f.ui.pending.value.body.requestId, id) + assert.equal(f.ui.canApply.value, false) +}) diff --git a/scripts/selfhost-update-service.py b/scripts/selfhost-update-service.py index a8bd091d..469c539a 100644 --- a/scripts/selfhost-update-service.py +++ b/scripts/selfhost-update-service.py @@ -109,6 +109,9 @@ class Controller: if record.get("status") in ("queued", "running"): record.update(status="interrupted", errorCode="INTERRUPTED", updatedAt=engine.now()) engine.write_json(path, record) + if (REQUEST_ID.fullmatch(str(record.get("requestId", ""))) + and (self.active is None or record.get("startedAt", "") > self.read(self.active).get("startedAt", ""))): + self.active = record["requestId"] updater.status() # Detect an abandoned engine job without touching an active CLI job. except BaseException: self.daemon_lock.close() diff --git a/scripts/selfhost-update.py b/scripts/selfhost-update.py index e019c943..362b1601 100644 --- a/scripts/selfhost-update.py +++ b/scripts/selfhost-update.py @@ -418,7 +418,14 @@ class Updater: for name in required: if old[name].get("profiles") or new[name].get("profiles") or new[name].get("build"): raise UpdateError("Build-Kontexte und Compose-Profile werden nicht unterstützt.") - if old[name].get("volumes", []) != new[name].get("volumes", []): + old_mounts, new_mounts = old[name].get("volumes", []), new[name].get("volumes", []) + # One-time bootstrap of the constrained updater API; never relax data-mount checks. + control_mount = {"type": "bind", "source": "/run/fedeo-updater", "target": "/run/fedeo-updater", + "read_only": True, "bind": {}} + control_variants = (control_mount, {**control_mount, "bind": {"create_host_path": True}}) + if name == "backend" and not any(v.get("target") == "/run/fedeo-updater" for v in old_mounts): + new_mounts = [v for v in new_mounts if v not in control_variants] + if old_mounts != new_mounts: raise UpdateError(f"Datenpfade/Mounts von {name} würden verändert; zuerst manuell migrieren.") if old[name].get("container_name") != new[name].get("container_name"): raise UpdateError(f"Container-Identität von {name} würde verändert.") diff --git a/scripts/update/tests/test_service.py b/scripts/update/tests/test_service.py index a1d1552d..35233c2b 100644 --- a/scripts/update/tests/test_service.py +++ b/scripts/update/tests/test_service.py @@ -130,6 +130,7 @@ class ServiceTests(unittest.TestCase): self.controller.close() self.controller = s.Controller(self.root, updater_factory=self.factory) self.server.controller = self.controller + self.assertEqual(self.request('GET', '/v1/status')[1]['request']['requestId'], request_id) status, _ = self.request('POST', '/v1/check', {'requestId': request_id, 'version': '3.2.0'}) self.assertEqual(status, 200) self.assertEqual(len(self.factory.calls), 1) diff --git a/scripts/update/tests/test_updater.py b/scripts/update/tests/test_updater.py index fd490f27..9dc5d270 100644 --- a/scripts/update/tests/test_updater.py +++ b/scripts/update/tests/test_updater.py @@ -449,6 +449,26 @@ class UpdateTests(unittest.TestCase): self.run_update() self.assert_untouched() + def test_first_update_can_add_only_the_readonly_control_directory(self): + control_line = b' - /run/fedeo-updater:/run/fedeo-updater:ro\n' + self.assertIn(control_line, self.original_compose) + (self.root / 'docker-compose.yml').write_bytes(self.original_compose.replace(control_line, b' []\n')) + result = self.run_update() + self.assertEqual(result['status'], 'succeeded') + self.assertIn(control_line, (self.root / 'docker-compose.yml').read_bytes()) + + def test_control_mount_bootstrap_rejects_write_access_or_another_host_directory(self): + control_line = b' - /run/fedeo-updater:/run/fedeo-updater:ro\n' + (self.root / 'docker-compose.yml').write_bytes(self.original_compose.replace(control_line, b' []\n')) + for replacement in (b' - /run/fedeo-updater:/run/fedeo-updater:rw\n', + b' - /var/run/docker.sock:/run/fedeo-updater:ro\n'): + with self.subTest(replacement=replacement): + self.http.files['docker-compose.selfhost.yml'] = self.original_compose.replace(control_line, replacement) + self.http.refresh() + with self.assertRaisesRegex(u.UpdateError, 'Datenpfade'): + self.run_update() + self.assertFalse(self.called('stop')) + def test_external_storage_fails_before_maintenance(self): (self.root / '.env').write_bytes(self.original_env.replace(b'http://minio:9000', b'https://storage.example.test')) with self.assertRaisesRegex(u.UpdateError, 'Backup-Adapter'):