feat(selfhost): start full updates from administration
This commit is contained in:
@@ -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);
|
||||
|
||||
91
backend/src/modules/selfhost-updater.ts
Normal file
91
backend/src/modules/selfhost-updater.ts
Normal file
@@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
38
backend/src/routes/selfhostUpdates.ts
Normal file
38
backend/src/routes/selfhostUpdates.ts
Normal file
@@ -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<typeof createUpdaterClient> }) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
18
backend/src/utils/requireSystemAdmin.ts
Normal file
18
backend/src/utils/requireSystemAdmin.ts
Normal file
@@ -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;
|
||||
}
|
||||
119
backend/tests/selfhostUpdates.test.ts
Normal file
119
backend/tests/selfhostUpdates.test.ts
Normal file
@@ -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<typeof createServer>[0]) {
|
||||
const directory = await mkdtemp(`${tmpdir()}/fedeo-api-`);
|
||||
const socket = `${directory}/control.sock`;
|
||||
const server = createServer(handler);
|
||||
await new Promise<void>(resolve => server.listen(socket, resolve));
|
||||
t.after(async () => { server.closeAllConnections(); await new Promise<void>(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"));
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user