Files
Termix/src/backend/starter.ts
T

379 lines
13 KiB
TypeScript

import { getErrorMessage } from "./utils/error-message.js";
import dotenv from "dotenv";
import { promises as fs, readFileSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { AutoSSLSetup } from "./tls/self-signed.js";
import { ensureDatabaseLayerPreupgradeBackup } from "./utils/database-layer-preupgrade-backup.js";
import { DatabaseSaveTrigger } from "./utils/database-save-trigger.js";
import { SystemCrypto } from "./utils/system-crypto.js";
import {
systemLogger,
versionLogger,
setGlobalLogLevel,
} from "./utils/logger.js";
import { getTrustedProxyAuthConfig } from "./utils/trusted-proxy-auth.js";
/**
* host:port from DATABASE_URL for the startup log. Parsed rather than printed
* so the password the URL also carries never reaches the logs.
*/
function describeDatabaseHost(): string {
const raw = process.env.DATABASE_URL?.trim();
if (!raw) return "unknown";
try {
const { host } = new URL(raw);
return host || "unknown";
} catch {
return "unknown";
}
}
async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { createCurrentUserRepository, createCurrentRoleRepository } =
await import("./database/repositories/factory.js");
const { AuthManager } = await import("./utils/auth-manager.js");
const crypto = await import("crypto");
const userRepository = createCurrentUserRepository();
const existingCount = await userRepository.countAll();
if (existingCount > 0) {
const allUsers = await userRepository.listAll();
for (const user of allUsers) {
try {
await AuthManager.getInstance().registerUser(user.id);
} catch (dekError) {
systemLogger.error(
"Failed to verify/provision data-encryption key for existing user",
dekError,
{ operation: "desktop_dek_healing", userId: user.id },
);
}
}
return;
}
const id = crypto.randomUUID();
const { isFirstUser } = await userRepository.createFirstLocalUser({
id,
username: "local",
passwordHash: "",
isOidc: false,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
});
try {
await createCurrentRoleRepository().assignRoleNameToUser({
userId: id,
roleName: isFirstUser ? "admin" : "user",
grantedBy: id,
});
} catch (roleError) {
systemLogger.error(
"Failed to assign default role to auto-provisioned local user",
roleError,
{ operation: "desktop_auto_provision_role" },
);
}
await AuthManager.getInstance().registerUser(
id,
crypto.randomBytes(32).toString("hex"),
);
systemLogger.success("Auto-provisioned local desktop user", {
operation: "desktop_auto_provision",
userId: id,
});
}
(async () => {
const initStartTime = Date.now();
try {
dotenv.config({ quiet: true });
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
try {
await fs.access(envPath);
const persistentConfig = dotenv.config({ path: envPath, quiet: true });
if (persistentConfig.parsed) {
Object.assign(process.env, persistentConfig.parsed);
}
} catch {
// expected - env file may not exist
}
systemLogger.info("Termix backend initialization started", {
operation: "backend_init_start",
nodeEnv: process.env.NODE_ENV || "production",
port: process.env.PORT || 4090,
});
let version = process.env.VERSION || "unknown";
if (version === "unknown") {
const candidates = [
path.join(process.cwd(), "package.json"),
path.join(
path.dirname(fileURLToPath(import.meta.url)),
"../../../package.json",
),
];
for (const packageJsonPath of candidates) {
try {
const packageJson = JSON.parse(
readFileSync(packageJsonPath, "utf-8"),
);
if (packageJson.version) {
version = packageJson.version;
break;
}
} catch {
// try the next location
}
}
}
process.env.VERSION = version;
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
operation: "startup",
version: version,
});
const trustedProxyAuth = getTrustedProxyAuthConfig();
const systemCrypto = SystemCrypto.getInstance();
await systemCrypto.initializeJWTSecret();
await systemCrypto.initializeDatabaseKey();
await systemCrypto.initializeEncryptionKey();
await systemCrypto.initializeInternalAuthToken();
const { needsExplicitPersist, resolveDatabaseDialect } =
await import("./database/db/dialect.js");
const databaseDialect = resolveDatabaseDialect();
// The pre-upgrade backup copies the SQLite file, so there is nothing for it
// to do on a client-server engine. Say so rather than no-op silently:
// backups are the operator's own responsibility there.
if (needsExplicitPersist(databaseDialect)) {
ensureDatabaseLayerPreupgradeBackup({ dataDir, version });
} else {
systemLogger.info(
`Skipping pre-upgrade backup on ${databaseDialect} - back up the database yourself`,
{
operation: "backend_init_db_backup_skipped",
dialect: databaseDialect,
},
);
}
await AutoSSLSetup.initialize();
systemLogger.success("SSL setup completed", {
operation: "backend_init_ssl",
sslEnabled: process.env.ENABLE_SSL === "true",
});
const dbModule = await import("./database/db/index.js");
await dbModule.initializeDatabase();
// Naming the engine makes a misconfiguration obvious: without it, a bad
// DATABASE_DIALECT silently falls back to SQLite and looks like data loss.
systemLogger.success(`Database initialized (${databaseDialect})`, {
operation: "backend_init_db",
dialect: databaseDialect,
// Host only, never the credentials the URL also carries.
...(needsExplicitPersist(databaseDialect)
? {}
: { host: describeDatabaseHost() }),
});
if (trustedProxyAuth.enabled) {
// Enabled external login methods are refused at runtime instead: their
// plugins are not running yet at this point.
const { createCurrentUserAuthRepository, createCurrentUserRepository } =
await import("./database/repositories/factory.js");
const [users, secondFactorUsers] = await Promise.all([
createCurrentUserRepository().listAll(),
createCurrentUserAuthRepository().listUserIdsWithSecondFactors(),
]);
const conflictingUser = users.some(
(user) => user.isOidc || secondFactorUsers.has(user.id),
);
if (process.env.OIDC_CLIENT_ID || conflictingUser) {
throw new Error(
"Trusted proxy authentication cannot start while OIDC or a second factor is enabled",
);
}
systemLogger.info("Trusted proxy authentication enabled", {
operation: "trusted_proxy_auth_enabled",
usernameHeader: trustedProxyAuth.usernameHeader,
roleHeader: trustedProxyAuth.roleHeader,
trustedProxyCount: trustedProxyAuth.trustedProxies.length,
});
}
const { runCoreBootMigrations } = await import("./boot.js");
await runCoreBootMigrations();
const { hostStatusService } =
await import("./hosts/status/host-status-service.js");
hostStatusService.start();
if (process.env.ELECTRON_EMBEDDED === "true") {
await provisionLocalDesktopUserIfNeeded();
}
const { serverReady } = await import("./database/database.js");
await serverReady;
// Before any role is edited: a role may hold a plugin permission whose
// plugin is disabled or gone, and PUT /rbac/roles/:id has to keep
// accepting it.
const { primeKnownPermissions } =
await import("./utils/known-permissions.js");
await primeKnownPermissions();
// Terminal, docker, host-metrics, file-manager and tmux monitoring are
// deliberately absent: the ssh-terminal, docker, host-metrics,
// file-manager and tmux-monitor plugins start their own servers, so
// disabling any of them stops its WS/HTTP server. See
// plugins/ssh-terminal, plugins/docker, plugins/host-metrics,
// plugins/file-manager and plugins/tmux-monitor.
// Every other plugin (AI, Proxmox, Remote Desktop, Fleets, Automations,
// Network Topology, Workspaces, Web Endpoint, Tunnels, Serial, Homepage)
// is absent for the same reason: each one serves its routes under
// /plugin-api/<id>/ (or a WS route under /plugin-ws/<id>/) through ctx on
// activate, so disabling it answers 503 instead of leaving a dead import
// here. The dashboard's own uptime and recent-activity routes are core
// and are mounted on the main server in database.ts.
// Automations' scheduler and tunnel autostart also start from their own
// activate() rather than here.
// Initialize log level from database settings
const { getCurrentSettingValue } =
await import("./database/repositories/factory.js");
const logLevel = getCurrentSettingValue("log_level");
if (logLevel) {
setGlobalLogLevel(logLevel);
systemLogger.info(`Log level set to: ${logLevel}`, {
operation: "log_level_init",
});
}
// Last, so a plugin's activate() sees a fully wired server. A plugin that
// fails to load must not stop the backend, so this never rejects.
try {
const { initializePlugins } = await import("./plugins/index.js");
const loaded = await initializePlugins();
if (loaded.length > 0) {
systemLogger.info(`Loaded ${loaded.length} plugin(s)`, {
operation: "plugin_init",
});
}
const { runPluginDataMigrations } =
await import("./upgrade/boot-migrations.js");
await runPluginDataMigrations();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
error: getErrorMessage(error),
});
}
const { startAnalyticsHeartbeat } = await import("./utils/analytics.js");
startAnalyticsHeartbeat();
systemLogger.success("Termix backend started successfully", {
operation: "backend_init_complete",
port: process.env.PORT || 4090,
ssl: process.env.ENABLE_SSL === "true",
duration: Date.now() - initStartTime,
});
const gracefulShutdown = async (signal: string) => {
systemLogger.info(`Received ${signal}, initiating graceful shutdown...`, {
operation: "shutdown",
});
// Terminate plugin workers before the database goes away, so a plugin
// mid-write cannot outlive it.
try {
const { shutdownPlugins } = await import("./plugins/index.js");
await shutdownPlugins();
} catch {
// Nothing to stop.
}
// Only SQLite has anything to flush. On a client-server engine the writes
// committed as they happened, so there is no file to save and claiming
// otherwise in the log would be untrue.
if (needsExplicitPersist(databaseDialect)) {
try {
await DatabaseSaveTrigger.forceSave("shutdown_explicit_save");
systemLogger.info("Database saved to disk before exit", {
operation: "shutdown_db_saved",
});
} catch (error) {
systemLogger.error("Failed to save database during shutdown", error, {
operation: "shutdown_db_save_failed",
});
}
}
process.exit(0);
};
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("message", (msg: { type?: string }) => {
if (msg?.type === "shutdown") {
gracefulShutdown("IPC shutdown");
}
});
// A single bad request must not take the server down. Exit only on errors
// that leave the process genuinely unusable; log and keep serving
// otherwise, since these are almost always scoped to one connection.
const isFatalError = (error: unknown): boolean => {
const code = (error as NodeJS.ErrnoException)?.code;
if (code === "ERR_WORKER_OUT_OF_MEMORY") return true;
if (error instanceof RangeError) {
return /call stack|heap out of memory/i.test(error.message);
}
return false;
};
process.on("uncaughtException", (error) => {
systemLogger.error("Uncaught exception occurred", error, {
operation: "error_handling",
fatal: isFatalError(error),
});
if (isFatalError(error)) {
process.exit(1);
}
});
process.on("unhandledRejection", (reason) => {
systemLogger.error("Unhandled promise rejection", reason, {
operation: "error_handling",
fatal: isFatalError(reason),
});
if (isFatalError(reason)) {
process.exit(1);
}
});
} catch (error) {
systemLogger.error("Failed to initialize backend services", error, {
operation: "startup_failed",
});
process.exit(1);
}
})();