mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
381 lines
13 KiB
TypeScript
381 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 { 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: "",
|
|
});
|
|
|
|
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();
|
|
|
|
const { backupBeforeUpgrade } = await import("./boot.js");
|
|
await backupBeforeUpgrade({ dataDir, version });
|
|
|
|
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.isExternal || 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();
|
|
|
|
// Status comes from whichever plugin provides it; none means unknown.
|
|
const { hostStatus } = await import("./plugins/host-status-provider.js");
|
|
hostStatus.start();
|
|
|
|
// Counted before the desktop user is provisioned, so a first desktop
|
|
// run counts as a fresh install too.
|
|
const { createCurrentUserRepository } =
|
|
await import("./database/repositories/factory.js");
|
|
const freshInstall = (await createCurrentUserRepository().countAll()) === 0;
|
|
|
|
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();
|
|
// Plugins serve their routes under /plugin-api/<id>/ and /plugin-ws/<id>/
|
|
// through ctx on activate, and start their own schedulers there, so
|
|
// disabling one answers 503 instead of leaving a dead import here. The
|
|
// dashboard's uptime and recent-activity routes are core forwarders and are
|
|
// mounted on the main server in database.ts.
|
|
|
|
// 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({ freshInstall });
|
|
if (loaded.length > 0) {
|
|
systemLogger.info(`Loaded ${loaded.length} plugin(s)`, {
|
|
operation: "plugin_init",
|
|
});
|
|
}
|
|
|
|
const { runPluginDataMigrations } =
|
|
await import("./upgrade/boot-migrations.js");
|
|
await runPluginDataMigrations();
|
|
|
|
const { startPluginAutoUpdate } =
|
|
await import("./plugins/auto-update.js");
|
|
startPluginAutoUpdate();
|
|
} catch (error) {
|
|
systemLogger.warn("Plugin runtime failed to initialize", {
|
|
operation: "plugin_init",
|
|
error: getErrorMessage(error),
|
|
});
|
|
}
|
|
|
|
// Sync itself starts with the plugin that provides it.
|
|
if (process.env.ELECTRON_EMBEDDED === "true") {
|
|
try {
|
|
const { startDesktopUpdateCheck } =
|
|
await import("./updates/desktop-update-check.js");
|
|
startDesktopUpdateCheck();
|
|
} catch (error) {
|
|
systemLogger.warn("Desktop update check failed to start", {
|
|
operation: "update_check_start",
|
|
error: getErrorMessage(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
|
|
// Log output can be filtered by the configured level or split across chunks.
|
|
if (
|
|
process.env.ELECTRON_EMBEDDED === "true" ||
|
|
process.env.TERMIX_DEV_RELOAD === "true"
|
|
) {
|
|
process.send?.({ type: "backend-ready" });
|
|
}
|
|
|
|
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");
|
|
}
|
|
});
|
|
|
|
if (process.env.TERMIX_DEV_RELOAD === "true") {
|
|
const { listenForDevReloads } = await import("./plugins/dev-reload.js");
|
|
listenForDevReloads();
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
})();
|