mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
* Add Helm and GitOps deployment setup * fix: build better-sqlite3 from source in Docker (#1267) * fix: preserve runtime SSL settings (#1268) * fix: support forwarding from the memory SSH agent (#1269) * fix: support forwarding from the memory agent * style: format memory agent test * fix: prompt for encrypted SFTP key passphrases (#1270) * fix: prompt for SFTP key passphrases * style: format SSH key utility test * fix: include host context in automation notifications (#1271) * fix: include host context in automation notifications * style: format automation notification changes * fix: reserve sidebar height for host tags (#1272) * fix: keep host action rows stable at large font sizes (#1273) * fix: honor certificate setting during server probe (#1274) * fix: package standard Linux icon sizes (#1275) * fix: avoid duplicate Docker HTTPS listener (#1276) * Fix host status without metrics collection (#1277) * fix: allow eight-digit secure auth codes (#1263) Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts. Generated with Codebuff 🤖 Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> * Harden Helm deployment defaults * Update Helm workflow action * Exclude Helm templates from Prettier * Fix browser RDP file drops (#1279) * Fix Proxmox guest credential usernames (#1280) * Add WSL local terminal option (#1281) * refactor: split the transfer engine into focused modules (#1282) * refactor: extract SFTP promisify helpers into sftp-promisify module * refactor: extract transfer timing and rate stats into transfer-stats module * refactor: extract transfer error classes and recovery checks into transfer-errors module * refactor: extract host/path utility helpers into transfer-host-utils module * refactor: extract SFTP directory tree helpers into transfer-sftp-dir module * refactor: extract segment copy job builder into transfer-segment-copy module * refactor: extract file scan and sample helpers into transfer-scan module * refactor: move throttled progress helper into transfer-stats module * style: format transfer modules * perf: optimize tmux monitor aggregation (#1283) * fix: reserve credential tag row height (#1284) * feat: edit AI provider model settings (#1285) * fix: clarify click-to-expand host setting (#1286) * fix: allow portable imports on remote databases (#1287) * fix: allow HTTPS to share the configured port (#1288) * fix: resolve synced jump hosts on the server (#1289) * fix: make terminal clipboard shortcuts layout independent (#1290) * fix: use compatible fetch dispatcher for Tailscale (#1291) * fix: add OIDC environment recovery override (#1292) * fix: coalesce rapid mobile terminal input (#1293) * fix: coalesce rapid mobile terminal input * fix: support clean xterm patch installs * fix: resolve synced remote desktop host IDs (#1295) * feat: make the SFTP file manager path bar editable (#1294) Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat: add passkey sign in to the login screen * fix: remove rounded corners from the host list search bar * fix: stop image storage settings text wrapping to one word per line * fix: prevent malformed websocket messages from crashing the server * chore: increment version * fix: remove gaps between host rows in the sidebar list Keep sub-pixel row measurements and stop wiping the size cache on hover. * fix: Failed to connect through jump hosts (#1180) https://github.com/Termix-SSH/Support/issues/1180 * feat: Progress bar for file downloads in the file manager (#1158) https://github.com/Termix-SSH/Support/issues/1158 * feat: Allow setting Silent OIDC Login via ENV var (#1174) https://github.com/Termix-SSH/Support/issues/1174 * feat: `IdentityFile` to limit the number of attempts by agents (#1165) https://github.com/Termix-SSH/Support/issues/1165 * feat: Credentials clone (#1159) https://github.com/Termix-SSH/Support/issues/1159 * chore: update release notes * docs: move helm setup guide to the docs site * fix: type errors in FilteredAgent agent identity handling * fix: remove stale better-sqlite3 prebuilds so the source build is used * fix: actually build better-sqlite3 from source so arm64 docker images work * fix: credential edit pencil in host editor and add clone action to credential list * fix: clear editingHost so the credential pencil actually opens the editor * chore: run format and lint * fix: folder drag and drop upload failing in the file manager * chore: sync Crowdin translations for 2.7.1 --------- Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com> Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com> Co-authored-by: dropafterfree <maxime.bonillo@gmail.com> Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
425 lines
14 KiB
TypeScript
425 lines
14 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 "./utils/auto-ssl-setup.js";
|
|
import { AuthManager } from "./utils/auth-manager.js";
|
|
import { DataCrypto } from "./utils/data-crypto.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",
|
|
totpSecret: null,
|
|
totpEnabled: false,
|
|
totpBackupCodes: null,
|
|
});
|
|
|
|
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) {
|
|
const {
|
|
createCurrentSettingsRepository,
|
|
createCurrentSsoProviderRepository,
|
|
createCurrentUserRepository,
|
|
} = await import("./database/repositories/factory.js");
|
|
const [legacyOidc, providers, users] = await Promise.all([
|
|
createCurrentSettingsRepository().get("oidc_config"),
|
|
createCurrentSsoProviderRepository().listEnabled(),
|
|
createCurrentUserRepository().listAll(),
|
|
]);
|
|
const conflictingProvider = providers.some((provider) =>
|
|
["oidc", "github", "google"].includes(provider.type),
|
|
);
|
|
const conflictingUser = users.some(
|
|
(user) => user.isOidc || user.totpEnabled,
|
|
);
|
|
if (
|
|
legacyOidc ||
|
|
process.env.OIDC_CLIENT_ID ||
|
|
conflictingProvider ||
|
|
conflictingUser
|
|
) {
|
|
throw new Error(
|
|
"Trusted proxy authentication cannot start while OIDC or TOTP is enabled",
|
|
);
|
|
}
|
|
systemLogger.info("Trusted proxy authentication enabled", {
|
|
operation: "trusted_proxy_auth_enabled",
|
|
usernameHeader: trustedProxyAuth.usernameHeader,
|
|
roleHeader: trustedProxyAuth.roleHeader,
|
|
trustedProxyCount: trustedProxyAuth.trustedProxies.length,
|
|
});
|
|
}
|
|
|
|
const { UserKeyManager } = await import("./utils/user-keys.js");
|
|
await UserKeyManager.getInstance().initialize();
|
|
|
|
const { runBootDekMigration } =
|
|
await import("./utils/crypto-migration/dek-migration.js");
|
|
await runBootDekMigration({ cleanupLegacy: true });
|
|
|
|
const { runLegacySharedCredentialCleanup } =
|
|
await import("./utils/crypto-migration/legacy-share-cleanup.js");
|
|
await runLegacySharedCredentialCleanup();
|
|
|
|
const authManager = AuthManager.getInstance();
|
|
await authManager.initialize();
|
|
DataCrypto.initialize();
|
|
|
|
const { runLegacySharedSshAuthOptInMigration } =
|
|
await import("./utils/crypto-migration/legacy-shared-ssh-auth-opt-in-migration.js");
|
|
await runLegacySharedSshAuthOptInMigration();
|
|
|
|
const { runSharedHostSecretsMigration } =
|
|
await import("./utils/crypto-migration/shared-host-secrets-migration.js");
|
|
await runSharedHostSecretsMigration();
|
|
|
|
const { runPrivateSharedSshAuthMigration } =
|
|
await import("./utils/crypto-migration/private-shared-ssh-auth-migration.js");
|
|
await runPrivateSharedSshAuthMigration();
|
|
|
|
const { runChannelConfigEncryptionMigration } =
|
|
await import("./utils/crypto-migration/channel-config-encryption.js");
|
|
await runChannelConfigEncryptionMigration();
|
|
|
|
const { runAutomationsMigration } =
|
|
await import("./utils/crypto-migration/automations-migration.js");
|
|
await runAutomationsMigration();
|
|
|
|
if (process.env.ELECTRON_EMBEDDED === "true") {
|
|
await provisionLocalDesktopUserIfNeeded();
|
|
}
|
|
|
|
import("./utils/opkssh-binary-manager.js").then(
|
|
({ OPKSSHBinaryManager }) => {
|
|
OPKSSHBinaryManager.ensureBinary().catch((error) => {
|
|
const dataDir =
|
|
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
|
|
systemLogger.warn(
|
|
"Failed to initialize OPKSSH binary - OPKSSH authentication will not be available",
|
|
{
|
|
operation: "opkssh_binary_init_failed",
|
|
error: getErrorMessage(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
dataDir,
|
|
},
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
const { serverReady } = await import("./database/database.js");
|
|
await serverReady;
|
|
await import("./hosts/terminal/index.js");
|
|
await import("./hosts/tunnel/index.js");
|
|
await import("./hosts/file-manager/index.js");
|
|
await import("./hosts/metrics/index.js");
|
|
await import("./hosts/docker/index.js");
|
|
await import("./hosts/docker/console.js");
|
|
await import("./hosts/tmux/index.js");
|
|
await import("./hosts/serial.js");
|
|
await import("./services/dashboard.js");
|
|
await import("./services/homepage.js");
|
|
|
|
// 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",
|
|
});
|
|
}
|
|
|
|
// Initialize Guacamole server for RDP/VNC/Telnet support
|
|
const guacEnabled = getCurrentSettingValue("guac_enabled") !== "false";
|
|
|
|
if (process.env.ENABLE_GUACAMOLE !== "false" && guacEnabled) {
|
|
import("./hosts/guacamole/guacamole-server.js")
|
|
.then(() => {
|
|
systemLogger.info("Guacamole server initialized", {
|
|
operation: "guac_init",
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
systemLogger.warn(
|
|
"Failed to initialize Guacamole server (guacd may not be available)",
|
|
{
|
|
operation: "guac_init_skip",
|
|
error: getErrorMessage(error),
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
// After metrics, which the automation triggers and headless polling hook into.
|
|
const { startAutomationScheduler } =
|
|
await import("./automations/scheduler.js");
|
|
startAutomationScheduler();
|
|
|
|
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",
|
|
});
|
|
// 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);
|
|
}
|
|
})();
|