fix: finish 2.9.0 plugin moves and get every check green

This commit is contained in:
LukeGus committed 2026-09-24 23:35:18 -05:00
1 parent 8c1e02af89
commit 6489b62ece
335 files changed
+29878 -17749

No files matched your search

+7
View File
@@ -232,6 +232,13 @@ export async function runLogin(
const user = await resolveUser(identity, deviceType);
await unlockUser(user, identity, deviceType);
if ("password" in identity && identity.password) {
// A 2.8 key still wrapped by the password only opens here, so this is the
// first chance to move that user's TOTP secret into the plugin.
const { runTotpMigration } =
await import("../utils/crypto-migration/totp-migration.js");
await runTotpMigration(user.id);
}
await syncSharedCredentialsForUserRoles(
user.id,
`${context.methodId}_role_shared_credentials`,
+48
View File
@@ -0,0 +1,48 @@
/**
* The core half of boot that runs once the database is open: user keys, the
* auth singletons and the one-off data migrations core owns. The starter and
* the boot integration tests share it so they cannot drift apart.
*/
import { AuthManager } from "./utils/auth-manager.js";
import { DataCrypto } from "./utils/data-crypto.js";
export async function runCoreBootMigrations(): Promise<void> {
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();
await AuthManager.getInstance().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 { runExternalIdentityMigration } =
await import("./utils/crypto-migration/external-identity-migration.js");
await runExternalIdentityMigration();
const { runHostStatusConfigMigration } =
await import("./utils/crypto-migration/host-status-config-migration.js");
await runHostStatusConfigMigration();
}
+94 -42
View File
@@ -24,7 +24,10 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
import syncRoutes from "./routes/sync.js";
import dashboardRoutes from "./routes/dashboard-routes.js";
import { mountPluginApi } from "./routes/plugin-api-routes.js";
import {
mountPluginApi,
mountPluginLegacyPaths,
} from "./routes/plugin-api-routes.js";
import { attachPluginWebSockets } from "../plugins/ws.js";
import pluginRoutes from "./routes/plugins.js";
import { createPluginAssetsRouter } from "../plugins/assets.js";
@@ -47,11 +50,13 @@ import {
createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
createCurrentHostRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
createCurrentSshCredentialUsageRepository,
createCurrentUserRepository,
} from "./repositories/factory.js";
import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
import { parseUserAgent } from "../utils/user-agent-parser.js";
import { getProxyAgent } from "../utils/proxy-agent.js";
import type {
@@ -815,21 +820,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
autostart_key_password TEXT,
credential_id INTEGER,
override_credential_username INTEGER,
enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT,
jump_hosts TEXT,
enable_file_manager INTEGER NOT NULL DEFAULT 1,
enable_web_ui INTEGER NOT NULL DEFAULT 0,
show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1,
show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0,
show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0,
show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0,
show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0,
default_path TEXT,
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER,
web_ui_config TEXT,
terminal_config TEXT,
quick_actions TEXT,
notes TEXT,
@@ -845,6 +838,13 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE plugin_settings (
plugin_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT
);
CREATE TABLE ssh_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -945,8 +945,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
const sshHosts =
await createCurrentHostRepository().listDecryptedByUserId(userId);
const insertHost = exportDb.prepare(`
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_web_ui, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, status_check_enabled, status_check_interval, web_ui_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const decrypted of sshHosts) {
@@ -973,21 +973,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
decrypted.autostartKeyPassword || null,
decrypted.credentialId || null,
decrypted.overrideCredentialUsername ? 1 : 0,
decrypted.enableTerminal ? 1 : 0,
decrypted.enableTunnel ? 1 : 0,
decrypted.tunnelConnections || null,
decrypted.jumpHosts || null,
decrypted.enableFileManager ? 1 : 0,
decrypted.enableWebUi ? 1 : 0,
decrypted.showTerminalInSidebar ? 1 : 0,
decrypted.showFileManagerInSidebar ? 1 : 0,
decrypted.showTunnelInSidebar ? 1 : 0,
decrypted.showDockerInSidebar ? 1 : 0,
decrypted.showServerStatsInSidebar ? 1 : 0,
decrypted.defaultPath || null,
decrypted.statusCheckEnabled === false ? 0 : 1,
decrypted.statusCheckInterval ?? null,
decrypted.webUiConfig || null,
decrypted.terminalConfig || null,
decrypted.quickActions || null,
decrypted.notes || null,
@@ -1004,6 +992,26 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
// Host-scope plugin settings travel with their hosts. Secrets are sealed
// with this server's key, which the importing server does not have.
const insertPluginSetting = exportDb.prepare(
"INSERT INTO plugin_settings (plugin_id, host_id, key, value) VALUES (?, ?, ?, ?)",
);
const hostPluginRows =
await createCurrentPluginSettingsRepository().getAllForScopeIds(
"host",
sshHosts.map((host) => String(host.id)),
);
for (const row of hostPluginRows) {
if (row.encrypted) continue;
insertPluginSetting.run(
row.pluginId,
Number(row.scopeId),
row.key,
row.value,
);
}
const credentials =
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
const insertCred = exportDb.prepare(`
@@ -1377,21 +1385,7 @@ app.post(
overrideCredentialUsername: Boolean(
host.override_credential_username,
),
enableTerminal: Boolean(host.enable_terminal),
enableTunnel: Boolean(host.enable_tunnel),
tunnelConnections: host.tunnel_connections,
jumpHosts: host.jump_hosts,
enableFileManager: Boolean(host.enable_file_manager),
showTerminalInSidebar: Boolean(host.show_terminal_in_sidebar),
showFileManagerInSidebar: Boolean(
host.show_file_manager_in_sidebar,
),
showTunnelInSidebar: Boolean(host.show_tunnel_in_sidebar),
showDockerInSidebar: Boolean(host.show_docker_in_sidebar),
showServerStatsInSidebar: Boolean(
host.show_server_stats_in_sidebar,
),
defaultPath: host.default_path,
...legacyStatusCheck(host),
terminalConfig: host.terminal_config,
quickActions: host.quick_actions,
@@ -1406,7 +1400,14 @@ app.post(
updatedAt: new Date().toISOString(),
};
await hostRepository.createEncryptedForUser(userId, hostData);
const created = await hostRepository.createEncryptedForUser(
userId,
hostData,
);
await applyPluginHostImportSettings(
Number(created.id),
importedHostPluginSettings(importDb, host),
);
result.summary.sshHostsImported++;
} catch (hostError) {
result.summary.errors.push(
@@ -1834,6 +1835,16 @@ app.use(
createPluginAssetsRouter((id) => getPluginRuntime().loader.get(id)),
);
mountPluginApi(app);
mountPluginLegacyPaths(app, () =>
getPluginRuntime()
.loader.list()
.filter((plugin) => plugin.state === "active")
.map((plugin) => ({
id: plugin.id,
legacyPaths: plugin.manifest.contributes?.http?.legacyPaths ?? [],
}))
.filter((plugin) => plugin.legacyPaths.length > 0),
);
const frontendDistPaths = [
path.join(__dirname, "../../../dist"),
@@ -2193,3 +2204,44 @@ function legacyStatusCheck(row: Record<string, unknown>): {
: null,
};
}
/**
* What an exported host carries for plugins: the plugin_settings rows a 2.9
* export writes, plus the whole row in camelCase so a plugin's import
* normalizer can read the columns a 2.8 export still had.
*/
function importedHostPluginSettings(
importDb: Database.Database,
host: Record<string, unknown>,
): Record<string, unknown> {
const raw: Record<string, unknown> = {};
for (const [column, value] of Object.entries(host)) {
raw[column.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())] = value;
}
const pluginSettings: Record<string, Record<string, unknown>> = {};
try {
const rows = importDb
.prepare(
"SELECT plugin_id, key, value FROM plugin_settings WHERE host_id = ?",
)
.all(host.id) as Array<{
plugin_id: string;
key: string;
value: string | null;
}>;
for (const row of rows) {
let value: unknown = null;
try {
value = row.value === null ? null : JSON.parse(row.value);
} catch {
continue;
}
(pluginSettings[row.plugin_id] ??= {})[row.key] = value;
}
} catch {
// A 2.8 export has no plugin_settings table.
}
raw.pluginSettings = pluginSettings;
return raw;
}
-86
View File
@@ -304,19 +304,12 @@ async function initializeCompleteDatabase(): Promise<void> {
key TEXT,
key_password TEXT,
key_type TEXT,
enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT,
enable_file_manager INTEGER NOT NULL DEFAULT 1,
enable_web_ui INTEGER NOT NULL DEFAULT 0,
default_path TEXT,
autostart_password TEXT,
autostart_key TEXT,
autostart_key_password TEXT,
force_keyboard_interactive TEXT,
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER,
web_ui_config TEXT,
terminal_config TEXT,
notes TEXT,
use_socks5 INTEGER,
@@ -921,39 +914,7 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "key", "TEXT");
addColumnIfNotExists("ssh_data", "key_password", "TEXT");
addColumnIfNotExists("ssh_data", "key_type", "TEXT");
addColumnIfNotExists(
"ssh_data",
"enable_terminal",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists(
"ssh_data",
"enable_session_logging",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists(
"ssh_data",
"enable_command_history",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists(
"ssh_data",
"enable_tunnel",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
addColumnIfNotExists("ssh_data", "jump_hosts", "TEXT");
addColumnIfNotExists(
"ssh_data",
"enable_file_manager",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists(
"ssh_data",
"scp_legacy",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("ssh_data", "default_path", "TEXT");
addColumnIfNotExists(
"ssh_data",
"created_at",
@@ -990,22 +951,6 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "status_check_interval", "INTEGER");
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
addColumnIfNotExists(
"ssh_data",
"enable_web_ui",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("ssh_data", "web_ui_config", "TEXT");
addColumnIfNotExists(
"ssh_data",
"enable_tmux_monitor",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists(
"ssh_data",
"enable_terminal_toolbar",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
addColumnIfNotExists("ssh_data", "domain", "TEXT");
@@ -1025,31 +970,6 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "host_key_last_verified", "TEXT");
addColumnIfNotExists("ssh_data", "host_key_changed_count", "INTEGER DEFAULT 0");
addColumnIfNotExists(
"ssh_data",
"show_terminal_in_sidebar",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists(
"ssh_data",
"show_file_manager_in_sidebar",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists(
"ssh_data",
"show_tunnel_in_sidebar",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists(
"ssh_data",
"show_docker_in_sidebar",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists(
"ssh_data",
"show_server_stats_in_sidebar",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
@@ -1284,11 +1204,6 @@ const migrateSchema = () => {
{ column: "override_credential_username", sql: "ALTER TABLE ssh_data ADD COLUMN override_credential_username INTEGER" },
{ column: "share_ssh_auth", sql: "ALTER TABLE ssh_data ADD COLUMN share_ssh_auth INTEGER NOT NULL DEFAULT 0" },
{ column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" },
{ column: "show_terminal_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1" },
{ column: "show_file_manager_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0" },
{ column: "show_tunnel_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0" },
{ column: "show_docker_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0" },
{ column: "show_server_stats_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0" },
{ column: "quick_actions", sql: "ALTER TABLE ssh_data ADD COLUMN quick_actions TEXT" },
{ column: "domain", sql: "ALTER TABLE ssh_data ADD COLUMN domain TEXT" },
{ column: "socks5_proxy_chain", sql: "ALTER TABLE ssh_data ADD COLUMN socks5_proxy_chain TEXT" },
@@ -1314,7 +1229,6 @@ const migrateSchema = () => {
{ column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" },
{ column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
{ column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" },
{ column: "allow_session_sharing", sql: "ALTER TABLE ssh_data ADD COLUMN allow_session_sharing INTEGER NOT NULL DEFAULT 1" },
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
];
+15 -61
View File
@@ -90,7 +90,7 @@ export const trustedDevices = mysqlTable(
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
expiresAt: text("expires_at").notNull(),
lastUsedAt: text("last_used_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
@@ -160,11 +160,11 @@ export const hosts = mysqlTable(
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
connectionType: text("connection_type").notNull().default("ssh"),
name: varchar("name", { length: 255 }),
name: text("name"),
ip: text("ip").notNull(),
port: int("port").notNull(),
username: text("username").notNull(),
folder: varchar("folder", { length: 255 }),
folder: text("folder"),
// Sub-host nesting: a host acting as an organizational parent for other
// hosts, mutually exclusive with folder (see host route validation).
parentHostId: int("parent_host_id").references(
@@ -194,57 +194,11 @@ export const hosts = mysqlTable(
credentialId: int("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
overrideCredentialUsername: boolean("override_credential_username"),
enableTerminal: boolean("enable_terminal")
.notNull()
.default(true),
enableSessionLogging: boolean("enable_session_logging")
.notNull()
.default(true),
allowSessionSharing: boolean("allow_session_sharing")
.notNull()
.default(true),
enableCommandHistory: boolean("enable_command_history")
.notNull()
.default(true),
enableTunnel: boolean("enable_tunnel")
.notNull()
.default(true),
tunnelConnections: text("tunnel_connections"),
jumpHosts: text("jump_hosts"),
enableFileManager: boolean("enable_file_manager")
.notNull()
.default(true),
scpLegacy: boolean("scp_legacy").notNull().default(false),
enableWebUi: boolean("enable_web_ui")
.notNull()
.default(false),
enableTmuxMonitor: boolean("enable_tmux_monitor")
.notNull()
.default(false),
enableTerminalToolbar: boolean("enable_terminal_toolbar")
.notNull()
.default(true),
showTerminalInSidebar: boolean("show_terminal_in_sidebar")
.notNull()
.default(true),
showFileManagerInSidebar: boolean("show_file_manager_in_sidebar")
.notNull()
.default(false),
showTunnelInSidebar: boolean("show_tunnel_in_sidebar")
.notNull()
.default(false),
showDockerInSidebar: boolean("show_docker_in_sidebar")
.notNull()
.default(false),
showServerStatsInSidebar: boolean("show_server_stats_in_sidebar")
.notNull()
.default(false),
defaultPath: text("default_path"),
statusCheckEnabled: boolean("status_check_enabled")
.notNull()
.default(true),
statusCheckInterval: int("status_check_interval"),
webUiConfig: text("web_ui_config"),
terminalConfig: text("terminal_config"),
quickActions: text("quick_actions"),
notes: text("notes"),
@@ -342,9 +296,9 @@ export const sshCredentials = mysqlTable(
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
name: text("name").notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
folder: text("folder"),
tags: text("tags"),
pin: boolean("pin").notNull().default(false),
// Manual drag-to-reorder position within a folder. Null means the
@@ -407,7 +361,7 @@ export const sshFolders = mysqlTable(
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
credentialId: int("credential_id").references(() => sshCredentials.id, {
@@ -660,11 +614,11 @@ export const apiKeys = mysqlTable(
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
name: text("name").notNull(),
tokenHash: text("token_hash").notNull(),
tokenPrefix: text("token_prefix").notNull(),
createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
expiresAt: varchar("expires_at", { length: 255 }),
expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"),
isActive: boolean("is_active").notNull().default(true),
},
@@ -781,7 +735,7 @@ export const notificationChannels = mysqlTable("notification_channels", {
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
name: text("name").notNull(),
type: text("type").notNull(),
config: text("config").notNull(),
enabled: boolean("enabled").notNull().default(true),
@@ -807,7 +761,7 @@ export const syncTombstones = mysqlTable("sync_tombstones", {
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: varchar("sync_id", { length: 255 }).notNull(),
syncId: text("sync_id").notNull(),
deletedAt: text("deleted_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
@@ -842,7 +796,7 @@ export const credentialAccess = mysqlTable(
permissionLevel: text("permission_level").notNull().default("use"),
expiresAt: varchar("expires_at", { length: 255 }),
expiresAt: text("expires_at"),
createdAt: text("created_at")
.notNull()
@@ -932,7 +886,7 @@ export const folderAccess = mysqlTable(
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("connect"),
expiresAt: varchar("expires_at", { length: 255 }),
expiresAt: text("expires_at"),
createdAt: text("created_at")
.notNull()
@@ -955,7 +909,7 @@ export const plugins = mysqlTable(
"plugins",
{
id: varchar("id", { length: 255 }).primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
name: text("name").notNull(),
version: text("version").notNull(),
tier: text("tier").notNull().default("available"),
source: text("source").notNull().default("community"),
@@ -1012,7 +966,7 @@ export const pluginPermissionGrants = mysqlTable(
export const pluginRegistries = mysqlTable("plugin_registries", {
id: varchar("id", { length: 255 }).primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
name: text("name").notNull(),
url: text("url").notNull(),
kind: text("kind").notNull().default("community"),
enabled: boolean("enabled").notNull().default(true),
@@ -1160,7 +1114,7 @@ export const rbacKnownPermissions = mysqlTable(
id: int("id").autoincrement().primaryKey(),
permission: varchar("permission", { length: 255 }).notNull(),
/** Which plugin contributed it, or null for a core permission. */
pluginId: varchar("plugin_id", { length: 255 }),
pluginId: text("plugin_id"),
firstSeenAt: text("first_seen_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
+15 -61
View File
@@ -91,7 +91,7 @@ export const trustedDevices = pgTable(
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
expiresAt: text("expires_at").notNull(),
lastUsedAt: text("last_used_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -161,11 +161,11 @@ export const hosts = pgTable(
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
connectionType: text("connection_type").notNull().default("ssh"),
name: varchar("name", { length: 255 }),
name: text("name"),
ip: text("ip").notNull(),
port: integer("port").notNull(),
username: text("username").notNull(),
folder: varchar("folder", { length: 255 }),
folder: text("folder"),
// Sub-host nesting: a host acting as an organizational parent for other
// hosts, mutually exclusive with folder (see host route validation).
parentHostId: integer("parent_host_id").references(
@@ -195,57 +195,11 @@ export const hosts = pgTable(
credentialId: integer("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
overrideCredentialUsername: boolean("override_credential_username"),
enableTerminal: boolean("enable_terminal")
.notNull()
.default(true),
enableSessionLogging: boolean("enable_session_logging")
.notNull()
.default(true),
allowSessionSharing: boolean("allow_session_sharing")
.notNull()
.default(true),
enableCommandHistory: boolean("enable_command_history")
.notNull()
.default(true),
enableTunnel: boolean("enable_tunnel")
.notNull()
.default(true),
tunnelConnections: text("tunnel_connections"),
jumpHosts: text("jump_hosts"),
enableFileManager: boolean("enable_file_manager")
.notNull()
.default(true),
scpLegacy: boolean("scp_legacy").notNull().default(false),
enableWebUi: boolean("enable_web_ui")
.notNull()
.default(false),
enableTmuxMonitor: boolean("enable_tmux_monitor")
.notNull()
.default(false),
enableTerminalToolbar: boolean("enable_terminal_toolbar")
.notNull()
.default(true),
showTerminalInSidebar: boolean("show_terminal_in_sidebar")
.notNull()
.default(true),
showFileManagerInSidebar: boolean("show_file_manager_in_sidebar")
.notNull()
.default(false),
showTunnelInSidebar: boolean("show_tunnel_in_sidebar")
.notNull()
.default(false),
showDockerInSidebar: boolean("show_docker_in_sidebar")
.notNull()
.default(false),
showServerStatsInSidebar: boolean("show_server_stats_in_sidebar")
.notNull()
.default(false),
defaultPath: text("default_path"),
statusCheckEnabled: boolean("status_check_enabled")
.notNull()
.default(true),
statusCheckInterval: integer("status_check_interval"),
webUiConfig: text("web_ui_config"),
terminalConfig: text("terminal_config"),
quickActions: text("quick_actions"),
notes: text("notes"),
@@ -343,9 +297,9 @@ export const sshCredentials = pgTable(
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
name: text("name").notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
folder: text("folder"),
tags: text("tags"),
pin: boolean("pin").notNull().default(false),
// Manual drag-to-reorder position within a folder. Null means the
@@ -408,7 +362,7 @@ export const sshFolders = pgTable(
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
credentialId: integer("credential_id").references(() => sshCredentials.id, {
@@ -661,11 +615,11 @@ export const apiKeys = pgTable(
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
name: text("name").notNull(),
tokenHash: text("token_hash").notNull(),
tokenPrefix: text("token_prefix").notNull(),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: varchar("expires_at", { length: 255 }),
expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"),
isActive: boolean("is_active").notNull().default(true),
},
@@ -782,7 +736,7 @@ export const notificationChannels = pgTable("notification_channels", {
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
name: text("name").notNull(),
type: text("type").notNull(),
config: text("config").notNull(),
enabled: boolean("enabled").notNull().default(true),
@@ -808,7 +762,7 @@ export const syncTombstones = pgTable("sync_tombstones", {
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: varchar("sync_id", { length: 255 }).notNull(),
syncId: text("sync_id").notNull(),
deletedAt: text("deleted_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -843,7 +797,7 @@ export const credentialAccess = pgTable(
permissionLevel: text("permission_level").notNull().default("use"),
expiresAt: varchar("expires_at", { length: 255 }),
expiresAt: text("expires_at"),
createdAt: text("created_at")
.notNull()
@@ -933,7 +887,7 @@ export const folderAccess = pgTable(
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("connect"),
expiresAt: varchar("expires_at", { length: 255 }),
expiresAt: text("expires_at"),
createdAt: text("created_at")
.notNull()
@@ -956,7 +910,7 @@ export const plugins = pgTable(
"plugins",
{
id: varchar("id", { length: 255 }).primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
name: text("name").notNull(),
version: text("version").notNull(),
tier: text("tier").notNull().default("available"),
source: text("source").notNull().default("community"),
@@ -1013,7 +967,7 @@ export const pluginPermissionGrants = pgTable(
export const pluginRegistries = pgTable("plugin_registries", {
id: varchar("id", { length: 255 }).primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
name: text("name").notNull(),
url: text("url").notNull(),
kind: text("kind").notNull().default("community"),
enabled: boolean("enabled").notNull().default(true),
@@ -1161,7 +1115,7 @@ export const rbacKnownPermissions = pgTable(
id: serial("id").primaryKey(),
permission: varchar("permission", { length: 255 }).notNull(),
/** Which plugin contributed it, or null for a core permission. */
pluginId: varchar("plugin_id", { length: 255 }),
pluginId: text("plugin_id"),
firstSeenAt: text("first_seen_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
-46
View File
@@ -187,57 +187,11 @@ export const hosts = sqliteTable(
overrideCredentialUsername: integer("override_credential_username", {
mode: "boolean",
}),
enableTerminal: integer("enable_terminal", { mode: "boolean" })
.notNull()
.default(true),
enableSessionLogging: integer("enable_session_logging", { mode: "boolean" })
.notNull()
.default(true),
allowSessionSharing: integer("allow_session_sharing", { mode: "boolean" })
.notNull()
.default(true),
enableCommandHistory: integer("enable_command_history", { mode: "boolean" })
.notNull()
.default(true),
enableTunnel: integer("enable_tunnel", { mode: "boolean" })
.notNull()
.default(true),
tunnelConnections: text("tunnel_connections"),
jumpHosts: text("jump_hosts"),
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
.notNull()
.default(true),
scpLegacy: integer("scp_legacy", { mode: "boolean" }).notNull().default(false),
enableWebUi: integer("enable_web_ui", { mode: "boolean" })
.notNull()
.default(false),
enableTmuxMonitor: integer("enable_tmux_monitor", { mode: "boolean" })
.notNull()
.default(false),
enableTerminalToolbar: integer("enable_terminal_toolbar", { mode: "boolean" })
.notNull()
.default(true),
showTerminalInSidebar: integer("show_terminal_in_sidebar", { mode: "boolean" })
.notNull()
.default(true),
showFileManagerInSidebar: integer("show_file_manager_in_sidebar", { mode: "boolean" })
.notNull()
.default(false),
showTunnelInSidebar: integer("show_tunnel_in_sidebar", { mode: "boolean" })
.notNull()
.default(false),
showDockerInSidebar: integer("show_docker_in_sidebar", { mode: "boolean" })
.notNull()
.default(false),
showServerStatsInSidebar: integer("show_server_stats_in_sidebar", { mode: "boolean" })
.notNull()
.default(false),
defaultPath: text("default_path"),
statusCheckEnabled: integer("status_check_enabled", { mode: "boolean" })
.notNull()
.default(true),
statusCheckInterval: integer("status_check_interval"),
webUiConfig: text("web_ui_config"),
terminalConfig: text("terminal_config"),
quickActions: text("quick_actions"),
notes: text("notes"),
@@ -1,4 +1,4 @@
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
@@ -305,17 +305,6 @@ export class HostResolutionRepository {
return this.decryptManyByOwner("ssh_data", rows);
}
async listHostsWithTunnelConnections(): Promise<HostResolutionHostRecord[]> {
const rows = await this.context.drizzle
.select()
.from(hosts)
.where(
and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)),
);
return this.decryptManyByOwner("ssh_data", rows);
}
async listHostsUsingCredentialForUser(
userId: string,
credentialId: number,
@@ -1160,13 +1160,6 @@ function formatSSHHostOutput(
: [],
pin: !!host.pin,
authType: host.authType,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections as string)
: [],
enableFileManager: host.enableFileManager !== false,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
};
@@ -1,303 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentHostRepository } from "../repositories/factory.js";
type HostAutostartRoutesDeps = {
authenticateJWT: RequestHandler;
requireViewPermission: RequestHandler;
requireEditPermission: RequestHandler;
requireDataAccess: RequestHandler;
};
export function registerHostAutostartRoutes(
router: Router,
{
authenticateJWT,
requireViewPermission,
requireEditPermission,
requireDataAccess,
}: HostAutostartRoutesDeps,
): void {
/**
* @openapi
* /host/autostart/enable:
* post:
* summary: Enable autostart for SSH configuration
* description: Enables autostart for a specific SSH configuration.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sshConfigId:
* type: number
* responses:
* 200:
* description: AutoStart enabled successfully.
* 400:
* description: Valid sshConfigId is required.
* 404:
* description: SSH configuration not found.
* 500:
* description: Internal server error.
*/
router.post(
"/autostart/enable",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
sshLogger.warn(
"Missing or invalid sshConfigId in autostart enable request",
{
operation: "autostart_enable",
userId,
sshConfigId,
},
);
return res.status(400).json({ error: "Valid sshConfigId is required" });
}
try {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
sshLogger.warn(
"User attempted to enable autostart without unlocked data",
{
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "data_locked",
},
);
return res.status(400).json({
error: "Failed to enable autostart. Ensure user data is unlocked.",
});
}
const hostRepository = createCurrentHostRepository();
const config = await hostRepository.findByIdForUser(
userId,
sshConfigId,
);
if (!config) {
sshLogger.warn("SSH config not found for autostart enable", {
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "config_not_found",
});
return res.status(404).json({
error: "SSH configuration not found",
});
}
const decryptedConfig = DataCrypto.decryptRecord(
"ssh_data",
config,
userId,
userDataKey,
);
let updatedTunnelConnections = config.tunnelConnections;
if (config.tunnelConnections) {
try {
const tunnelConnections = JSON.parse(config.tunnelConnections);
const endpointHosts = await hostRepository.listByUserId(userId);
const resolvedConnections = await Promise.all(
tunnelConnections.map(async (tunnel: Record<string, unknown>) => {
if (
tunnel.autoStart &&
tunnel.endpointHost &&
!tunnel.endpointPassword &&
!tunnel.endpointKey
) {
const endpointHost = endpointHosts.find(
(h) =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost,
);
if (endpointHost) {
const decryptedEndpoint = DataCrypto.decryptRecord(
"ssh_data",
endpointHost,
userId,
userDataKey,
);
return {
...tunnel,
endpointPassword: decryptedEndpoint.password || null,
endpointKey: decryptedEndpoint.key || null,
endpointKeyPassword:
decryptedEndpoint.keyPassword || null,
endpointAuthType: endpointHost.authType,
};
}
}
return tunnel;
}),
);
updatedTunnelConnections = JSON.stringify(resolvedConnections);
} catch (error) {
sshLogger.warn("Failed to update tunnel connections", {
operation: "tunnel_connections_update_failed",
error: getErrorMessage(error),
});
}
}
await hostRepository.updateForUser(userId, sshConfigId, {
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
autostartKeyPassword: decryptedConfig.keyPassword || null,
tunnelConnections: updatedTunnelConnections,
});
res.json({
message: "AutoStart enabled successfully",
sshConfigId,
});
} catch (error) {
sshLogger.error("Error enabling autostart", error, {
operation: "autostart_enable_error",
userId,
sshConfigId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
/**
* @openapi
* /host/autostart/disable:
* delete:
* summary: Disable autostart for SSH configuration
* description: Disables autostart for a specific SSH configuration.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sshConfigId:
* type: number
* responses:
* 200:
* description: AutoStart disabled successfully.
* 400:
* description: Valid sshConfigId is required.
* 500:
* description: Internal server error.
*/
router.delete(
"/autostart/disable",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
sshLogger.warn(
"Missing or invalid sshConfigId in autostart disable request",
{
operation: "autostart_disable",
userId,
sshConfigId,
},
);
return res.status(400).json({ error: "Valid sshConfigId is required" });
}
try {
await createCurrentHostRepository().updateForUser(userId, sshConfigId, {
autostartPassword: null,
autostartKey: null,
autostartKeyPassword: null,
});
res.json({
message: "AutoStart disabled successfully",
sshConfigId,
});
} catch (error) {
sshLogger.error("Error disabling autostart", error, {
operation: "autostart_disable_error",
userId,
sshConfigId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
/**
* @openapi
* /host/autostart/status:
* get:
* summary: Get autostart status
* description: Retrieves the autostart status for the user's SSH configurations.
* tags:
* - SSH
* responses:
* 200:
* description: A list of autostart configurations.
* 500:
* description: Internal server error.
*/
router.get(
"/autostart/status",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const autostartConfigs = (
await createCurrentHostRepository().listByUserId(userId)
).filter((config) => config.autostartPassword || config.autostartKey);
const statusList = autostartConfigs.map((config) => ({
sshConfigId: config.id,
host: config.ip,
port: config.port,
username: config.username,
authType: config.authType,
}));
res.json({
autostart_configs: statusList,
total_count: statusList.length,
});
} catch (error) {
sshLogger.error("Error getting autostart status", error, {
operation: "autostart_status_error",
userId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
}
+46 -135
View File
@@ -10,10 +10,16 @@ import {
createCurrentCredentialRepository,
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentPluginSettingsRepository,
} from "../repositories/factory.js";
import { validateParentHostId } from "./host-parent-validation.js";
import { applyPluginHostImportSettings } from "./host-plugin-settings.js";
import {
listSshAuthProviders,
listSshAuthTypeOwners,
} from "../../hosts/connect/auth-provider-registry.js";
import {
applyPluginHostImportSettings,
setHostPluginEnabled,
} from "./host-plugin-settings.js";
import {
isNonEmptyString,
isValidPort,
@@ -182,6 +188,15 @@ export function registerHostBulkRoutes(
* parentHostId:
* type: integer
* nullable: true
* statusCheckEnabled:
* type: boolean
* pin:
* type: boolean
* pluginEnable:
* type: object
* description: Plugin id to on or off, for each plugin that declares a host enable switch.
* additionalProperties:
* type: boolean
* responses:
* 200:
* description: Bulk update completed.
@@ -278,16 +293,6 @@ export function registerHostBulkRoutes(
simpleUpdates.folder = null;
}
}
if (typeof updates.enableTerminal === "boolean")
simpleUpdates.enableTerminal = updates.enableTerminal;
if (typeof updates.enableTunnel === "boolean")
simpleUpdates.enableTunnel = updates.enableTunnel;
if (typeof updates.enableFileManager === "boolean")
simpleUpdates.enableFileManager = updates.enableFileManager;
if (typeof updates.enableTmuxMonitor === "boolean")
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
if (typeof updates.enableTerminalToolbar === "boolean")
simpleUpdates.enableTerminalToolbar = updates.enableTerminalToolbar;
if (typeof updates.statusCheckEnabled === "boolean")
simpleUpdates.statusCheckEnabled = updates.statusCheckEnabled;
@@ -299,91 +304,15 @@ export function registerHostBulkRoutes(
);
}
// Proxmox enable/disable now lives in the proxmox plugin's own
// host-scope settings, not a hosts column. Disabling is a plain flag
// flip; enabling also defaults the Proxmox credential to the one
// already stored on the host, so discovery works without picking one
// by hand. Existing proxmoxConfig values are preserved.
if (typeof updates.enableProxmox === "boolean") {
const pluginSettingsRepository =
createCurrentPluginSettingsRepository();
for (const host of ownedHosts) {
try {
const scopeId = String(host.id);
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"enableProxmox",
JSON.stringify(updates.enableProxmox),
);
if (updates.enableProxmox) {
const existingRow = await pluginSettingsRepository.get(
"proxmox",
"host",
scopeId,
"proxmoxConfig",
);
const existing = existingRow?.value
? JSON.parse(existingRow.value)
: {};
const merged = {
defaultCredentialId:
existing.defaultCredentialId ?? host.credentialId ?? null,
windowsPatterns: existing.windowsPatterns ?? "win, windows",
dockerPatterns: existing.dockerPatterns ?? "docker",
preferredPrefixes:
existing.preferredPrefixes ?? "10., 192.168.",
autoSyncEnabled: existing.autoSyncEnabled ?? false,
syncIntervalMinutes: existing.syncIntervalMinutes ?? 15,
markMissingGuests: existing.markMissingGuests ?? true,
};
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"proxmoxConfig",
JSON.stringify(merged),
);
}
} catch {
errors.push(
`Failed to ${updates.enableProxmox ? "enable" : "disable"} Proxmox for host ${host.id}`,
);
}
}
}
// Web endpoint enable/disable lives in the plugin's own host-scope
// settings. Disabling clears webUiConfig, matching the old column
// write it replaces.
if (typeof updates.enableWebUi === "boolean") {
const pluginSettingsRepository =
createCurrentPluginSettingsRepository();
for (const host of ownedHosts) {
try {
const scopeId = String(host.id);
await pluginSettingsRepository.set(
"web-endpoint",
"host",
scopeId,
"enableWebUi",
JSON.stringify(updates.enableWebUi),
);
if (!updates.enableWebUi) {
await pluginSettingsRepository.set(
"web-endpoint",
"host",
scopeId,
"webUiConfig",
JSON.stringify(null),
);
}
} catch {
errors.push(
`Failed to ${updates.enableWebUi ? "enable" : "disable"} web endpoints for host ${host.id}`,
);
// Each plugin's own host switch, by plugin id: the field its manifest
// names in contributes.settings.host.enableKey.
if (updates.pluginEnable && typeof updates.pluginEnable === "object") {
for (const [pluginId, enabled] of Object.entries(
updates.pluginEnable as Record<string, unknown>,
)) {
if (typeof enabled !== "boolean") continue;
if (!(await setHostPluginEnabled(pluginId, ownedIds, enabled))) {
errors.push(`Plugin ${pluginId} has no host switch`);
}
}
}
@@ -605,6 +534,7 @@ export function registerHostBulkRoutes(
}
}
const knownAuthTypes = listKnownAuthTypes();
for (const { host: hostData, index: i, exportId } of orderedHosts) {
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
@@ -644,20 +574,11 @@ export function registerHostBulkRoutes(
if (
effectiveConnectionType === "ssh" &&
hostData.authType &&
![
"password",
"key",
"credential",
"none",
"opkssh",
"stepca",
"tailscale",
"vault",
].includes(hostData.authType)
!knownAuthTypes.has(hostData.authType)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'stepca', 'tailscale', or 'vault'`,
`Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`,
);
continue;
}
@@ -744,22 +665,7 @@ export function registerHostBulkRoutes(
port: hostData.port,
username,
pin: hostData.pin || false,
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
enableFileManager: hostData.enableFileManager !== false,
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
enableTerminalToolbar: hostData.enableTerminalToolbar !== false,
enableCommandHistory: hostData.enableCommandHistory !== false,
showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0,
showDockerInSidebar: hostData.showDockerInSidebar ? 1 : 0,
showServerStatsInSidebar: hostData.showServerStatsInSidebar ? 1 : 0,
defaultPath: hostData.defaultPath || "/",
sudoPassword: hostData.sudoPassword || null,
tunnelConnections: hostData.tunnelConnections
? JSON.stringify(hostData.tunnelConnections)
: "[]",
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
quickActions: hostData.quickActions
? JSON.stringify(hostData.quickActions)
@@ -1004,19 +910,7 @@ export function registerHostBulkRoutes(
keyType: null,
credentialId: null,
pin: false,
enableTerminal: true,
enableTunnel: true,
enableFileManager: true,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
showTerminalInSidebar: 0,
showFileManagerInSidebar: 0,
showTunnelInSidebar: 0,
showDockerInSidebar: 0,
showServerStatsInSidebar: 0,
defaultPath: "/",
sudoPassword: null,
tunnelConnections: "[]",
jumpHosts: hostData.jumpHosts
? JSON.stringify(hostData.jumpHosts)
: null,
@@ -1098,3 +992,20 @@ function importedStatusCheck(raw: Record<string, unknown>): {
: null,
};
}
const BUILTIN_SSH_AUTH_TYPES = [
"password",
"key",
"credential",
"agent",
"none",
];
/** Core's own types plus every type a plugin registers or declares. */
function listKnownAuthTypes(): Set<string> {
return new Set([
...BUILTIN_SSH_AUTH_TYPES,
...listSshAuthProviders().map((provider) => provider.type),
...listSshAuthTypeOwners().map((owner) => owner.type),
]);
}
@@ -9,7 +9,6 @@ export function applyHostEnrollmentDefaults(
connectionType: "ssh",
port: 22,
authType: "none",
enableTerminal: true,
enableSsh: true,
...hostData,
};
@@ -1,171 +0,0 @@
import type { Request, Response, Router } from "express";
import { SystemCrypto } from "../../utils/system-crypto.js";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
export function registerHostInternalRoutes(router: Router): void {
/**
* @openapi
* /host/db/host/internal:
* get:
* summary: Get internal SSH host data
* description: Returns internal SSH host data for autostart tunnels. Requires internal auth token.
* tags:
* - SSH
* responses:
* 200:
* description: A list of autostart hosts.
* 403:
* description: Forbidden.
* 500:
* description: Failed to fetch autostart SSH data.
*/
router.get("/db/host/internal", async (req: Request, res: Response) => {
try {
const internalToken = req.headers["x-internal-auth-token"];
const systemCrypto = SystemCrypto.getInstance();
const expectedToken = await systemCrypto.getInternalAuthToken();
if (internalToken !== expectedToken) {
sshLogger.warn(
"Unauthorized attempt to access internal SSH host endpoint",
{
source: req.ip,
userAgent: req.headers["user-agent"],
providedToken: internalToken ? "present" : "missing",
},
);
return res.status(403).json({ error: "Forbidden" });
}
} catch (error) {
sshLogger.error("Failed to validate internal auth token", error);
return res.status(500).json({ error: "Internal server error" });
}
try {
const autostartHosts =
await createCurrentHostResolutionRepository().listHostsWithTunnelConnections();
const result = autostartHosts
.map((host) => {
const tunnelConnections = host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [];
const hasAutoStartTunnels = tunnelConnections.some(
(tunnel: Record<string, unknown>) => tunnel.autoStart,
);
if (!hasAutoStartTunnels) {
return null;
}
return {
id: host.id,
userId: host.userId,
name: host.name || `autostart-${host.id}`,
ip: host.ip,
port: host.port,
username: host.username,
authType: host.authType,
keyType: host.keyType,
credentialId: host.credentialId,
enableTunnel: true,
tunnelConnections: tunnelConnections.filter(
(tunnel: Record<string, unknown>) => tunnel.autoStart,
),
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableFileManager: host.enableFileManager !== false,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
showDockerInSidebar: !!host.showDockerInSidebar,
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
tags: ["autostart"],
};
})
.filter(Boolean);
res.json(result);
} catch (err) {
sshLogger.error("Failed to fetch autostart SSH data", err);
res.status(500).json({ error: "Failed to fetch autostart SSH data" });
}
});
/**
* @openapi
* /host/db/host/internal/all:
* get:
* summary: Get all internal SSH host data
* description: Returns all internal SSH host data. Requires internal auth token.
* tags:
* - SSH
* responses:
* 200:
* description: A list of all hosts.
* 401:
* description: Invalid or missing internal authentication token.
* 500:
* description: Failed to fetch all hosts.
*/
router.get("/db/host/internal/all", async (req: Request, res: Response) => {
try {
const internalToken = req.headers["x-internal-auth-token"];
if (!internalToken) {
return res
.status(401)
.json({ error: "Internal authentication token required" });
}
const systemCrypto = SystemCrypto.getInstance();
const expectedToken = await systemCrypto.getInternalAuthToken();
if (internalToken !== expectedToken) {
return res
.status(401)
.json({ error: "Invalid internal authentication token" });
}
const allHosts =
await createCurrentHostResolutionRepository().listAllHosts();
const result = allHosts.map((host) => {
const tunnelConnections = host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [];
return {
id: host.id,
userId: host.userId,
name: host.name || `${host.username}@${host.ip}`,
ip: host.ip,
port: host.port,
username: host.username,
authType: host.authType,
keyType: host.keyType,
credentialId: host.credentialId,
enableTunnel: !!host.enableTunnel,
tunnelConnections: tunnelConnections,
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableFileManager: host.enableFileManager !== false,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
showDockerInSidebar: !!host.showDockerInSidebar,
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
};
});
res.json(result);
} catch (err) {
sshLogger.error("Failed to fetch all hosts for internal use", err);
res.status(500).json({ error: "Failed to fetch all hosts" });
}
});
}
@@ -170,30 +170,11 @@ export type NormalizedImportedHost = Record<string, unknown> & {
credentialId?: number;
credentialAlias?: string;
pin?: unknown;
enableTerminal?: unknown;
enableTunnel?: unknown;
enableFileManager?: unknown;
enableWebUi?: unknown;
enableProxmox?: unknown;
enableTmuxMonitor?: unknown;
enableTerminalToolbar?: unknown;
enableCommandHistory?: unknown;
showTerminalInSidebar?: unknown;
showFileManagerInSidebar?: unknown;
showTunnelInSidebar?: unknown;
showDockerInSidebar?: unknown;
showServerStatsInSidebar?: unknown;
defaultPath?: unknown;
sudoPassword?: unknown;
tunnelConnections?: unknown;
jumpHosts?: unknown;
quickActions?: unknown;
statusCheckEnabled?: unknown;
statusCheckInterval?: unknown;
webUiConfig?: unknown;
proxmoxConfig?: unknown;
enableProxmoxStats?: unknown;
proxmoxStatsConfig?: unknown;
terminalConfig?: unknown;
forceKeyboardInteractive?: unknown;
notes?: unknown;
@@ -333,24 +314,11 @@ const CONNECT_LEVEL_FIELDS = new Set([
"shareSshAuth",
"authOverrides",
"connectionType",
"enableTerminal",
"enableTunnel",
"enableFileManager",
"enableTmuxMonitor",
"enableTerminalToolbar",
"showTerminalInSidebar",
"showFileManagerInSidebar",
"showTunnelInSidebar",
"showDockerInSidebar",
"showServerStatsInSidebar",
"statusCheckEnabled",
"statusCheckInterval",
"enableSsh",
"sshPort",
"rdpAuthType",
"defaultPath",
"scpLegacy",
"tunnelConnections",
"jumpHosts",
"createdAt",
"updatedAt",
@@ -428,25 +396,12 @@ export function transformHostResponse(
: [],
pin: !!host.pin,
shareSshAuth: !!host.shareSshAuth,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
enableFileManager: host.enableFileManager !== false,
enableTmuxMonitor: !!host.enableTmuxMonitor,
enableTerminalToolbar: host.enableTerminalToolbar !== false,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
showDockerInSidebar: !!host.showDockerInSidebar,
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
enableSsh: !!host.enableSsh,
sshPort: host.sshPort ?? host.port ?? 22,
rdpUser: host.rdpUser || undefined,
rdpDomain: host.rdpDomain || undefined,
vncUser: host.vncUser || undefined,
telnetUser: host.telnetUser || undefined,
tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections as string)
: [],
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
quickActions: host.quickActions
? JSON.parse(host.quickActions as string)
@@ -13,13 +13,34 @@
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { createCurrentPluginSettingsRepository } from "../repositories/factory.js";
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
import { declaredFields, resolveFieldValue } from "../../plugins/settings.js";
import {
declaredFields,
resolveFieldValue,
setSetting,
} from "../../plugins/settings.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { consume } from "../../plugins/registry.js";
import { sshLogger } from "../../utils/logger.js";
export type HostPluginSettings = Record<string, Record<string, unknown>>;
/**
* Marks a host changed after one of its plugin settings did, so remote sync,
* which pulls by updated_at, carries the new value across.
*/
export async function touchHost(hostId: number): Promise<void> {
try {
const { sql } = await import("drizzle-orm");
const { runStatement } =
await import("../../utils/crypto-migration/raw-rows.js");
await runStatement(
sql`UPDATE ssh_data SET updated_at = ${new Date().toISOString()} WHERE id = ${hostId}`,
);
} catch {
// Only a sync hint; the setting itself is already saved.
}
}
/** Enabled plugins that declare host-scope settings, with their manifests. */
export function hostSettingsPlugins(): PluginManifest[] {
try {
@@ -105,6 +126,71 @@ export async function loadHostPluginSettings(
return result;
}
const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
/**
* Drops the fields a shared host's recipient may not read: a field declares
* the lowest share level that sees it with shareRead. The owner sees all.
*/
export function visibleHostPluginSettings(
values: HostPluginSettings,
host: Record<string, unknown>,
): HostPluginSettings {
if (!host.isShared) return values;
const level = SHARE_LEVELS.indexOf(
typeof host.permissionLevel === "string" ? host.permissionLevel : "connect",
);
const result: HostPluginSettings = {};
for (const manifest of hostSettingsPlugins()) {
const own = values[manifest.id];
if (!own) continue;
const copy = { ...own };
for (const field of declaredFields(manifest, "host")) {
if (
field.shareRead &&
SHARE_LEVELS.indexOf(field.shareRead) > Math.max(level, 0)
) {
delete copy[field.key];
}
}
result[manifest.id] = copy;
}
return result;
}
/**
* Fields a plugin still puts on the host payload in their pre-2.9 shape, for
* clients outside Termix (Termix-Mobile) that have not moved to
* pluginSettings yet. Registered as ctx.registry.provide(
* "<id>.hostPayloadLegacy", fn); the function gets the plugin's own host
* values and the host, and never overwrites a field core already set.
*/
export type PluginHostPayloadLegacy = (
values: Record<string, unknown>,
host: Record<string, unknown>,
) => Record<string, unknown> | null;
function applyLegacyFields(
host: Record<string, unknown>,
values: HostPluginSettings,
): void {
for (const manifest of hostSettingsPlugins()) {
const own = values[manifest.id];
if (!own) continue;
const legacy = consume<PluginHostPayloadLegacy>(
`${manifest.id}.hostPayloadLegacy`,
);
if (!legacy) continue;
try {
for (const [key, value] of Object.entries(legacy(own, host) ?? {})) {
if (!(key in host)) host[key] = value;
}
} catch {
// A plugin's compat shape must never break a host read.
}
}
}
/** Attaches the map to already-transformed host objects, in place. */
export function attachHostPluginSettings(
hosts: Record<string, unknown>[],
@@ -114,7 +200,9 @@ export function attachHostPluginSettings(
for (const host of hosts) {
const hostId = Number(host.id);
const values = settings.get(hostId);
if (values) host.pluginSettings = values;
if (!values) continue;
host.pluginSettings = visibleHostPluginSettings(values, host);
applyLegacyFields(host, host.pluginSettings as HostPluginSettings);
}
}
@@ -127,7 +215,13 @@ export async function withHostPluginSettings(
const settings = await loadHostPluginSettings([hostId]);
const values = settings.get(hostId);
return values ? { ...host, pluginSettings: values } : host;
if (!values) return host;
const result: Record<string, unknown> = {
...host,
pluginSettings: visibleHostPluginSettings(values, host),
};
applyLegacyFields(result, result.pluginSettings as HostPluginSettings);
return result;
}
/**
@@ -175,13 +269,33 @@ export async function applyPluginHostImportSettings(
hostId: number,
raw: Record<string, unknown>,
): Promise<void> {
const exported =
raw.pluginSettings && typeof raw.pluginSettings === "object"
? (raw.pluginSettings as HostPluginSettings)
: {};
for (const manifest of hostSettingsPlugins()) {
const normalizer = consume<PluginHostImportNormalizer>(
`${manifest.id}.hostImportNormalizer`,
);
if (!normalizer) continue;
try {
const values = normalizer(raw);
// What an export carried for this plugin, validated like any other
// write. Secrets never leave the server, so there are none to restore.
const carried = exported[manifest.id];
if (carried && typeof carried === "object") {
for (const field of declaredFields(manifest, "host")) {
if (field.type === "secret" || !(field.key in carried)) continue;
await setSetting(
manifest,
"host",
hostId,
field.key,
carried[field.key],
);
}
}
const normalizer = consume<PluginHostImportNormalizer>(
`${manifest.id}.hostImportNormalizer`,
);
const values = normalizer?.(raw);
if (values) await writeHostPluginSettings(manifest.id, hostId, values);
} catch (error) {
sshLogger.warn("Plugin host import normalizer failed", {
@@ -193,3 +307,22 @@ export async function applyPluginHostImportSettings(
}
}
}
/**
* Turns one plugin's host switch on or off for a set of hosts. Returns false
* when the plugin is not running or declares no enableKey.
*/
export async function setHostPluginEnabled(
pluginId: string,
hostIds: number[],
enabled: boolean,
): Promise<boolean> {
const manifest = hostSettingsPlugins().find((m) => m.id === pluginId);
const enableKey = manifest?.contributes?.settings?.host?.enableKey;
if (!manifest || !enableKey) return false;
for (const hostId of hostIds) {
await setSetting(manifest, "host", hostId, enableKey, enabled);
await touchHost(hostId);
}
return true;
}
-217
View File
@@ -41,14 +41,11 @@ import {
import {
attachHostPluginSettings,
loadHostPluginSettings,
writeHostPluginSettings,
withHostPluginSettings,
} from "./host-plugin-settings.js";
import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostAuthCompatRoutes } from "./host-compat-routes.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
import { registerHostInternalRoutes } from "./host-internal-routes.js";
import { registerHostNetworkRoutes } from "./host-network-routes.js";
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
import { registerHostStatusRoutes } from "./host-status-routes.js";
@@ -104,8 +101,6 @@ const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
registerHostInternalRoutes(router);
registerHostStatusRoutes(router, {
authenticateJWT,
requireAdmin: authManager.createAdminMiddleware(),
@@ -188,28 +183,10 @@ router.post(
keyType,
sudoPassword,
pin,
enableTerminal,
enableCommandHistory,
enableTunnel,
enableFileManager,
scpLegacy,
enableProxmox,
enableTmuxMonitor,
enableTerminalToolbar,
showTerminalInSidebar,
showFileManagerInSidebar,
showTunnelInSidebar,
showDockerInSidebar,
showServerStatsInSidebar,
defaultPath,
tunnelConnections,
jumpHosts,
quickActions,
statusCheckEnabled,
statusCheckInterval,
proxmoxConfig,
enableProxmoxStats,
proxmoxStatsConfig,
terminalConfig,
forceKeyboardInteractive,
domain,
@@ -306,26 +283,10 @@ router.post(
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
enableCommandHistory: enableCommandHistory ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(tunnelConnections)
? JSON.stringify(tunnelConnections)
: null,
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
quickActions: Array.isArray(quickActions)
? JSON.stringify(quickActions)
: null,
enableFileManager: enableFileManager ? 1 : 0,
scpLegacy: scpLegacy ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
showDockerInSidebar: showDockerInSidebar ? 1 : 0,
showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0,
defaultPath: defaultPath || null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
terminalConfig: terminalConfig
@@ -451,36 +412,6 @@ router.post(
const createdHost = result;
if (
enableProxmox !== undefined ||
proxmoxConfig !== undefined ||
enableProxmoxStats !== undefined ||
proxmoxStatsConfig !== undefined
) {
try {
await writeHostPluginSettings("proxmox", createdHost.id as number, {
enableProxmox: !!enableProxmox,
proxmoxConfig: proxmoxConfig
? typeof proxmoxConfig === "string"
? safeParseJson(proxmoxConfig)
: proxmoxConfig
: null,
enableProxmoxStats: !!enableProxmoxStats,
proxmoxStatsConfig: proxmoxStatsConfig
? typeof proxmoxStatsConfig === "string"
? safeParseJson(proxmoxStatsConfig)
: proxmoxStatsConfig
: null,
});
} catch (proxmoxSettingsError) {
sshLogger.warn("Failed to save Proxmox host settings", {
operation: "host_create_proxmox_settings",
hostId: createdHost.id,
error: getErrorMessage(proxmoxSettingsError),
});
}
}
// Standing folder shares apply to the newcomer.
try {
await applyFolderAccessRules(
@@ -582,12 +513,6 @@ router.post(
* - type: array
* items:
* type: string
* enableTerminal:
* type: boolean
* enableFileManager:
* type: boolean
* enableTunnel:
* type: boolean
* responses:
* 200:
* description: Host enrolled successfully.
@@ -735,19 +660,6 @@ router.post(
key: resolvedKey,
keyPassword: resolvedKeyPassword,
keyType: resolvedKeyType,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
enableWebUi: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
showTerminalInSidebar: true,
showFileManagerInSidebar: false,
showTunnelInSidebar: false,
showDockerInSidebar: false,
showServerStatsInSidebar: false,
defaultPath: "/",
tunnelConnections: [],
jumpHosts: [],
quickActions: [],
statusCheckEnabled: true,
@@ -860,28 +772,10 @@ router.put(
keyType,
sudoPassword,
pin,
enableTerminal,
enableCommandHistory,
enableTunnel,
enableFileManager,
scpLegacy,
enableProxmox,
enableTmuxMonitor,
enableTerminalToolbar,
showTerminalInSidebar,
showFileManagerInSidebar,
showTunnelInSidebar,
showDockerInSidebar,
showServerStatsInSidebar,
defaultPath,
tunnelConnections,
jumpHosts,
quickActions,
statusCheckEnabled,
statusCheckInterval,
proxmoxConfig,
enableProxmoxStats,
proxmoxStatsConfig,
terminalConfig,
forceKeyboardInteractive,
domain,
@@ -979,26 +873,10 @@ router.put(
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
enableCommandHistory: enableCommandHistory ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(tunnelConnections)
? JSON.stringify(tunnelConnections)
: null,
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
quickActions: Array.isArray(quickActions)
? JSON.stringify(quickActions)
: null,
enableFileManager: enableFileManager ? 1 : 0,
scpLegacy: scpLegacy ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
showDockerInSidebar: showDockerInSidebar ? 1 : 0,
showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0,
defaultPath: defaultPath || null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
terminalConfig: terminalConfig
@@ -1300,36 +1178,6 @@ router.put(
sshDataObj,
);
if (
enableProxmox !== undefined ||
proxmoxConfig !== undefined ||
enableProxmoxStats !== undefined ||
proxmoxStatsConfig !== undefined
) {
try {
await writeHostPluginSettings("proxmox", Number(hostId), {
enableProxmox: !!enableProxmox,
proxmoxConfig: proxmoxConfig
? typeof proxmoxConfig === "string"
? safeParseJson(proxmoxConfig)
: proxmoxConfig
: null,
enableProxmoxStats: !!enableProxmoxStats,
proxmoxStatsConfig: proxmoxStatsConfig
? typeof proxmoxStatsConfig === "string"
? safeParseJson(proxmoxStatsConfig)
: proxmoxStatsConfig
: null,
});
} catch (proxmoxSettingsError) {
sshLogger.warn("Failed to save Proxmox host settings", {
operation: "host_update_proxmox_settings",
hostId: parseInt(hostId),
error: getErrorMessage(proxmoxSettingsError),
});
}
}
// A host that moved into a folder inherits that folder's standing shares.
try {
await applyFolderAccessRules(
@@ -1987,16 +1835,6 @@ router.get(
const hostPluginSettings = (
await loadHostPluginSettings([Number(hostId)])
).get(Number(hostId));
const proxmoxSettings = hostPluginSettings?.proxmox as
| {
enableProxmox?: boolean;
proxmoxConfig?: unknown;
enableProxmoxStats?: boolean;
proxmoxStatsConfig?: unknown;
}
| undefined;
const webEndpointSettings = hostPluginSettings?.["web-endpoint"] as
{ enableWebUi?: boolean; webUiConfig?: unknown } | undefined;
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
@@ -2047,35 +1885,13 @@ router.get(
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername:
!!resolvedHost.overrideCredentialUsername,
enableTerminal: !!resolvedHost.enableTerminal,
enableCommandHistory: resolvedHost.enableCommandHistory !== false,
enableTunnel: !!resolvedHost.enableTunnel,
enableFileManager: resolvedHost.enableFileManager !== false,
scpLegacy: !!resolvedHost.scpLegacy,
enableWebUi: !!webEndpointSettings?.enableWebUi,
enableProxmox: !!proxmoxSettings?.enableProxmox,
enableProxmoxStats: !!proxmoxSettings?.enableProxmoxStats,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
enableTerminalToolbar: resolvedHost.enableTerminalToolbar !== false,
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
defaultPath: resolvedHost.defaultPath,
sudoPassword: resolvedHost.sudoPassword || null,
tunnelConnections: resolvedHost.tunnelConnections
? JSON.parse(resolvedHost.tunnelConnections as string)
: [],
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
webUiConfig: webEndpointSettings?.webUiConfig ?? { endpoints: [] },
proxmoxConfig: proxmoxSettings?.proxmoxConfig ?? null,
proxmoxStatsConfig: proxmoxSettings?.proxmoxStatsConfig ?? null,
terminalConfig: resolvedHost.terminalConfig
? JSON.parse(resolvedHost.terminalConfig as string)
: null,
@@ -2162,10 +1978,6 @@ router.get(
? host
: (await resolveHostCredentials(host, userId)) || host;
const hostPluginSettings = pluginSettingsByHost.get(host.id as number);
const proxmoxSettings = hostPluginSettings?.proxmox as
{ enableProxmox?: boolean; proxmoxConfig?: unknown } | undefined;
const webEndpointSettings = hostPluginSettings?.["web-endpoint"] as
{ enableWebUi?: boolean; webUiConfig?: unknown } | undefined;
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
@@ -2204,37 +2016,15 @@ router.get(
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername:
!!resolvedHost.overrideCredentialUsername,
enableTerminal: !!resolvedHost.enableTerminal,
enableCommandHistory: resolvedHost.enableCommandHistory !== false,
enableTunnel: !!resolvedHost.enableTunnel,
enableFileManager: resolvedHost.enableFileManager !== false,
enableWebUi: !!webEndpointSettings?.enableWebUi,
enableProxmox: !!proxmoxSettings?.enableProxmox,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
enableTerminalToolbar:
resolvedHost.enableTerminalToolbar !== false,
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
defaultPath: resolvedHost.defaultPath,
sudoPassword: shareMode
? null
: resolvedHost.sudoPassword || null,
tunnelConnections: resolvedHost.tunnelConnections
? JSON.parse(resolvedHost.tunnelConnections as string)
: [],
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
webUiConfig: webEndpointSettings?.webUiConfig ?? {
endpoints: [],
},
proxmoxConfig: proxmoxSettings?.proxmoxConfig ?? null,
terminalConfig: resolvedHost.terminalConfig
? JSON.parse(resolvedHost.terminalConfig as string)
: null,
@@ -2646,13 +2436,6 @@ registerHostBulkRoutes(
requireDataAccess,
);
registerHostAutostartRoutes(router, {
authenticateJWT,
requireViewPermission: permissionManager.requirePermission("hosts.view"),
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
});
registerHostAuthCompatRoutes(router);
registerHostNetworkRoutes(router, {
@@ -3,8 +3,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import { createCurrentNotificationChannelRepository } from "../repositories/factory.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js";
import { sendDiscord } from "../../utils/discord-sender.js";
import { deliverNotification } from "../../utils/notification-sender.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -224,43 +223,15 @@ router.post(
);
if (!row) return res.status(404).json({ error: "Channel not found" });
const testPayload = {
hostName: "Test Host",
hostId: 0,
triggerType: "test",
message: "This is a test notification from Termix",
severity: "info" as const,
timestamp: new Date().toISOString(),
ruleId: 0,
ruleName: "Test",
};
try {
let config: Record<string, unknown>;
try {
config = JSON.parse(row.config) as Record<string, unknown>;
} catch {
return res
.status(400)
.json({ success: false, error: "Invalid channel config" });
}
if (row.type === "webhook") {
await sendWebhook(
config as unknown as Parameters<typeof sendWebhook>[0],
testPayload,
);
} else if (row.type === "ntfy") {
await sendNtfy(
config as unknown as Parameters<typeof sendNtfy>[0],
testPayload,
);
} else if (row.type === "discord") {
await sendDiscord(
config as unknown as Parameters<typeof sendDiscord>[0],
testPayload,
);
}
// The same path an automation's notify step takes, so a test proves
// the channel works for real, private-network opt-in included.
await deliverNotification(row, {
title: "Termix",
body: "This is a test notification from Termix",
severity: "info",
context: { sourceName: "Test", triggerType: "test" },
});
res.json({ success: true });
} catch (err) {
res.json({
@@ -78,4 +78,32 @@ export function mountPluginApi(app: express.Express): void {
app.use("/plugin-api", router);
}
/**
* Serves the old URLs plugins declare in contributes.http.legacyPaths, each
* under "/<plugin id>/", through that plugin's router with the prefix
* removed. Mounted after every core route, so core wins a clash.
*/
export function mountPluginLegacyPaths(
app: express.Express,
listActive: () => Array<{ id: string; legacyPaths: string[] }>,
): void {
app.use((req: Request, res: Response, next) => {
for (const plugin of listActive()) {
const matched = plugin.legacyPaths.some(
(path) => req.path === path || req.path.startsWith(`${path}/`),
);
if (!matched) continue;
const pluginRouter = getPluginRouter(plugin.id);
if (!pluginRouter) {
res.status(404).json({ error: "Plugin not installed or not running" });
return;
}
req.url = req.url.slice(plugin.id.length + 1) || "/";
pluginRouter(req, res, next);
return;
}
next();
});
}
export default router;
+105 -48
View File
@@ -14,11 +14,13 @@ import {
} from "../repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { describePluginFrontend } from "../../plugins/assets.js";
import { getSetting } from "../../plugins/settings.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import {
findField,
getAllSettings,
setSetting,
validateSettingsSave,
type PluginSettingsScope,
} from "../../plugins/settings.js";
import {
@@ -231,57 +233,59 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
);
}
const plugins = records.map((record) => {
const loaded = live.get(record.id);
const plugins = await Promise.all(
records.map(async (record) => {
const loaded = live.get(record.id);
let contributes: unknown = null;
let capabilities: string[] = [];
let icon: string | undefined;
let dependencies: Record<string, string> = {};
let optionalDependencies: Record<string, string> = {};
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
capabilities?: unknown;
icon?: unknown;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
let contributes: unknown = null;
let capabilities: string[] = [];
let icon: string | undefined;
let dependencies: Record<string, string> = {};
let optionalDependencies: Record<string, string> = {};
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
capabilities?: unknown;
icon?: unknown;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
contributes = await withHostFieldDefaults(manifest as PluginManifest);
capabilities = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
: [];
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
dependencies = manifest?.dependencies ?? {};
optionalDependencies = manifest?.optionalDependencies ?? {};
} catch {
contributes = null;
}
const summary = {
id: record.id,
name: record.name,
version: record.version,
enabled: record.state === "enabled",
state: loaded?.state ?? record.state,
contributes,
icon,
dependencies,
optionalDependencies,
...describePluginFrontend(loaded),
};
contributes = manifest?.contributes ?? null;
capabilities = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
: [];
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
dependencies = manifest?.dependencies ?? {};
optionalDependencies = manifest?.optionalDependencies ?? {};
} catch {
contributes = null;
}
const summary = {
id: record.id,
name: record.name,
version: record.version,
enabled: record.state === "enabled",
state: loaded?.state ?? record.state,
contributes,
icon,
dependencies,
optionalDependencies,
...describePluginFrontend(loaded),
};
if (!canManage) return summary;
if (!canManage) return summary;
return {
...summary,
tier: record.tier,
source: record.source,
capabilities,
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
lastError: loaded?.lastError ?? record.lastError ?? null,
};
});
return {
...summary,
tier: record.tier,
source: record.source,
capabilities,
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
lastError: loaded?.lastError ?? record.lastError ?? null,
};
}),
);
res.json(plugins);
} catch (error) {
@@ -762,10 +766,11 @@ async function applySettings(
scope: PluginSettingsScope,
scopeId: string | null,
body: Record<string, unknown>,
options: { isHostOwner?: boolean } = {},
): Promise<Record<string, string>> {
const errors: Record<string, string> = {};
for (const [key, value] of Object.entries(body)) {
for (const [key] of Object.entries(body)) {
const field = findField(manifest, scope, key);
if (!field) {
errors[key] = "Not a settings field this plugin declares";
@@ -775,7 +780,22 @@ async function applySettings(
errors[key] = "You do not have permission to change this setting";
continue;
}
if (scope === "host" && field.ownerOnly && !options.isHostOwner) {
errors[key] = "Only the host's owner can change this setting";
}
}
if (Object.keys(errors).length > 0) return errors;
// The plugin's own checks see the whole save before anything is written.
const rejected = await validateSettingsSave(
manifest.id,
scope,
scopeId,
body,
);
if (Object.keys(rejected).length > 0) return rejected;
for (const [key, value] of Object.entries(body)) {
const error = await setSetting(manifest, scope, scopeId, key, value);
if (error) errors[key] = error;
}
@@ -1208,11 +1228,14 @@ router.put(
"host",
String(hostId),
req.body,
{ isHostOwner: access.isOwner === true },
);
if (Object.keys(errors).length > 0) {
res.status(400).json({ error: "Some settings were rejected", errors });
return;
}
const { touchHost } = await import("./host-plugin-settings.js");
await touchHost(hostId);
const values = await getAllSettings(manifest, "host", hostId, {
redactSecrets: true,
@@ -1230,3 +1253,37 @@ router.put(
);
export default router;
/**
* A host field with defaultFrom starts new hosts at an admin setting's value,
* so the host editor gets that value as the field's default.
*/
async function withHostFieldDefaults(
manifest: PluginManifest,
): Promise<unknown> {
const contributes = manifest?.contributes;
const host = contributes?.settings?.host;
if (!host?.fields.some((field) => field.defaultFrom)) {
return contributes ?? null;
}
const fields: PluginSettingsField[] = await Promise.all(
host.fields.map(async (field) => {
if (!field.defaultFrom) return field;
try {
const value = await getSetting(
manifest,
"admin",
null,
field.defaultFrom,
);
return value === undefined ? field : { ...field, default: value };
} catch {
return field;
}
}),
);
return {
...contributes,
settings: { ...contributes.settings, host: { ...host, fields } },
};
}
@@ -0,0 +1,84 @@
/**
* Host plugin settings over remote sync. A host row travels with a
* pluginSettings map of its non-secret values; a plugin whose value refers to
* one of its own rows by local id translates it with a registered
* ctx.registry.provide("<id>.hostSettingsSync", { exportValue, importValue }).
*/
import { consume } from "../../plugins/registry.js";
import {
declaredFields,
getAllSettings,
setSetting,
} from "../../plugins/settings.js";
import { databaseLogger } from "../../utils/logger.js";
import {
hostSettingsPlugins,
type HostPluginSettings,
} from "./host-plugin-settings.js";
export interface PluginHostSettingsSync {
exportValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
importValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
}
/** What a host carries to the other side of a sync pair. */
export async function exportHostPluginSettings(
hostId: number,
): Promise<HostPluginSettings> {
const result: HostPluginSettings = {};
for (const manifest of hostSettingsPlugins()) {
const fields = declaredFields(manifest, "host").filter(
(field) => field.type !== "secret",
);
if (fields.length === 0) continue;
const values = await getAllSettings(manifest, "host", hostId, {
redactSecrets: true,
});
const hook = consume<PluginHostSettingsSync>(
`${manifest.id}.hostSettingsSync`,
);
const own: Record<string, unknown> = {};
for (const field of fields) {
const value = values[field.key];
own[field.key] = hook?.exportValue
? await hook.exportValue(field.key, value)
: value;
}
result[manifest.id] = own;
}
return result;
}
/** Writes what a synced host carried, translated back to local ids. */
export async function importHostPluginSettings(
hostId: number,
carried: unknown,
): Promise<void> {
if (!carried || typeof carried !== "object") return;
const byPlugin = carried as HostPluginSettings;
for (const manifest of hostSettingsPlugins()) {
const own = byPlugin[manifest.id];
if (!own || typeof own !== "object") continue;
const hook = consume<PluginHostSettingsSync>(
`${manifest.id}.hostSettingsSync`,
);
for (const field of declaredFields(manifest, "host")) {
if (field.type === "secret" || !(field.key in own)) continue;
try {
const value = hook?.importValue
? await hook.importValue(field.key, own[field.key])
: own[field.key];
await setSetting(manifest, "host", hostId, field.key, value);
} catch (error) {
databaseLogger.warn("Could not apply a synced host plugin setting", {
operation: "sync_host_plugin_settings",
pluginId: manifest.id,
hostId,
key: field.key,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
}
+15
View File
@@ -32,6 +32,10 @@ import {
updateReturning,
} from "../repositories/returning.js";
import { validateParentHostId } from "./host-parent-validation.js";
import {
exportHostPluginSettings,
importHostPluginSettings,
} from "./sync-host-plugin-settings.js";
// Primed at import so every consumer of this module - the routes below, the
// reference resolvers, the Electron entity-types endpoint - sees the same
@@ -302,6 +306,11 @@ router.get(
(referenceType, id) =>
findReferenceSyncId(context, referenceType, id, userId),
);
if (entityType === "hosts") {
result.pluginSettings = await exportHostPluginSettings(
row.id as number,
);
}
return singleton
? { ...result, syncId: `${entityType}:singleton` }
: result;
@@ -461,6 +470,8 @@ router.post(
);
if (parentError) return res.status(400).json({ error: parentError });
}
const carriedPluginSettings = resolvedPayload.pluginSettings;
delete resolvedPayload.pluginSettings;
const writePayload = stripWritePayload(entityType, resolvedPayload);
const encryptedPayload = encryptIfNeeded(
entityType,
@@ -495,6 +506,10 @@ router.post(
resultRow = insertedRows[0] as Record<string, unknown>;
}
if (entityType === "hosts" && typeof resultRow?.id === "number") {
await importHostPluginSettings(resultRow.id, carriedPluginSettings);
}
await DatabaseSaveTrigger.forceSave("sync_upsert");
res.json({
@@ -35,8 +35,6 @@ export type HostDefaults = {
theme?: string;
cursorStyle?: string;
cursorBlink?: boolean;
enableSessionLogging?: boolean;
enableCommandHistory?: boolean;
autoTmux?: boolean;
};
@@ -1,6 +1,6 @@
/**
* The SSH auth types core always provides: password, key, credential, agent
* and none. Everything else is a plugin (or, in 2.9.0, a legacy provider).
* and none. Everything else is a plugin, through ctx.auth.
*/
import ssh2 from "ssh2";
+1 -1
View File
@@ -152,7 +152,7 @@ export interface SshAuthField {
export interface SshAuthProvider {
/** Stored in ssh_data.auth_type. */
type: string;
/** "core" for built-ins and the legacy providers A8 keeps in core. */
/** "core" for the built-ins, otherwise the registering plugin's id. */
pluginId: string;
labelKey: string;
descriptionKey?: string;
@@ -1,541 +0,0 @@
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { fileLogger } from "../../utils/logger.js";
import { execChannel, type SSHSession } from "./session.js";
type FileActionRoutesDeps = {
sshSessions: Record<string, SSHSession>;
scheduleSessionCleanup: (sessionId: string) => void;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
export function registerFileActionRoutes(
app: Express,
{
sshSessions,
scheduleSessionCleanup,
verifySessionOwnership,
}: FileActionRoutesDeps,
): void {
/**
* @openapi
* /ssh/file_manager/ssh/copyItem:
* post:
* summary: Copy a file or directory
* description: Copies a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* sourcePath:
* type: string
* targetDir:
* type: string
* hostId:
* type: integer
* userId:
* type: string
* responses:
* 200:
* description: Item copied successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 500:
* description: Failed to copy item.
*/
app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
const { sessionId, sourcePath, targetDir, hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId || !sourcePath || !targetDir) {
return res.status(400).json({ error: "Missing required parameters" });
}
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
return res
.status(400)
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const sourceName = sourcePath.split("/").pop() || "copied_item";
const timestamp = Date.now().toString().slice(-8);
const uniqueName = `${sourceName}_copy_${timestamp}`;
const targetPath = `${targetDir}/${uniqueName}`;
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
const commandTimeout = setTimeout(() => {
fileLogger.error("Copy command timed out after 60 seconds", {
sourcePath,
targetPath,
command: copyCommand,
});
if (!res.headersSent) {
res.status(500).json({
error: "Copy operation timed out",
toast: {
type: "error",
message:
"Copy operation timed out. SSH connection may be unstable.",
},
});
}
}, 60000);
execChannel(sshConn, copyCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH copyItem error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let errorData = "";
let stdoutData = "";
stream.on("data", (data: Buffer) => {
const output = data.toString();
stdoutData += output;
stream.stderr.on("data", (data: Buffer) => {
const output = data.toString();
errorData += output;
});
stream.on("close", (code) => {
clearTimeout(commandTimeout);
if (code !== 0) {
const fullErrorInfo =
errorData || stdoutData || "No error message available";
fileLogger.error(`SSH copyItem command failed with code ${code}`, {
operation: "file_copy_failed",
sessionId,
sourcePath,
targetPath,
command: copyCommand,
exitCode: code,
errorData,
stdoutData,
fullErrorInfo,
});
if (!res.headersSent) {
return res.status(500).json({
error: `Copy failed: ${fullErrorInfo}`,
toast: {
type: "error",
message: `Copy failed: ${fullErrorInfo}`,
},
debug: {
sourcePath,
targetPath,
exitCode: code,
command: copyCommand,
},
});
}
return;
}
const copySuccessful =
stdoutData.includes("COPY_SUCCESS") || code === 0;
if (copySuccessful) {
fileLogger.success("Item copied successfully", {
operation: "file_copy",
sessionId,
sourcePath,
targetPath,
uniqueName,
hostId,
userId,
});
if (!res.headersSent) {
res.json({
message: "Item copied successfully",
sourcePath,
targetPath,
uniqueName,
toast: {
type: "success",
message: `Successfully copied to: ${uniqueName}`,
},
});
}
} else {
fileLogger.warn("Copy completed but without success confirmation", {
operation: "file_copy_uncertain",
sessionId,
sourcePath,
targetPath,
code,
stdoutData: stdoutData.substring(0, 200),
});
if (!res.headersSent) {
res.json({
message: "Copy may have completed",
sourcePath,
targetPath,
uniqueName,
toast: {
type: "warning",
message: `Copy completed but verification uncertain for: ${uniqueName}`,
},
});
}
}
});
stream.on("error", (streamErr) => {
clearTimeout(commandTimeout);
fileLogger.error("SSH copyItem stream error:", streamErr);
if (!res.headersSent) {
res
.status(500)
.json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
});
/**
* @openapi
* /ssh/file_manager/ssh/executeFile:
* post:
* summary: Execute a file
* description: Executes a file on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* filePath:
* type: string
* responses:
* 200:
* description: File execution result.
* 400:
* description: Missing required parameters or SSH connection not available.
* 500:
* description: Failed to execute file.
*/
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
const { sessionId, filePath } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sshConn || !sshConn.isConnected) {
fileLogger.error(
"SSH connection not found or not connected for executeFile",
{
operation: "execute_file",
sessionId,
hasConnection: !!sshConn,
isConnected: sshConn?.isConnected,
},
);
return res.status(400).json({ error: "SSH connection not available" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath) {
return res.status(400).json({ error: "File path is required" });
}
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
execChannel(sshConn, checkCommand, (checkErr, checkStream) => {
if (checkErr) {
fileLogger.error("SSH executeFile check error:", checkErr);
return res
.status(500)
.json({ error: "Failed to check file executability" });
}
let checkResult = "";
checkStream.on("data", (data) => {
checkResult += data.toString();
});
checkStream.on("close", () => {
if (!checkResult.includes("EXECUTABLE")) {
return res.status(400).json({ error: "File is not executable" });
}
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
execChannel(sshConn, executeCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH executeFile error:", err);
return res.status(500).json({ error: "Failed to execute file" });
}
let output = "";
let errorOutput = "";
stream.on("data", (data) => {
output += data.toString();
});
stream.stderr.on("data", (data) => {
errorOutput += data.toString();
});
stream.on("close", (code) => {
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
const actualExitCode = exitCodeMatch
? parseInt(exitCodeMatch[1])
: code;
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
fileLogger.info("File execution completed", {
operation: "execute_file",
sessionId,
filePath,
exitCode: actualExitCode,
outputLength: cleanOutput.length,
errorLength: errorOutput.length,
});
res.json({
success: true,
exitCode: actualExitCode,
output: cleanOutput,
error: errorOutput,
timestamp: new Date().toISOString(),
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH executeFile stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: "Execution stream error" });
}
});
});
});
});
});
/**
* @openapi
* /ssh/file_manager/ssh/changePermissions:
* post:
* summary: Change file permissions
* description: Changes the permissions of a file on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* permissions:
* type: string
* responses:
* 200:
* description: Permissions changed successfully.
* 400:
* description: Missing required parameters or SSH connection not available.
* 408:
* description: Permission change timed out.
* 500:
* description: Failed to change permissions.
*/
app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
const { sessionId, path, permissions } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sshConn || !sshConn.isConnected) {
fileLogger.error(
"SSH connection not found or not connected for changePermissions",
{
operation: "change_permissions",
sessionId,
hasConnection: !!sshConn,
isConnected: sshConn?.isConnected,
},
);
return res.status(400).json({ error: "SSH connection not available" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!path) {
return res.status(400).json({ error: "File path is required" });
}
if (!permissions || !/^\d{3,4}$/.test(permissions)) {
return res.status(400).json({
error: "Valid permissions required (e.g., 755, 644)",
});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const octalPerms = permissions.slice(-3);
const escapedPath = path.replace(/'/g, "'\"'\"'");
const command = `chmod ${octalPerms} '${escapedPath}' && echo "SUCCESS"`;
fileLogger.info("Changing file permissions", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
const commandTimeout = setTimeout(() => {
if (!res.headersSent) {
fileLogger.error("changePermissions command timeout", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
res.status(408).json({
error: "Permission change timed out. SSH connection may be unstable.",
});
}
}, 10000);
execChannel(sshConn, command, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH changePermissions exec error:", err, {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
return res
.status(500)
.json({ error: "Failed to change permissions" });
}
return;
}
let outputData = "";
let errorOutput = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
});
stream.on("close", (code) => {
clearTimeout(commandTimeout);
if (outputData.includes("SUCCESS")) {
fileLogger.success("File permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
res.json({
success: true,
message: "Permissions changed successfully",
});
}
return;
}
if (code !== 0) {
fileLogger.error("chmod command failed", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
exitCode: code,
error: errorOutput,
});
if (!res.headersSent) {
return res.status(500).json({
error: errorOutput || "Failed to change permissions",
});
}
return;
}
fileLogger.success("File permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
res.json({
success: true,
message: "Permissions changed successfully",
});
}
});
stream.on("error", (streamErr) => {
clearTimeout(commandTimeout);
fileLogger.error("SSH changePermissions stream error:", streamErr, {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
res
.status(500)
.json({ error: "Stream error while changing permissions" });
}
});
});
});
}
File diff suppressed because it is too large. Load diff
@@ -1,72 +0,0 @@
export interface DirectTransferEndpoint {
host: string;
port: number;
username: string;
}
export const DIRECT_TRANSFER_MIN_IMPROVEMENT = 0.2;
export const DIRECT_TRANSFER_MIN_BYTES = 32 * 1024 * 1024;
export function shouldBenchmarkDirectTransfer(totalBytes: number): boolean {
return totalBytes >= DIRECT_TRANSFER_MIN_BYTES;
}
export function quoteShell(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function formatHost(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
export function buildDirectSshCommand(
endpoint: DirectTransferEndpoint,
): string {
const target = `${endpoint.username}@${formatHost(endpoint.host)}`;
return [
"ssh",
"-o BatchMode=yes",
"-o ConnectTimeout=5",
"-o StrictHostKeyChecking=yes",
`-p ${endpoint.port}`,
quoteShell(target),
].join(" ");
}
export function buildDirectProbeCommand(
endpoint: DirectTransferEndpoint,
): string {
return `${buildDirectSshCommand(endpoint)} ${quoteShell("command -v rsync >/dev/null")}`;
}
export function buildDirectRsyncCommand(
endpoint: DirectTransferEndpoint,
sourcePaths: string[],
destPath: string,
destIsDirectory: boolean,
): string {
const sshTransport = buildDirectSshCommand(endpoint).replace(/ '[^']+'$/, "");
const targetHost = `${endpoint.username}@${formatHost(endpoint.host)}`;
const targetPath = destIsDirectory
? `${destPath.replace(/\/+$/, "") || "/"}/`
: destPath;
const sources = sourcePaths.map(quoteShell).join(" ");
const target = quoteShell(`${targetHost}:${targetPath}`);
return [
"rsync -a --partial --append-verify --protect-args --info=progress2",
`-e ${quoteShell(sshTransport)}`,
"--",
sources,
target,
].join(" ");
}
export function shouldUseDirectTransfer(
directMs: number,
relayMs: number,
minImprovement = DIRECT_TRANSFER_MIN_IMPROVEMENT,
): boolean {
if (directMs <= 0 || relayMs <= 0) return false;
return directMs <= relayMs * (1 - minImprovement);
}
@@ -1,356 +0,0 @@
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { fileLogger } from "../../utils/logger.js";
import { getMimeType } from "./utils.js";
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
type FileDownloadRoutesDeps = {
sshSessions: Record<string, SSHSession>;
scheduleSessionCleanup: (sessionId: string) => void;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
function escapeSingleQuotes(path: string): string {
return path.replace(/'/g, "'\"'\"'");
}
function downloadViaExec(
session: SSHSession,
filePath: string,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const escaped = escapeSingleQuotes(filePath);
execChannel(session, `cat '${escaped}'`, (err, stream) => {
if (err) return reject(err);
const chunks: Buffer[] = [];
let stderr = "";
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
stream.on("close", (code: number) => {
if (code !== 0) {
return reject(
new Error(stderr.trim() || `cat exited with code ${code}`),
);
}
resolve(Buffer.concat(chunks));
});
stream.on("error", reject);
});
});
}
export function registerFileDownloadRoutes(
app: Express,
{
sshSessions,
scheduleSessionCleanup,
verifySessionOwnership,
}: FileDownloadRoutesDeps,
): void {
/**
* @openapi
* /ssh/file_manager/ssh/downloadFile:
* post:
* summary: Download a file
* description: Downloads a file from the remote host. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* hostId:
* type: integer
* userId:
* type: string
* responses:
* 200:
* description: The file content.
* 400:
* description: Missing required parameters or file too large.
* 500:
* description: Failed to download file.
*/
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
const { sessionId, path: filePath, hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
const downloadStartTime = Date.now();
if (!sessionId || !filePath) {
fileLogger.warn("Missing download parameters", {
operation: "file_download",
sessionId,
hasFilePath: !!filePath,
});
return res.status(400).json({ error: "Missing download parameters" });
}
fileLogger.info("File download started", {
operation: "file_download_start",
sessionId,
userId,
path: filePath,
});
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
fileLogger.warn("SSH session not found or not connected for download", {
operation: "file_download",
sessionId,
isConnected: sshConn?.isConnected,
});
return res
.status(400)
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
if (sshConn.scpLegacy) {
fileLogger.info(
"Downloading file via legacy exec/cat (SCP legacy mode)",
{
operation: "file_download_legacy",
sessionId,
userId,
path: filePath,
},
);
try {
const data = await downloadViaExec(sshConn, filePath);
const fileName = filePath.split("/").pop() || "download";
fileLogger.success("File download completed (legacy mode)", {
operation: "file_download_complete",
sessionId,
userId,
hostId,
path: filePath,
bytes: data.length,
duration: Date.now() - downloadStartTime,
});
return res.json({
content: data.toString("base64"),
fileName,
size: data.length,
mimeType: getMimeType(fileName),
path: filePath,
});
} catch (err) {
fileLogger.error("Legacy exec/cat download failed:", err);
return res.status(500).json({
error: `Failed to download file: ${(err as Error).message}`,
});
}
}
fileLogger.info("Opening SFTP channel", {
operation: "file_sftp_open",
sessionId,
userId,
path: filePath,
});
getSessionSftp(sshConn)
.then((sftp) => {
sftp.stat(filePath, (statErr, stats) => {
if (statErr) {
fileLogger.error("File stat failed for download:", statErr);
return res
.status(500)
.json({ error: `Cannot access file: ${statErr.message}` });
}
if (!stats.isFile()) {
fileLogger.warn("Attempted to download non-file", {
operation: "file_download",
sessionId,
filePath,
isFile: stats.isFile(),
isDirectory: stats.isDirectory(),
});
return res
.status(400)
.json({ error: "Cannot download directories or special files" });
}
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
if (stats.size > MAX_FILE_SIZE) {
fileLogger.warn("File too large for download", {
operation: "file_download",
sessionId,
filePath,
fileSize: stats.size,
maxSize: MAX_FILE_SIZE,
});
return res.status(400).json({
error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`,
});
}
sftp.readFile(filePath, (readErr, data) => {
if (readErr) {
fileLogger.error("File read failed for download:", readErr);
return res
.status(500)
.json({ error: `Failed to read file: ${readErr.message}` });
}
const base64Content = data.toString("base64");
const fileName = filePath.split("/").pop() || "download";
fileLogger.success("File download completed", {
operation: "file_download_complete",
sessionId,
userId,
hostId,
path: filePath,
bytes: stats.size,
duration: Date.now() - downloadStartTime,
});
res.json({
content: base64Content,
fileName: fileName,
size: stats.size,
mimeType: getMimeType(fileName),
path: filePath,
});
});
});
})
.catch((err) => {
fileLogger.error("SFTP connection failed for download:", err);
return res.status(500).json({ error: "SFTP connection failed" });
});
});
/**
* @openapi
* /ssh/file_manager/ssh/downloadFileStream:
* post:
* summary: Stream-download a file
* description: Downloads a file as a binary stream. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* responses:
* 200:
* description: Binary file stream.
* 400:
* description: Missing required parameters.
* 500:
* description: Download failed.
*/
app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
const { sessionId, path: filePath } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId || !filePath) {
return res.status(400).json({ error: "Missing download parameters" });
}
const sshConn = sshSessions[sessionId];
if (!sshConn?.isConnected) {
return res
.status(400)
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
const fileName = filePath.split("/").pop() || "download";
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(fileName)}"`,
);
if (sshConn.scpLegacy) {
fileLogger.info("Streaming file via legacy exec/cat (SCP legacy mode)", {
operation: "file_download_stream_legacy",
sessionId,
userId,
path: filePath,
});
const escaped = escapeSingleQuotes(filePath);
execChannel(sshConn, `cat '${escaped}'`, (err, stream) => {
if (err) {
if (!res.headersSent) {
res.status(500).json({ error: `Download failed: ${err.message}` });
}
return;
}
stream.on("error", (streamErr: Error) => {
if (!res.headersSent) {
res
.status(500)
.json({ error: `Download failed: ${streamErr.message}` });
} else {
res.destroy();
}
});
stream.pipe(res);
});
return;
}
try {
const sftp = await getSessionSftp(sshConn);
const stats = await new Promise<{ size: number; isFile: () => boolean }>(
(resolve, reject) => {
sftp.stat(filePath, (err, s) => (err ? reject(err) : resolve(s)));
},
);
if (!stats.isFile()) {
return res.status(400).json({ error: "Cannot download directories" });
}
res.setHeader("Content-Length", String(stats.size));
const readStream = sftp.createReadStream(filePath);
readStream.on("error", (err) => {
if (!res.headersSent) {
res.status(500).json({ error: `Download failed: ${err.message}` });
} else {
res.destroy();
}
});
readStream.pipe(res);
} catch (err) {
if (!res.headersSent) {
res
.status(500)
.json({ error: `Download failed: ${(err as Error).message}` });
}
}
});
}
File diff suppressed because it is too large. Load diff
@@ -1,467 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { fileLogger } from "../../utils/logger.js";
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
import {
formatMtime,
isExecutableFile,
modeToPermissions,
parseLsDateToTimestamp,
} from "./utils.js";
type FileListingRoutesDeps = {
sshSessions: Record<string, SSHSession>;
activeListRequests: Record<string, boolean>;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
export function registerFileListingRoutes(
app: Express,
{
sshSessions,
activeListRequests,
verifySessionOwnership,
}: FileListingRoutesDeps,
): void {
/**
* @openapi
* /ssh/file_manager/ssh/listFiles:
* get:
* summary: List files in a directory
* description: Lists the files and directories in a given path on the remote host.
* tags:
* - File Manager
* parameters:
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* - in: query
* name: path
* required: true
* schema:
* type: string
* responses:
* 200:
* description: A list of files and directories.
* 400:
* description: Session ID is required or SSH connection not established.
* 500:
* description: Failed to list files.
*/
app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const sshPath = (req.query.path as string) || "/";
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
// Drop concurrent requests for the same session+path — each would open
// a new SSH channel and can exceed the server's per-connection channel limit.
const listKey = `${sessionId}:${sshPath}`;
if (activeListRequests[listKey]) {
return res
.status(409)
.json({ error: "List request already in progress" });
}
activeListRequests[listKey] = true;
res.on("finish", () => {
delete activeListRequests[listKey];
});
sshConn.lastActive = Date.now();
sshConn.activeOperations++;
const trySFTP = () => {
try {
fileLogger.info("Opening SFTP channel", {
operation: "file_sftp_open",
sessionId,
userId,
path: sshPath,
});
getSessionSftp(sshConn)
.then((sftp) => {
sftp.readdir(sshPath, (readdirErr, list) => {
if (readdirErr) {
fileLogger.warn(
`SFTP readdir failed, trying fallback: ${readdirErr.message}`,
);
tryFallbackMethod();
return;
}
const symlinks: Array<{ index: number; path: string }> = [];
const files: Array<{
name: string;
type: string;
size: number | undefined;
modified: string;
modifiedTimestamp: number;
permissions: string;
owner: string;
group: string;
linkTarget: string | undefined;
path: string;
executable: boolean;
}> = [];
for (const entry of list) {
if (entry.filename === "." || entry.filename === "..") continue;
const attrs = entry.attrs;
const permissions = modeToPermissions(attrs.mode);
const isDirectory = attrs.isDirectory();
const isLink = attrs.isSymbolicLink();
const fileEntry = {
name: entry.filename,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : attrs.size,
modified: formatMtime(attrs.mtime),
modifiedTimestamp: attrs.mtime,
permissions,
owner: String(attrs.uid),
group: String(attrs.gid),
linkTarget: undefined as string | undefined,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, entry.filename)
: false,
};
if (isLink) {
symlinks.push({ index: files.length, path: fileEntry.path });
}
files.push(fileEntry);
}
if (symlinks.length === 0) {
sshConn.activeOperations--;
return res.json({ files, path: sshPath });
}
let resolved = 0;
let responded = false;
const sendResponse = () => {
if (responded) return;
responded = true;
sshConn.activeOperations--;
res.json({ files, path: sshPath });
};
const readlinkTimeout = setTimeout(sendResponse, 5000);
for (const link of symlinks) {
sftp.readlink(link.path, (linkErr, target) => {
resolved++;
if (!linkErr && target) {
files[link.index].linkTarget = target;
}
if (resolved === symlinks.length) {
clearTimeout(readlinkTimeout);
sendResponse();
}
});
}
});
})
.catch((err: Error) => {
fileLogger.warn(
`SFTP failed for listFiles, trying fallback: ${err.message}`,
);
const isChannelFailure =
err.message.toLowerCase().includes("channel open failure") ||
err.message.toLowerCase().includes("open failed");
if (isChannelFailure) {
sshConn.isConnected = false;
sshConn.sftp = undefined;
}
tryFallbackMethod();
});
} catch (sftpErr: unknown) {
const errMsg = getErrorMessage(sftpErr);
fileLogger.warn(`SFTP connection error, trying fallback: ${errMsg}`);
tryFallbackMethod();
}
};
const tryFallbackMethod = () => {
if (!sshConn?.isConnected) {
sshConn.activeOperations--;
return res
.status(503)
.json({ error: "SSH session disconnected", disconnected: true });
}
try {
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
execChannel(
sshConn,
`command ls -la --color=never '${escapedPath}'`,
(err, stream) => {
if (err) {
sshConn.activeOperations--;
fileLogger.error("SSH listFiles error:", err);
return res.status(500).json({ error: err.message });
}
let data = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on("close", (code) => {
if (code !== 0) {
const isPermissionDenied =
errorData.toLowerCase().includes("permission denied") ||
errorData.toLowerCase().includes("access denied");
if (isPermissionDenied) {
if (sshConn.sudoPassword) {
fileLogger.info(
`Permission denied for listFiles, retrying with sudo: ${sshPath}`,
);
tryWithSudo();
return;
}
sshConn.activeOperations--;
fileLogger.warn(
`Permission denied for listFiles, sudo required: ${sshPath}`,
);
return res.status(403).json({
error: `Permission denied: Cannot access ${sshPath}`,
needsSudo: true,
path: sshPath,
});
}
sshConn.activeOperations--;
fileLogger.error(
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
return res
.status(500)
.json({ error: `Command failed: ${errorData}` });
}
sshConn.activeOperations--;
const lines = data.split("\n").filter((line) => line.trim());
const files = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
if (parts.length >= 9) {
const permissions = parts[0];
const owner = parts[2];
const group = parts[3];
const size = parseInt(parts[4], 10);
let dateStr = "";
const nameStartIndex = 8;
if (parts[5] && parts[6] && parts[7]) {
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
}
const name = parts.slice(nameStartIndex).join(" ");
const isDirectory = permissions.startsWith("d");
const isLink = permissions.startsWith("l");
if (name === "." || name === "..") continue;
let actualName = name;
let linkTarget = undefined;
if (isLink && name.includes(" -> ")) {
const linkParts = name.split(" -> ");
actualName = linkParts[0];
linkTarget = linkParts[1];
}
files.push({
name: actualName,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : size,
modified: dateStr,
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
permissions,
owner,
group,
linkTarget,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, actualName)
: false,
});
}
}
res.json({ files, path: sshPath });
});
},
);
} catch (execErr: unknown) {
sshConn.activeOperations--;
const errMsg = getErrorMessage(execErr);
fileLogger.error(`Fallback listFiles exec failed: ${errMsg}`);
if (!res.headersSent) {
return res.status(500).json({ error: errMsg });
}
}
};
const tryWithSudo = () => {
try {
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'");
const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`;
execChannel(sshConn, sudoCommand, (err, stream) => {
if (err) {
sshConn.activeOperations--;
fileLogger.error("SSH sudo listFiles error:", err);
return res.status(500).json({ error: err.message });
}
let data = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on("close", (code) => {
sshConn.activeOperations--;
data = data.replace(/\[sudo\] password for .+?:\s*/g, "");
if (
data.toLowerCase().includes("sorry, try again") ||
data.toLowerCase().includes("incorrect password") ||
errorData.toLowerCase().includes("sorry, try again")
) {
sshConn.sudoPassword = undefined;
return res.status(403).json({
error: "Sudo authentication failed. Please try again.",
needsSudo: true,
sudoFailed: true,
path: sshPath,
});
}
if (code !== 0 && !data.trim()) {
fileLogger.error(
`SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
return res
.status(500)
.json({ error: `Sudo command failed: ${errorData || data}` });
}
const lines = data.split("\n").filter((line) => line.trim());
const files: Array<{
name: string;
type: string;
size: number | undefined;
modified: string;
modifiedTimestamp: number;
permissions: string;
owner: string;
group: string;
linkTarget: string | undefined;
path: string;
executable: boolean;
}> = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
if (parts.length >= 9) {
const permissions = parts[0];
const owner = parts[2];
const group = parts[3];
const size = parseInt(parts[4], 10);
let dateStr = "";
const nameStartIndex = 8;
if (parts[5] && parts[6] && parts[7]) {
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
}
const name = parts.slice(nameStartIndex).join(" ");
const isDirectory = permissions.startsWith("d");
const isLink = permissions.startsWith("l");
if (name === "." || name === "..") continue;
let actualName = name;
let linkTarget = undefined;
if (isLink && name.includes(" -> ")) {
const linkParts = name.split(" -> ");
actualName = linkParts[0];
linkTarget = linkParts[1];
}
files.push({
name: actualName,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : size,
modified: dateStr,
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
permissions,
owner,
group,
linkTarget,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, actualName)
: false,
});
}
}
res.json({ files, path: sshPath });
});
});
} catch (execErr: unknown) {
sshConn.activeOperations--;
const errMsg = getErrorMessage(execErr);
fileLogger.error(`Sudo listFiles exec failed: ${errMsg}`);
if (!res.headersSent) {
return res.status(500).json({ error: errMsg });
}
}
};
trySFTP();
});
}
@@ -1,39 +0,0 @@
import { isWindowsSftpPath, sftpPathToLocalPath } from "../transfer-paths.js";
export interface DeleteCommand {
command: string;
commandWithSuccess: string;
}
function quotePosixPath(path: string): string {
return `'${path.replace(/'/g, "'\"'\"'")}'`;
}
function quotePowerShellLiteral(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
export function buildDeleteCommand(
itemPath: string,
isDirectory: boolean,
): DeleteCommand {
if (isWindowsSftpPath(itemPath)) {
const path = quotePowerShellLiteral(sftpPathToLocalPath(itemPath));
const command = isDirectory
? `Remove-Item -LiteralPath ${path} -Recurse -Force -ErrorAction Stop`
: `Remove-Item -LiteralPath ${path} -Force -ErrorAction Stop`;
return {
command,
commandWithSuccess: `${command}; if ($?) { Write-Output "SUCCESS" }`,
};
}
const path = quotePosixPath(itemPath);
const command = isDirectory ? `rm -rf ${path}` : `rm -f ${path}`;
return {
command,
commandWithSuccess: `${command} && echo "SUCCESS"`,
};
}
@@ -1,913 +0,0 @@
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { fileLogger } from "../../utils/logger.js";
import {
execChannel,
execWithSudo,
getSessionSftp,
type SSHSession,
} from "./session.js";
import { ensureDirectoryTreeSftp } from "./transfer-sftp-dir.js";
import { buildDeleteCommand } from "./operation-commands.js";
import {
emptyTrash,
listTrash,
moveToTrash,
permanentlyDeleteTrashItem,
restoreTrashItem,
} from "./trash-service.js";
import {
createCurrentSettingsRepository,
getCurrentSettingValue,
} from "../../database/repositories/factory.js";
import { PermissionManager } from "../../utils/permission-manager.js";
type FileOperationRoutesDeps = {
sshSessions: Record<string, SSHSession>;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
export function registerFileOperationRoutes(
app: Express,
{ sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
): void {
const permissionManager = PermissionManager.getInstance();
const getTrashRetentionDays = () => {
try {
const value = Number(
getCurrentSettingValue("file_manager_trash_retention_days"),
);
return Number.isInteger(value) && value >= 1 && value <= 3650 ? value : 7;
} catch {
return 7;
}
};
async function ownedSession(
req: AuthenticatedRequest,
res: import("express").Response,
) {
const sessionId = String(req.body?.sessionId ?? req.query?.sessionId ?? "");
const session = sshSessions[sessionId];
if (!sessionId || !session?.isConnected) {
res.status(400).json({ error: "SSH connection not established" });
return null;
}
if (!verifySessionOwnership(session, req.userId)) {
res.status(403).json({ error: "Session access denied" });
return null;
}
session.lastActive = Date.now();
return session;
}
app.get("/ssh/file_manager/ssh/trash", async (req, res) => {
const session = await ownedSession(
req as unknown as AuthenticatedRequest,
res,
);
if (!session) return;
try {
res.json({
items: await listTrash(
await getSessionSftp(session),
getTrashRetentionDays(),
),
retentionDays: getTrashRetentionDays(),
canManageRetention: await permissionManager.isAdmin(
(req as AuthenticatedRequest).userId,
),
});
} catch (error) {
fileLogger.error("Failed to list trash", error);
res.status(500).json({ error: (error as Error).message });
}
});
app.post("/ssh/file_manager/ssh/trash/:id/restore", async (req, res) => {
const session = await ownedSession(
req as unknown as AuthenticatedRequest,
res,
);
if (!session) return;
try {
res.json({
item: await restoreTrashItem(
await getSessionSftp(session),
req.params.id,
),
});
} catch (error) {
const message = (error as Error).message;
res
.status(message.includes("already exists") ? 409 : 500)
.json({ error: message });
}
});
app.delete("/ssh/file_manager/ssh/trash/:id", async (req, res) => {
const session = await ownedSession(
req as unknown as AuthenticatedRequest,
res,
);
if (!session) return;
try {
await permanentlyDeleteTrashItem(
await getSessionSftp(session),
req.params.id,
);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
app.delete("/ssh/file_manager/ssh/trash", async (req, res) => {
const session = await ownedSession(req as AuthenticatedRequest, res);
if (!session) return;
try {
res.json({ deleted: await emptyTrash(await getSessionSftp(session)) });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
app.put("/ssh/file_manager/ssh/trash-retention", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
if (!(await permissionManager.isAdmin(userId))) {
return res.status(403).json({ error: "Admin access required" });
}
const retentionDays = Number(req.body?.retentionDays);
if (
!Number.isInteger(retentionDays) ||
retentionDays < 1 ||
retentionDays > 3650
) {
return res
.status(400)
.json({ error: "Retention must be between 1 and 3650 days" });
}
await createCurrentSettingsRepository().upsert(
"file_manager_trash_retention_days",
String(retentionDays),
);
return res.json({ retentionDays });
});
/**
* @openapi
* /ssh/file_manager/ssh/createFile:
* post:
* summary: Create a file
* description: Creates an empty file on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* fileName:
* type: string
* responses:
* 200:
* description: File created successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to create file.
*/
app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
const { sessionId, path: filePath, fileName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath || !fileName) {
return res.status(400).json({ error: "File path and name are required" });
}
sshConn.lastActive = Date.now();
const fullPath = filePath.endsWith("/")
? filePath + fileName
: filePath + "/" + fileName;
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
execChannel(sshConn, createCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH createFile error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
fileLogger.error(`Permission denied creating file: ${fullPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.`,
});
}
return;
}
});
stream.on("close", (code) => {
if (outputData.includes("SUCCESS")) {
if (!res.headersSent) {
res.json({
message: "File created successfully",
path: fullPath,
toast: { type: "success", message: `File created: ${fullPath}` },
});
}
return;
}
if (code !== 0) {
fileLogger.error(
`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: {
type: "error",
message: `File creation failed: ${errorData}`,
},
});
}
return;
}
if (!res.headersSent) {
res.json({
message: "File created successfully",
path: fullPath,
toast: { type: "success", message: `File created: ${fullPath}` },
});
}
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH createFile stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
/**
* @openapi
* /ssh/file_manager/ssh/createFolder:
* post:
* summary: Create a folder
* description: Creates a new folder on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* folderName:
* type: string
* responses:
* 200:
* description: Folder created successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to create folder.
*/
app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
const { sessionId, path: folderPath, folderName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!folderPath || !folderName) {
return res
.status(400)
.json({ error: "Folder path and name are required" });
}
sshConn.lastActive = Date.now();
const fullPath = folderPath.endsWith("/")
? folderPath + folderName
: folderPath + "/" + folderName;
fileLogger.info("Creating directory", {
operation: "file_mkdir",
sessionId,
userId,
path: fullPath,
});
try {
await ensureDirectoryTreeSftp(await getSessionSftp(sshConn), fullPath);
res.json({
message: "Folder created successfully",
path: fullPath,
toast: { type: "success", message: `Folder created: ${fullPath}` },
});
} catch (error) {
fileLogger.error("SFTP createFolder failed", error);
const code = (error as { code?: string | number }).code;
res.status(code === 3 || code === "EACCES" ? 403 : 500).json({
error: (error as Error).message,
});
}
});
/**
* @openapi
* /ssh/file_manager/ssh/deleteItem:
* delete:
* summary: Delete a file or directory
* description: Deletes a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* isDirectory:
* type: boolean
* responses:
* 200:
* description: Item deleted successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to delete item.
*/
app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
const { sessionId, path: itemPath, isDirectory, permanent } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!itemPath) {
return res.status(400).json({ error: "Item path is required" });
}
fileLogger.info("Deleting item", {
operation: "file_delete",
sessionId,
userId,
path: itemPath,
type: isDirectory ? "directory" : "file",
});
sshConn.lastActive = Date.now();
if (!permanent) {
try {
const sftp = await getSessionSftp(sshConn);
await listTrash(sftp, getTrashRetentionDays());
const item = await moveToTrash(sftp, itemPath);
fileLogger.success("Item moved to trash", {
operation: "file_trash_success",
sessionId,
userId,
path: itemPath,
trashId: item.id,
});
return res.json({
message: "Item moved to trash",
path: itemPath,
trashItem: item,
});
} catch (error) {
fileLogger.error("Failed to move item to trash", error, {
operation: "file_trash_failed",
sessionId,
userId,
path: itemPath,
});
return res.status(409).json({
error: (error as Error).message,
trashUnavailable: true,
});
}
}
const { command: deleteCommand, commandWithSuccess } = buildDeleteCommand(
itemPath,
Boolean(isDirectory),
);
const executeDelete = (useSudo: boolean): Promise<void> => {
return new Promise((resolve) => {
if (useSudo && sshConn.sudoPassword) {
execWithSudo(sshConn, deleteCommand, sshConn.sudoPassword).then(
(result) => {
if (
result.code === 0 ||
(!result.stderr.includes("Permission denied") &&
!result.stdout.includes("Permission denied"))
) {
res.json({
message: "Item deleted successfully",
path: itemPath,
toast: {
type: "success",
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
},
});
} else {
res.status(500).json({
error: `Delete failed: ${result.stderr || result.stdout}`,
});
}
resolve();
},
);
return;
}
execChannel(sshConn, commandWithSuccess, (err, stream) => {
if (err) {
fileLogger.error("SSH deleteItem error:", err);
res.status(500).json({ error: err.message });
resolve();
return;
}
let outputData = "";
let errorData = "";
let permissionDenied = false;
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
permissionDenied = true;
}
});
stream.on("close", (code) => {
if (permissionDenied) {
if (sshConn.sudoPassword) {
executeDelete(true).then(resolve);
return;
}
fileLogger.error(`Permission denied deleting: ${itemPath}`);
res.status(403).json({
error: `Permission denied: Cannot delete ${itemPath}.`,
needsSudo: true,
});
resolve();
return;
}
if (outputData.includes("SUCCESS")) {
fileLogger.success("Item deleted successfully", {
operation: "file_delete_success",
sessionId,
userId,
path: itemPath,
});
res.json({
message: "Item deleted successfully",
path: itemPath,
toast: {
type: "success",
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
},
});
} else {
const detail =
errorData.trim() ||
outputData.trim() ||
`command exited with code ${code} and produced no output (the remote shell may not support the delete command)`;
fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, {
operation: "file_delete_failed",
sessionId,
userId,
path: itemPath,
});
res.status(500).json({
error: `Delete failed: ${detail}`,
});
}
resolve();
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH deleteItem stream error:", streamErr);
res
.status(500)
.json({ error: `Stream error: ${streamErr.message}` });
resolve();
});
});
});
};
await executeDelete(false);
});
/**
* @openapi
* /ssh/file_manager/ssh/renameItem:
* put:
* summary: Rename a file or directory
* description: Renames a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* oldPath:
* type: string
* newName:
* type: string
* responses:
* 200:
* description: Item renamed successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to rename item.
*/
app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
const { sessionId, oldPath, newName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!oldPath || !newName) {
return res
.status(400)
.json({ error: "Old path and new name are required" });
}
sshConn.lastActive = Date.now();
const oldDir = oldPath.substring(0, oldPath.lastIndexOf("/") + 1);
const newPath = oldDir + newName;
fileLogger.info("Renaming item", {
operation: "file_rename",
sessionId,
userId,
from: oldPath,
to: newPath,
});
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
execChannel(sshConn, renameCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH renameItem error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
fileLogger.error(`Permission denied renaming: ${oldPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.`,
});
}
return;
}
});
stream.on("close", (code) => {
if (outputData.includes("SUCCESS")) {
fileLogger.success("Item renamed successfully", {
operation: "file_rename_success",
sessionId,
userId,
from: oldPath,
to: newPath,
});
if (!res.headersSent) {
res.json({
message: "Item renamed successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item renamed: ${oldPath} -> ${newPath}`,
},
});
}
return;
}
if (code !== 0) {
fileLogger.error(
`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: { type: "error", message: `Rename failed: ${errorData}` },
});
}
return;
}
fileLogger.success("Item renamed successfully", {
operation: "file_rename_success",
sessionId,
userId,
from: oldPath,
to: newPath,
});
if (!res.headersSent) {
res.json({
message: "Item renamed successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item renamed: ${oldPath} -> ${newPath}`,
},
});
}
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH renameItem stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
/**
* @openapi
* /ssh/file_manager/ssh/moveItem:
* put:
* summary: Move a file or directory
* description: Moves a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* oldPath:
* type: string
* newPath:
* type: string
* responses:
* 200:
* description: Item moved successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 408:
* description: Move operation timed out.
* 500:
* description: Failed to move item.
*/
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
const { sessionId, oldPath, newPath } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!oldPath || !newPath) {
return res
.status(400)
.json({ error: "Old path and new path are required" });
}
sshConn.lastActive = Date.now();
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
const commandTimeout = setTimeout(() => {
if (!res.headersSent) {
res.status(408).json({
error: "Move operation timed out. SSH connection may be unstable.",
toast: {
type: "error",
message:
"Move operation timed out. SSH connection may be unstable.",
},
});
}
}, 60000);
execChannel(sshConn, moveCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH moveItem error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
fileLogger.error(`Permission denied moving: ${oldPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
toast: {
type: "error",
message: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
},
});
}
return;
}
});
stream.on("close", (code) => {
clearTimeout(commandTimeout);
if (outputData.includes("SUCCESS")) {
if (!res.headersSent) {
res.json({
message: "Item moved successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item moved: ${oldPath} -> ${newPath}`,
},
});
}
return;
}
if (code !== 0) {
fileLogger.error(
`SSH moveItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: { type: "error", message: `Move failed: ${errorData}` },
});
}
return;
}
if (!res.headersSent) {
res.json({
message: "Item moved successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item moved: ${oldPath} -> ${newPath}`,
},
});
}
});
stream.on("error", (streamErr) => {
clearTimeout(commandTimeout);
fileLogger.error("SSH moveItem stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
}
-282
View File
@@ -1,282 +0,0 @@
import type { Client as SSHClient } from "ssh2";
// Serializes SSH channel open requests so only one channel negotiation is
// in-flight at a time per session. Once the channel is established the slot
// is released immediately so the next open can proceed; the channels
// themselves remain open concurrently.
export class ChannelOpenSerializer {
private tail: Promise<void> = Promise.resolve();
run<T>(action: () => Promise<T>): Promise<T> {
const next = this.tail.then(
() => action(),
() => action(),
);
this.tail = next.then(
() => {},
() => {},
);
return next;
}
}
export interface SSHSession {
client: SSHClient;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number;
sudoPassword?: string;
sftp?: import("ssh2").SFTPWrapper;
sftpPending?: Promise<import("ssh2").SFTPWrapper>;
channelOpener: ChannelOpenSerializer;
poolKey?: string;
userId?: string;
hostId?: number;
ip?: string;
port?: number;
username?: string;
transferDedicated?: boolean;
transferId?: string;
browseSessionId?: string;
scpLegacy?: boolean;
}
export interface PendingTOTPSession {
client: SSHClient;
finish: (responses: string[]) => void;
config: import("ssh2").ConnectConfig;
createdAt: number;
sessionId: string;
hostId?: number;
ip?: string;
port?: number;
username?: string;
userId?: string;
prompts?: Array<{ prompt: string; echo: boolean }>;
totpPromptIndex?: number;
resolvedPassword?: string;
totpAttempts: number;
isWarpgate?: boolean;
}
export function execWithSudo(
session: SSHSession,
command: string,
sudoPassword: string,
): Promise<{ stdout: string; stderr: string; code: number }> {
return execWithSudoBuffer(session, command, sudoPassword).then((result) => ({
stdout: result.stdout.toString("utf8"),
stderr: result.stderr,
code: result.code,
}));
}
export function execWithSudoBuffer(
session: SSHSession,
command: string,
sudoPassword: string,
maxStdoutBytes?: number,
): Promise<{
stdout: Buffer;
stderr: string;
code: number;
exceededLimit?: boolean;
}> {
return new Promise((resolve) => {
const sudoCommand = `sudo -S -p '' ${command} 2>&1`;
execChannel(session, sudoCommand, (err, stream) => {
if (err) {
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
return;
}
const stdoutChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderr = "";
let settled = false;
const finish = (
code: number,
extra: { exceededLimit?: boolean } = {},
) => {
if (settled) return;
settled = true;
resolve({
stdout: Buffer.concat(stdoutChunks),
stderr,
code,
...extra,
});
};
stream.on("data", (chunk: Buffer) => {
if (settled) return;
stdoutBytes += chunk.length;
if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) {
stderr = `Command output exceeds ${maxStdoutBytes} bytes`;
finish(1, { exceededLimit: true });
stream.close();
return;
}
stdoutChunks.push(chunk);
});
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
stream.on("close", (code: number) => {
if (settled) return;
let stdout = Buffer.concat(stdoutChunks);
const sudoPromptMatch = stdout
.toString("utf8", 0, Math.min(stdout.length, 256))
.match(/^\[sudo\] password for .+?:\s*/);
if (sudoPromptMatch) {
stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0]));
}
settled = true;
resolve({ stdout, stderr, code: code || 0 });
});
stream.on("error", (streamErr: Error) => {
stderr = streamErr.message;
finish(1);
});
stream.write(`${sudoPassword}\n`);
});
});
}
export function execBuffer(
session: SSHSession,
command: string,
maxStdoutBytes?: number,
): Promise<{
stdout: Buffer;
stderr: string;
code: number;
exceededLimit?: boolean;
}> {
return new Promise((resolve) => {
execChannel(session, command, (err, stream) => {
if (err) {
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
return;
}
const stdoutChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderr = "";
let settled = false;
const finish = (
code: number,
extra: { exceededLimit?: boolean } = {},
) => {
if (settled) return;
settled = true;
resolve({
stdout: Buffer.concat(stdoutChunks),
stderr,
code,
...extra,
});
};
stream.on("data", (chunk: Buffer) => {
if (settled) return;
stdoutBytes += chunk.length;
if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) {
stderr = `Command output exceeds ${maxStdoutBytes} bytes`;
finish(1, { exceededLimit: true });
stream.close();
return;
}
stdoutChunks.push(chunk);
});
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
stream.on("close", (code: number) => finish(code || 0));
stream.on("error", (streamErr: Error) => {
stderr = stderr || streamErr.message;
finish(1);
});
});
});
}
export function getSessionSftp(
session: SSHSession,
): Promise<import("ssh2").SFTPWrapper> {
if (session.sftp) {
return Promise.resolve(session.sftp);
}
if (session.sftpPending) {
return session.sftpPending;
}
const openOnce = (): Promise<import("ssh2").SFTPWrapper> =>
session.channelOpener.run(
() =>
new Promise<import("ssh2").SFTPWrapper>((resolve, reject) => {
session.client.sftp((err, sftp) => {
if (err) return reject(err);
session.sftp = sftp;
sftp.on("error", () => {
session.sftp = undefined;
});
sftp.on("close", () => {
session.sftp = undefined;
});
resolve(sftp);
});
}),
);
session.sftpPending = openOnce()
.catch((err: Error) => {
const isChannelFailure =
err.message.toLowerCase().includes("channel open failure") ||
err.message.toLowerCase().includes("open failed");
if (isChannelFailure) {
return new Promise<import("ssh2").SFTPWrapper>((resolve, reject) =>
setTimeout(() => openOnce().then(resolve, reject), 500),
);
}
return Promise.reject(err);
})
.finally(() => {
session.sftpPending = undefined;
});
return session.sftpPending;
}
export function execChannel(
session: SSHSession,
command: string,
callback: (
err: Error | undefined,
stream: import("ssh2").ClientChannel,
) => void,
): void {
session.channelOpener
.run(
() =>
new Promise<import("ssh2").ClientChannel>((resolve, reject) => {
session.client.exec(command, (err, stream) => {
if (err) return reject(err);
resolve(stream);
});
}),
)
.then(
(stream) => callback(undefined, stream),
(err: Error) => callback(err, undefined as never),
);
}
@@ -1,120 +0,0 @@
type SFTPWrapper = import("ssh2").SFTPWrapper;
export const SFTP_OPEN_READ = 0x00000001;
export const SFTP_OPEN_WRITE = 0x00000002 | 0x00000008 | 0x00000010;
export const SFTP_OPEN_WRITE_RESUME = 0x00000001 | 0x00000002 | 0x00000008;
export function promisifySftpStat(
sftp: SFTPWrapper,
path: string,
): Promise<import("ssh2").Stats> {
return new Promise((resolve, reject) => {
sftp.stat(path, (err, stats) => {
if (err) reject(err);
else resolve(stats);
});
});
}
export function promisifySftpUnlink(
sftp: SFTPWrapper,
path: string,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.unlink(path, (err) => {
if (err) reject(err);
else resolve();
});
});
}
export function promisifySftpRmdir(
sftp: SFTPWrapper,
path: string,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.rmdir(path, (err) => {
if (err) reject(err);
else resolve();
});
});
}
export function promisifySftpMkdir(
sftp: SFTPWrapper,
path: string,
mode: number,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.mkdir(path, { mode }, (err) => {
if (err && (err as NodeJS.ErrnoException).code !== "EEXIST") {
reject(err);
} else {
resolve();
}
});
});
}
export function promisifySftpChmod(
sftp: SFTPWrapper,
path: string,
mode: number,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.chmod(path, mode, (err) => {
if (err) reject(err);
else resolve();
});
});
}
export function promisifySftpReaddir(
sftp: SFTPWrapper,
path: string,
): Promise<Array<{ filename: string; attrs: import("ssh2").Stats }>> {
return new Promise((resolve, reject) => {
sftp.readdir(path, (err, list) => {
if (err) reject(err);
else resolve(list);
});
});
}
export function promisifySftpOpen(
sftp: SFTPWrapper,
path: string,
flags: number,
mode: number,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
sftp.open(path, flags, mode, (err, handle) => {
if (err) reject(err);
else resolve(handle);
});
});
}
export function promisifySftpClose(
sftp: SFTPWrapper,
handle: Buffer,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.close(handle, (err) => {
if (err) reject(err);
else resolve();
});
});
}
export function promisifySftpFstat(
sftp: SFTPWrapper,
handle: Buffer,
): Promise<import("ssh2").Stats> {
return new Promise((resolve, reject) => {
sftp.fstat(handle, (err, stats) => {
if (err) reject(err);
else resolve(stats);
});
});
}
File diff suppressed because it is too large. Load diff
@@ -1,52 +0,0 @@
export class TransferCancelledError extends Error {
constructor() {
super("Transfer cancelled");
this.name = "TransferCancelledError";
}
}
export class TransferStalledError extends Error {
readonly byteOffset?: number;
readonly segmentIndex?: number;
constructor(byteOffset?: number, segmentIndex?: number) {
const pos = byteOffset !== undefined ? ` at byte offset ${byteOffset}` : "";
const seg = segmentIndex !== undefined ? ` (segment ${segmentIndex})` : "";
super(`Transfer stalled — no data moved for 45 seconds${pos}${seg}`);
this.name = "TransferStalledError";
this.byteOffset = byteOffset;
this.segmentIndex = segmentIndex;
}
}
export class TransferConnectionLostError extends Error {
constructor(message = "Transfer SSH connection lost") {
super(message);
this.name = "TransferConnectionLostError";
}
}
export function isRecoverableTransferConnectionError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("no response from server") ||
msg.includes("connection lost") ||
msg.includes("not connected") ||
msg.includes("econnreset") ||
msg.includes("econnrefused") ||
msg.includes("etimedout") ||
msg.includes("socket hang up") ||
msg.includes("protocol error") ||
msg.includes("connection closed") ||
msg.includes("channel open failure")
);
}
export function isRecoverableTransferError(err: unknown): boolean {
return (
err instanceof TransferStalledError ||
err instanceof TransferConnectionLostError ||
isRecoverableTransferConnectionError(err)
);
}
@@ -1,59 +0,0 @@
import { networkInterfaces } from "os";
import { normalizeSftpPath } from "../transfer-paths.js";
let cachedLocalAddresses: Set<string> | null = null;
export function normalizeHostAddress(host: string): string {
const trimmed = host.trim().toLowerCase();
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
return trimmed.slice(1, -1);
}
return trimmed.split(":")[0] ?? trimmed;
}
function getLocalAddresses(): Set<string> {
if (cachedLocalAddresses) return cachedLocalAddresses;
const addresses = new Set(["127.0.0.1", "::1", "localhost"]);
for (const ifaces of Object.values(networkInterfaces())) {
if (!ifaces) continue;
for (const iface of ifaces) {
if (!iface.internal && iface.family === "IPv4") {
addresses.add(iface.address.toLowerCase());
}
}
}
cachedLocalAddresses = addresses;
return addresses;
}
export function isLocalSshEndpoint(ip?: string): boolean {
if (!ip) return false;
const bare = normalizeHostAddress(ip);
if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1") {
return true;
}
return getLocalAddresses().has(bare);
}
export function escapeShell(s: string): string {
return s.replace(/'/g, "'\"'\"'");
}
export function isRootOnlyPath(path: string): boolean {
const normalized = normalizeSftpPath(path);
return (
normalized === "/" ||
/^\/[A-Za-z]:$/.test(normalized) ||
/^[A-Za-z]:$/.test(normalized)
);
}
export function isPermissionError(err: Error): boolean {
const msg = err.message.toLowerCase();
return (
msg.includes("permission denied") ||
msg.includes("eacces") ||
msg.includes("access denied")
);
}
@@ -1,54 +0,0 @@
import { createHash } from "node:crypto";
type SFTPWrapper = import("ssh2").SFTPWrapper;
export interface TransferIntegrityResult {
algorithm: "sha256";
digest: string;
}
export async function hashSftpFile(
sftp: SFTPWrapper,
path: string,
shouldAbort: () => boolean = () => false,
createAbortError: () => Error = () => new Error("Transfer cancelled"),
): Promise<string> {
const stream = sftp.createReadStream(path);
const hash = createHash("sha256");
try {
for await (const chunk of stream) {
if (shouldAbort()) {
const error = createAbortError();
stream.destroy(error);
throw error;
}
hash.update(chunk);
}
} finally {
stream.destroy();
}
if (shouldAbort()) throw createAbortError();
return hash.digest("hex");
}
export async function verifySftpFileIntegrity(
sourceSftp: SFTPWrapper,
destSftp: SFTPWrapper,
sourcePath: string,
destPath: string,
shouldAbort: () => boolean = () => false,
createAbortError: () => Error = () => new Error("Transfer cancelled"),
): Promise<TransferIntegrityResult> {
const [sourceDigest, destDigest] = await Promise.all([
hashSftpFile(sourceSftp, sourcePath, shouldAbort, createAbortError),
hashSftpFile(destSftp, destPath, shouldAbort, createAbortError),
]);
if (sourceDigest !== destDigest) {
throw new Error(`SHA-256 verification failed for ${sourcePath}`);
}
return { algorithm: "sha256", digest: sourceDigest };
}
@@ -1,205 +0,0 @@
import type { TransferPlatform } from "../transfer-paths.js";
import { deflateRawSync } from "node:zlib";
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
export interface TransferScanSummary {
fileCount: number;
totalBytes: number;
largestFileBytes: number;
/** Share of total bytes in likely incompressible file types (0–1). */
incompressibleRatio: number;
/** Share inferred from bounded content samples, when available. */
sampledIncompressibleRatio?: number;
}
const INCOMPRESSIBLE_EXT =
/\.(zip|gz|bz2|xz|7z|rar|tar|tgz|jpg|jpeg|png|gif|webp|mp3|mp4|mkv|avi|mov|wmv|iso|dmg|deb|rpm|pdf|db|sqlite|wasm|vmdk|qcow2)$/i;
const GB = 1024 * 1024 * 1024;
const MB = 1024 * 1024;
export function isLikelyIncompressiblePath(path: string): boolean {
return INCOMPRESSIBLE_EXT.test(path);
}
export function buildTransferScanSummary(
items: Array<{ sourcePath: string; size: number }>,
): TransferScanSummary {
let totalBytes = 0;
let largestFileBytes = 0;
let incompressibleBytes = 0;
for (const item of items) {
totalBytes += item.size;
largestFileBytes = Math.max(largestFileBytes, item.size);
if (isLikelyIncompressiblePath(item.sourcePath)) {
incompressibleBytes += item.size;
}
}
return {
fileCount: items.length,
totalBytes,
largestFileBytes,
incompressibleRatio: totalBytes > 0 ? incompressibleBytes / totalBytes : 0,
};
}
export function estimateIncompressibleSample(data: Buffer): boolean {
if (data.length === 0) return false;
return deflateRawSync(data, { level: 1 }).length / data.length >= 0.9;
}
function effectiveIncompressibleRatio(summary: TransferScanSummary): number {
return summary.sampledIncompressibleRatio ?? summary.incompressibleRatio;
}
/**
* Choose tar vs per-item SFTP for directory / multi-file transfers.
* Single-file stream transfers bypass this entirely.
*/
export function resolveArchiveTransferMethod(
preference: TransferMethodPreference,
summary: TransferScanSummary,
sourcePlatform: TransferPlatform,
destPlatform: TransferPlatform,
sourceHasTar: boolean,
destHasTar: boolean,
): "tar" | "item_sftp" {
if (sourcePlatform === "windows" || destPlatform === "windows") {
return "item_sftp";
}
if (preference === "item_sftp") {
return "item_sftp";
}
if (preference === "tar") {
return sourceHasTar && destHasTar ? "tar" : "item_sftp";
}
// auto
if (!sourceHasTar || !destHasTar) {
return "item_sftp";
}
const { fileCount, totalBytes, largestFileBytes } = summary;
const incompressibleRatio = effectiveIncompressibleRatio(summary);
if (fileCount === 0) {
return "item_sftp";
}
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
return "item_sftp";
}
// Multi-item sets with a large file: tar only when data is likely compressible
// (mostly video/zip → per-file SFTP is simpler and avoids pack/unpack time).
if (
fileCount > 1 &&
largestFileBytes >= 2 * GB &&
totalBytes >= 500 * MB &&
incompressibleRatio < 0.7
) {
return "tar";
}
if (fileCount === 1 && largestFileBytes >= 5 * GB) {
return "item_sftp";
}
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
return "item_sftp";
}
if (incompressibleRatio >= 0.85) {
return "item_sftp";
}
if (fileCount >= 100) {
return "tar";
}
if (fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) {
return "tar";
}
if (fileCount > 5 && incompressibleRatio < 0.7) {
return "tar";
}
return "item_sftp";
}
export type ArchiveTransferReasonKey =
| "user_item_sftp"
| "user_tar"
| "tar_unavailable"
| "windows_host"
| "auto_multi_large"
| "auto_single_large_in_archive"
| "auto_many_incompressible"
| "auto_many_files"
| "auto_default";
/** i18n key suffix under transfer.methodReason.* */
export function getArchiveTransferReasonKey(
preference: TransferMethodPreference,
resolvedMethod: "tar" | "item_sftp",
summary: TransferScanSummary,
sourcePlatform: TransferPlatform,
destPlatform: TransferPlatform,
sourceHasTar: boolean,
destHasTar: boolean,
): ArchiveTransferReasonKey {
if (preference === "item_sftp") return "user_item_sftp";
if (preference === "tar") {
return sourceHasTar && destHasTar ? "user_tar" : "tar_unavailable";
}
if (sourcePlatform === "windows" || destPlatform === "windows") {
return "windows_host";
}
if (!sourceHasTar || !destHasTar) {
return "tar_unavailable";
}
const { fileCount, totalBytes, largestFileBytes } = summary;
const incompressibleRatio = effectiveIncompressibleRatio(summary);
if (
fileCount > 1 &&
largestFileBytes >= 2 * GB &&
totalBytes >= 500 * MB &&
incompressibleRatio < 0.7
) {
return "auto_multi_large";
}
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
return "auto_single_large_in_archive";
}
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
return "auto_many_incompressible";
}
if (incompressibleRatio >= 0.85) {
return "auto_many_incompressible";
}
if (
fileCount >= 100 ||
(fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) ||
(fileCount > 5 && incompressibleRatio < 0.7)
) {
return "auto_many_files";
}
if (resolvedMethod === "tar") {
return "auto_many_files";
}
return "auto_default";
}
@@ -1,89 +0,0 @@
import { basename, joinPath } from "../transfer-paths.js";
import {
SFTP_OPEN_READ,
promisifySftpClose,
promisifySftpOpen,
promisifySftpReaddir,
promisifySftpStat,
} from "./sftp-promisify.js";
type SFTPWrapper = import("ssh2").SFTPWrapper;
export interface FileWorkItem {
sourcePath: string;
destPath: string;
mode: number;
size: number;
}
export async function collectFileWorkItems(
sftp: SFTPWrapper,
sourcePath: string,
destRoot: string,
destBaseName?: string,
): Promise<FileWorkItem[]> {
const stats = await promisifySftpStat(sftp, sourcePath);
const name = destBaseName ?? basename(sourcePath);
const destPath = joinPath(destRoot, name);
if (stats.isFile()) {
return [
{
sourcePath,
destPath,
mode: stats.mode & 0o7777,
size: stats.size,
},
];
}
if (!stats.isDirectory()) {
return [];
}
const items: FileWorkItem[] = [];
const walk = async (srcDir: string, dstDir: string) => {
const entries = await promisifySftpReaddir(sftp, srcDir);
for (const entry of entries) {
if (entry.filename === "." || entry.filename === "..") continue;
const srcChild = joinPath(srcDir, entry.filename);
const dstChild = joinPath(dstDir, entry.filename);
if (entry.attrs.isDirectory()) {
await walk(srcChild, dstChild);
} else if (entry.attrs.isFile()) {
items.push({
sourcePath: srcChild,
destPath: dstChild,
mode: entry.attrs.mode & 0o7777,
size: entry.attrs.size,
});
}
}
};
await walk(sourcePath, destPath);
return items;
}
export async function readSftpSample(
sftp: SFTPWrapper,
path: string,
fileSize: number,
): Promise<Buffer> {
const sampleSize = Math.min(64 * 1024, fileSize);
const position = Math.max(0, Math.floor((fileSize - sampleSize) / 2));
const handle = await promisifySftpOpen(sftp, path, SFTP_OPEN_READ, 0o666);
try {
const buffer = Buffer.alloc(sampleSize);
const bytesRead = await new Promise<number>((resolve, reject) => {
sftp.read(handle, buffer, 0, sampleSize, position, (err, count) => {
if (err) reject(err);
else resolve(count);
});
});
return buffer.subarray(0, bytesRead);
} finally {
await promisifySftpClose(sftp, handle).catch(() => {});
}
}
@@ -1,40 +0,0 @@
export const SFTP_XFER_SEGMENT_SIZE = 256 * 1024 * 1024;
export const DEFAULT_PARALLEL_SEGMENT_COUNT = 2;
export const MAX_PARALLEL_SEGMENT_COUNT = 8;
export interface SegmentCopyJob {
offset: number;
length: number;
segmentIndex: number;
}
export function clampParallelSegmentCount(value?: number): number {
const n = value ?? DEFAULT_PARALLEL_SEGMENT_COUNT;
return Math.max(1, Math.min(MAX_PARALLEL_SEGMENT_COUNT, Math.floor(n)));
}
export function buildSegmentCopyJobs(
fileSize: number,
initialOffset: number,
destResumeSize: number,
): SegmentCopyJob[] {
const jobs: SegmentCopyJob[] = [];
for (
let offset = initialOffset;
offset < fileSize;
offset += SFTP_XFER_SEGMENT_SIZE
) {
const length = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset);
const segmentIndex = Math.floor(offset / SFTP_XFER_SEGMENT_SIZE);
if (destResumeSize >= offset + length) {
continue;
}
const start = destResumeSize > offset ? destResumeSize : offset;
jobs.push({
offset: start,
length: offset + length - start,
segmentIndex,
});
}
return jobs;
}
@@ -1,74 +0,0 @@
import {
buildPathFromSegments,
joinPath,
normalizeSftpPath,
splitPathSegments,
} from "../transfer-paths.js";
import { isRootOnlyPath } from "./transfer-host-utils.js";
import {
promisifySftpMkdir,
promisifySftpReaddir,
promisifySftpRmdir,
promisifySftpStat,
promisifySftpUnlink,
} from "./sftp-promisify.js";
type SFTPWrapper = import("ssh2").SFTPWrapper;
export async function ensureDirectoryTreeSftp(
sftp: SFTPWrapper,
dirPath: string,
created: Set<string> = new Set(),
): Promise<void> {
const normalized = normalizeSftpPath(dirPath);
if (!normalized || isRootOnlyPath(normalized)) return;
const { root, segments } = splitPathSegments(normalized);
if (segments.length === 0) return;
for (let i = 0; i < segments.length; i++) {
const current = buildPathFromSegments(root, segments, i + 1);
if (created.has(current)) continue;
try {
await promisifySftpMkdir(sftp, current, 0o755);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== "EEXIST") {
try {
const stats = await promisifySftpStat(sftp, current);
if (!stats.isDirectory()) throw err;
} catch {
throw err;
}
}
}
created.add(current);
}
}
export async function deletePathSftp(
sftp: SFTPWrapper,
path: string,
): Promise<void> {
let stats: import("ssh2").Stats;
try {
stats = await promisifySftpStat(sftp, path);
} catch {
return;
}
if (stats.isDirectory()) {
const entries = await promisifySftpReaddir(sftp, path);
for (const entry of entries) {
if (entry.filename === "." || entry.filename === "..") continue;
await deletePathSftp(sftp, joinPath(path, entry.filename));
}
await promisifySftpRmdir(sftp, path);
return;
}
if (stats.isFile()) {
await promisifySftpUnlink(sftp, path);
}
}
@@ -1,157 +0,0 @@
import { performance } from "node:perf_hooks";
const TRANSFER_PROGRESS_INTERVAL_MS = 200;
export type TransferHopId =
"source_read" | "dest_sftp_write" | "dest_local_write";
export interface TransferHopMetrics {
id: TransferHopId;
bytes: number;
/** Wall-clock span from first I/O on this hop to last I/O complete. */
spanMs: number;
mbPerSec: number;
}
export interface TransferTimings {
prepareDestMs?: number;
compressMs?: number;
transferMs?: number;
extractMs?: number;
verifyMs?: number;
directBenchmarkMs?: number;
relayBenchmarkMs?: number;
sourceDeleteMs?: number;
totalMs?: number;
transferBytes?: number;
endToEndMbPerSec?: number;
hops?: TransferHopMetrics[];
}
export function elapsedMs(start: number): number {
return Date.now() - start;
}
export function computeTransferMbPerSec(
bytes: number,
ms: number,
): number | undefined {
if (ms <= 0 || bytes <= 0) return undefined;
return ((bytes / ms) * 1000) / (1024 * 1024);
}
export interface HopWallClock {
firstAt: number | null;
lastAt: number | null;
}
export function createHopWallClock(): HopWallClock {
return { firstAt: null, lastAt: null };
}
export function noteHopStart(
clock: HopWallClock,
t: number = performance.now(),
): void {
if (clock.firstAt === null) clock.firstAt = t;
}
export function noteHopEnd(
clock: HopWallClock,
t: number = performance.now(),
): void {
clock.lastAt = t;
}
export function hopSpanMs(clock: HopWallClock): number {
if (clock.firstAt === null || clock.lastAt === null) return 0;
return Math.max(0, clock.lastAt - clock.firstAt);
}
export function createThrottledProgress(onProgress?: (bytes: number) => void) {
let pending = 0;
let lastFlush = 0;
const flush = () => {
if (pending > 0) {
onProgress?.(pending);
pending = 0;
lastFlush = Date.now();
}
};
return {
add(bytes: number) {
pending += bytes;
const now = Date.now();
if (now - lastFlush >= TRANSFER_PROGRESS_INTERVAL_MS) {
flush();
}
},
flush,
};
}
export interface PipelinedXferStats {
bytes: number;
sourceReadSpanMs: number;
destWriteSpanMs: number;
destWriteKind: "sftp" | "local";
}
export function createEmptyXferStats(): PipelinedXferStats {
return {
bytes: 0,
sourceReadSpanMs: 0,
destWriteSpanMs: 0,
destWriteKind: "sftp",
};
}
export function mergeXferStats(
target: PipelinedXferStats,
source: PipelinedXferStats,
): void {
target.bytes += source.bytes;
target.sourceReadSpanMs += source.sourceReadSpanMs;
target.destWriteSpanMs += source.destWriteSpanMs;
target.destWriteKind = source.destWriteKind;
}
export function buildTransferHopTimings(
stats: PipelinedXferStats,
transferMs: number,
): Pick<TransferTimings, "transferBytes" | "endToEndMbPerSec" | "hops"> {
const hops: TransferHopMetrics[] = [];
const sourceRate = computeTransferMbPerSec(
stats.bytes,
stats.sourceReadSpanMs,
);
if (sourceRate !== undefined) {
hops.push({
id: "source_read",
bytes: stats.bytes,
spanMs: stats.sourceReadSpanMs,
mbPerSec: sourceRate,
});
}
const destHopId: TransferHopId =
stats.destWriteKind === "local" ? "dest_local_write" : "dest_sftp_write";
const destRate = computeTransferMbPerSec(stats.bytes, stats.destWriteSpanMs);
if (destRate !== undefined) {
hops.push({
id: destHopId,
bytes: stats.bytes,
spanMs: stats.destWriteSpanMs,
mbPerSec: destRate,
});
}
return {
transferBytes: stats.bytes,
endToEndMbPerSec: computeTransferMbPerSec(stats.bytes, transferMs),
hops,
};
}
@@ -1,387 +0,0 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
export interface TransferPerformanceProfile {
throughputBps: number;
failureRate: number;
samples: number;
preferredLanes: number;
pipelineConcurrency: number;
updatedAt: number;
}
export interface TransferTuning {
parallelSegmentCount: number;
pipelineConcurrency: number;
}
export interface DirectRouteProfile {
directMs: number;
relayMs: number;
failureRate: number;
benchmarkSamples: number;
outcomeSamples: number;
benchmarkedAt: number;
cooldownUntil?: number;
updatedAt: number;
}
const MB = 1024 * 1024;
const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_PROFILES = 128;
const STORE_VERSION = 1;
const STORE_FILENAME = "adaptive-transfer-profiles.json";
const profiles = new Map<string, TransferPerformanceProfile>();
const directRoutes = new Map<string, DirectRouteProfile>();
let loadedPath: string | undefined;
let loadPromise: Promise<void> | undefined;
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let persistPromise = Promise.resolve();
interface PersistedProfiles {
version: typeof STORE_VERSION;
profiles: Record<string, TransferPerformanceProfile>;
directRoutes: Record<string, DirectRouteProfile>;
}
function validDirectRoute(value: unknown): value is DirectRouteProfile {
if (!value || typeof value !== "object") return false;
const profile = value as Partial<DirectRouteProfile>;
return (
Number.isFinite(profile.directMs) &&
Number(profile.directMs) > 0 &&
Number.isFinite(profile.relayMs) &&
Number(profile.relayMs) > 0 &&
Number.isFinite(profile.failureRate) &&
Number(profile.failureRate) >= 0 &&
Number(profile.failureRate) <= 1 &&
Number.isFinite(profile.benchmarkSamples) &&
Number(profile.benchmarkSamples) > 0 &&
Number.isFinite(profile.outcomeSamples) &&
Number(profile.outcomeSamples) >= 0 &&
Number.isFinite(profile.benchmarkedAt) &&
(profile.cooldownUntil === undefined ||
Number.isFinite(profile.cooldownUntil)) &&
Number.isFinite(profile.updatedAt)
);
}
function storePath(): string {
const dataDir =
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
return path.join(dataDir, STORE_FILENAME);
}
function profileId(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
function validProfile(value: unknown): value is TransferPerformanceProfile {
if (!value || typeof value !== "object") return false;
const profile = value as Partial<TransferPerformanceProfile>;
return (
Number.isFinite(profile.throughputBps) &&
Number(profile.throughputBps) >= 0 &&
Number.isFinite(profile.failureRate) &&
Number(profile.failureRate) >= 0 &&
Number(profile.failureRate) <= 1 &&
Number.isFinite(profile.samples) &&
Number(profile.samples) > 0 &&
Number.isFinite(profile.preferredLanes) &&
Number.isFinite(profile.pipelineConcurrency) &&
Number.isFinite(profile.updatedAt)
);
}
function trimProfiles(now = Date.now()): void {
for (const [key, profile] of profiles) {
if (now - profile.updatedAt > PROFILE_TTL_MS) profiles.delete(key);
}
const recent = [...profiles.entries()]
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
.slice(0, MAX_PROFILES);
profiles.clear();
for (const [key, profile] of recent) profiles.set(key, profile);
for (const [key, profile] of directRoutes) {
if (now - profile.updatedAt > PROFILE_TTL_MS) directRoutes.delete(key);
}
const recentRoutes = [...directRoutes.entries()]
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
.slice(0, MAX_PROFILES);
directRoutes.clear();
for (const [key, profile] of recentRoutes) directRoutes.set(key, profile);
}
async function persistProfiles(): Promise<void> {
trimProfiles();
const target = storePath();
const temporary = `${target}.${process.pid}.tmp`;
const payload: PersistedProfiles = {
version: STORE_VERSION,
profiles: Object.fromEntries(profiles),
directRoutes: Object.fromEntries(directRoutes),
};
try {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(temporary, JSON.stringify(payload), {
encoding: "utf8",
mode: 0o600,
});
await fs.rename(temporary, target);
} catch {
await fs.rm(temporary, { force: true }).catch(() => {});
}
}
function queuePersist(): void {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
persistTimer = undefined;
persistPromise = persistPromise.then(persistProfiles);
}, 250);
persistTimer.unref?.();
}
export async function initializeTransferProfiles(): Promise<void> {
const target = storePath();
if (loadedPath === target) return loadPromise;
loadedPath = target;
loadPromise = (async () => {
profiles.clear();
directRoutes.clear();
try {
const parsed = JSON.parse(
await fs.readFile(target, "utf8"),
) as Partial<PersistedProfiles>;
if (parsed.version !== STORE_VERSION || !parsed.profiles) return;
for (const [key, profile] of Object.entries(parsed.profiles)) {
if (/^[a-f0-9]{64}$/.test(key) && validProfile(profile)) {
profiles.set(key, profile);
}
}
for (const [key, profile] of Object.entries(parsed.directRoutes ?? {})) {
if (/^[a-f0-9]{64}$/.test(key) && validDirectRoute(profile)) {
directRoutes.set(key, profile);
}
}
trimProfiles();
} catch {
// Missing or malformed local learning data must not affect transfers.
}
})();
return loadPromise;
}
export async function flushTransferProfiles(): Promise<void> {
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = undefined;
}
persistPromise = persistPromise.then(persistProfiles);
await persistPromise;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, Math.floor(value)));
}
export function selectTransferTuning(
fileSize: number,
profile?: TransferPerformanceProfile,
requestedLanes?: number,
): TransferTuning {
if (fileSize < 32 * MB) {
return { parallelSegmentCount: 1, pipelineConcurrency: 8 };
}
let lanes = fileSize >= 1024 * MB ? 4 : 2;
let pipelineConcurrency = fileSize >= 256 * MB ? 32 : 16;
if (profile) {
lanes = profile.preferredLanes;
pipelineConcurrency = profile.pipelineConcurrency;
if (profile.failureRate >= 0.2) {
lanes = Math.min(lanes, 2);
pipelineConcurrency = Math.min(pipelineConcurrency, 16);
}
}
if (requestedLanes !== undefined) lanes = requestedLanes;
const segmentCapacity = Math.max(1, Math.ceil(fileSize / (256 * MB)));
return {
parallelSegmentCount: clamp(lanes, 1, Math.min(8, segmentCapacity)),
pipelineConcurrency: clamp(pipelineConcurrency, 8, 64),
};
}
export function updateTransferProfile(
previous: TransferPerformanceProfile | undefined,
observation: {
bytes: number;
durationMs: number;
lanes: number;
pipelineConcurrency: number;
failed: boolean;
now?: number;
},
): TransferPerformanceProfile {
const now = observation.now ?? Date.now();
const throughputBps =
observation.durationMs > 0
? (observation.bytes * 1000) / observation.durationMs
: 0;
const weight = previous ? 0.25 : 1;
const smoothedThroughput = previous
? previous.throughputBps * (1 - weight) + throughputBps * weight
: throughputBps;
const failure = observation.failed ? 1 : 0;
const failureRate = previous
? previous.failureRate * (1 - weight) + failure * weight
: failure;
let preferredLanes = observation.lanes;
let pipelineConcurrency = observation.pipelineConcurrency;
if (failureRate >= 0.2) {
preferredLanes = Math.max(1, Math.floor(preferredLanes / 2));
pipelineConcurrency = Math.max(8, Math.floor(pipelineConcurrency / 2));
} else if (
!observation.failed &&
previous &&
previous.samples >= 2 &&
throughputBps > previous.throughputBps * 1.25
) {
preferredLanes = Math.min(8, preferredLanes + 1);
pipelineConcurrency = Math.min(64, pipelineConcurrency + 8);
}
return {
throughputBps: smoothedThroughput,
failureRate,
samples: (previous?.samples ?? 0) + 1,
preferredLanes,
pipelineConcurrency,
updatedAt: now,
};
}
export function getTransferProfile(
key: string,
now = Date.now(),
): TransferPerformanceProfile | undefined {
const id = profileId(key);
const profile = profiles.get(id);
if (!profile) return undefined;
if (now - profile.updatedAt <= PROFILE_TTL_MS) return profile;
profiles.delete(id);
queuePersist();
return undefined;
}
export function recordTransferProfile(
key: string,
observation: Parameters<typeof updateTransferProfile>[1],
): TransferPerformanceProfile {
const profile = updateTransferProfile(getTransferProfile(key), observation);
profiles.set(profileId(key), profile);
trimProfiles(observation.now);
queuePersist();
return profile;
}
export function getDirectRouteProfile(
key: string,
now = Date.now(),
): DirectRouteProfile | undefined {
const id = profileId(key);
const profile = directRoutes.get(id);
if (!profile) return undefined;
if (now - profile.updatedAt <= PROFILE_TTL_MS) return profile;
directRoutes.delete(id);
queuePersist();
return undefined;
}
export function recordDirectRouteBenchmark(
key: string,
directMs: number,
relayMs: number,
now = Date.now(),
): DirectRouteProfile {
const previous = getDirectRouteProfile(key, now);
const weight = previous ? 0.25 : 1;
const profile: DirectRouteProfile = {
directMs: previous
? previous.directMs * (1 - weight) + directMs * weight
: directMs,
relayMs: previous
? previous.relayMs * (1 - weight) + relayMs * weight
: relayMs,
failureRate: previous?.failureRate ?? 0,
benchmarkSamples: (previous?.benchmarkSamples ?? 0) + 1,
outcomeSamples: previous?.outcomeSamples ?? 0,
benchmarkedAt: now,
cooldownUntil: previous?.cooldownUntil,
updatedAt: now,
};
directRoutes.set(profileId(key), profile);
trimProfiles(now);
queuePersist();
return profile;
}
export function recordDirectRouteOutcome(
key: string,
failed: boolean,
now = Date.now(),
cooldownMs = 10 * 60 * 1000,
): DirectRouteProfile | undefined {
const previous = getDirectRouteProfile(key, now);
if (!previous) return undefined;
const failure = failed ? 1 : 0;
const weight = previous.outcomeSamples > 0 ? 0.25 : 1;
const profile = {
...previous,
failureRate: previous.failureRate * (1 - weight) + failure * weight,
outcomeSamples: previous.outcomeSamples + 1,
cooldownUntil: failed ? now + cooldownMs : undefined,
updatedAt: now,
};
directRoutes.set(profileId(key), profile);
queuePersist();
return profile;
}
export function getRecentDirectRouteDecision(
key: string,
maxAgeMs: number,
now = Date.now(),
): { useDirect: boolean; directMs: number; relayMs: number } | undefined {
const profile = getDirectRouteProfile(key, now);
if (!profile) return undefined;
if ((profile.cooldownUntil ?? 0) > now) {
return {
useDirect: false,
directMs: profile.directMs,
relayMs: profile.relayMs,
};
}
if (now - profile.benchmarkedAt > maxAgeMs) return undefined;
return {
useDirect:
profile.failureRate < 0.2 && profile.directMs <= profile.relayMs * 0.8,
directMs: profile.directMs,
relayMs: profile.relayMs,
};
}
export function clearTransferProfiles(): void {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = undefined;
loadedPath = undefined;
loadPromise = undefined;
profiles.clear();
directRoutes.clear();
}
@@ -1,234 +0,0 @@
import crypto from "node:crypto";
import path from "node:path";
import type { SFTPWrapper, Stats } from "ssh2";
export interface TrashItem {
id: string;
name: string;
originalPath: string;
isDirectory: boolean;
deletedAt: string;
size: number;
}
type StoredTrashItem = TrashItem & { trashPath: string };
const TRASH_DIR = ".termix-trash";
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
function call<T>(
run: (done: (error: Error | undefined, value: T) => void) => void,
) {
return new Promise<T>((resolve, reject) => {
run((error, value) => (error ? reject(error) : resolve(value)));
});
}
function stat(sftp: SFTPWrapper, target: string) {
return call<Stats>((done) => sftp.stat(target, done));
}
function lstat(sftp: SFTPWrapper, target: string) {
return call<Stats>((done) => sftp.lstat(target, done));
}
function readdir(sftp: SFTPWrapper, target: string) {
return call<import("ssh2").FileEntry[]>((done) => sftp.readdir(target, done));
}
function readFile(sftp: SFTPWrapper, target: string) {
return call<Buffer>((done) => sftp.readFile(target, done));
}
function writeFile(sftp: SFTPWrapper, target: string, data: string) {
return call<void>((done) => sftp.writeFile(target, data, done));
}
function rename(sftp: SFTPWrapper, from: string, to: string) {
return call<void>((done) => sftp.rename(from, to, done));
}
function unlink(sftp: SFTPWrapper, target: string) {
return call<void>((done) => sftp.unlink(target, done));
}
function mkdir(sftp: SFTPWrapper, target: string) {
return call<void>((done) => sftp.mkdir(target, done));
}
function rmdir(sftp: SFTPWrapper, target: string) {
return call<void>((done) => sftp.rmdir(target, done));
}
async function exists(sftp: SFTPWrapper, target: string) {
try {
await stat(sftp, target);
return true;
} catch {
return false;
}
}
async function ensureDirectory(sftp: SFTPWrapper, target: string) {
const normalized = target.replace(/\\/g, "/");
const root = normalized.startsWith("/") ? "/" : "";
const parts = normalized.split("/").filter(Boolean);
let current = root;
for (const part of parts) {
current =
current === "/" ? `/${part}` : current ? `${current}/${part}` : part;
if (await exists(sftp, current)) continue;
await mkdir(sftp, current);
}
}
async function removeTree(sftp: SFTPWrapper, target: string): Promise<void> {
const targetStat = await lstat(sftp, target);
if (!targetStat.isDirectory()) {
await unlink(sftp, target);
return;
}
for (const entry of await readdir(sftp, target)) {
if (entry.filename === "." || entry.filename === "..") continue;
await removeTree(sftp, path.posix.join(target, entry.filename));
}
await rmdir(sftp, target);
}
async function trashPaths(sftp: SFTPWrapper) {
const home = await call<string>((done) => sftp.realpath(".", done));
const root = path.posix.join(home.replace(/\\/g, "/"), TRASH_DIR);
const files = path.posix.join(root, "files");
const info = path.posix.join(root, "info");
await ensureDirectory(sftp, files);
await ensureDirectory(sftp, info);
return { root, files, info };
}
export function isSafeTrashSource(itemPath: string, trashRoot: string) {
const normalized = path.posix.normalize(itemPath.replace(/\\/g, "/"));
const root = path.posix.normalize(trashRoot);
return (
normalized !== "." &&
normalized !== "/" &&
!/^[A-Za-z]:\/?$/.test(normalized) &&
normalized !== root &&
!normalized.startsWith(`${root}/`)
);
}
function publicItem(item: StoredTrashItem): TrashItem {
const { trashPath: _trashPath, ...result } = item;
return result;
}
async function readStoredItem(
sftp: SFTPWrapper,
dirs: { root: string; files: string; info: string },
id: string,
): Promise<StoredTrashItem> {
if (!ID_PATTERN.test(id)) throw new Error("Invalid trash item id");
const parsed = JSON.parse(
(await readFile(sftp, path.posix.join(dirs.info, `${id}.json`))).toString(
"utf8",
),
) as StoredTrashItem;
if (
parsed.id !== id ||
parsed.trashPath !== path.posix.join(dirs.files, id) ||
!isSafeTrashSource(parsed.originalPath, dirs.root)
) {
throw new Error("Invalid trash metadata");
}
return parsed;
}
export async function moveToTrash(
sftp: SFTPWrapper,
itemPath: string,
): Promise<TrashItem> {
const dirs = await trashPaths(sftp);
if (!isSafeTrashSource(itemPath, dirs.root)) {
throw new Error("This path cannot be moved to trash");
}
const itemStat = await lstat(sftp, itemPath);
const id = crypto.randomUUID();
const trashPath = path.posix.join(dirs.files, id);
const item: StoredTrashItem = {
id,
name: path.posix.basename(itemPath.replace(/\\/g, "/")),
originalPath: itemPath,
trashPath,
isDirectory: itemStat.isDirectory(),
deletedAt: new Date().toISOString(),
size: itemStat.size,
};
await rename(sftp, itemPath, trashPath);
try {
await writeFile(
sftp,
path.posix.join(dirs.info, `${id}.json`),
JSON.stringify(item),
);
} catch (error) {
await rename(sftp, trashPath, itemPath).catch(() => {});
throw error;
}
return publicItem(item);
}
export async function listTrash(
sftp: SFTPWrapper,
retentionDays: number,
): Promise<TrashItem[]> {
const dirs = await trashPaths(sftp);
const cutoff = Date.now() - retentionDays * 86_400_000;
const items: TrashItem[] = [];
for (const entry of await readdir(sftp, dirs.info)) {
if (!entry.filename.endsWith(".json")) continue;
const id = entry.filename.slice(0, -5);
try {
const item = await readStoredItem(sftp, dirs, id);
if (new Date(item.deletedAt).getTime() < cutoff) {
if (await exists(sftp, item.trashPath))
await removeTree(sftp, item.trashPath);
await unlink(sftp, path.posix.join(dirs.info, entry.filename));
continue;
}
if (await exists(sftp, item.trashPath)) items.push(publicItem(item));
else await unlink(sftp, path.posix.join(dirs.info, entry.filename));
} catch {
// Ignore corrupt metadata without exposing arbitrary paths to deletion.
}
}
return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
}
export async function restoreTrashItem(sftp: SFTPWrapper, id: string) {
const dirs = await trashPaths(sftp);
const item = await readStoredItem(sftp, dirs, id);
if (await exists(sftp, item.originalPath)) {
throw new Error("A file already exists at the original path");
}
await rename(sftp, item.trashPath, item.originalPath);
await unlink(sftp, path.posix.join(dirs.info, `${id}.json`));
return publicItem(item);
}
export async function permanentlyDeleteTrashItem(
sftp: SFTPWrapper,
id: string,
) {
const dirs = await trashPaths(sftp);
const item = await readStoredItem(sftp, dirs, id);
if (await exists(sftp, item.trashPath))
await removeTree(sftp, item.trashPath);
await unlink(sftp, path.posix.join(dirs.info, `${id}.json`));
}
export async function emptyTrash(sftp: SFTPWrapper) {
const items = await listTrash(sftp, 365_000);
for (const item of items) await permanentlyDeleteTrashItem(sftp, item.id);
return items.length;
}
-164
View File
@@ -1,164 +0,0 @@
export function isExecutableFile(
permissions: string,
fileName: string,
): boolean {
const hasExecutePermission =
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
const scriptExtensions = [
".sh",
".py",
".pl",
".rb",
".js",
".php",
".bash",
".zsh",
".fish",
];
const hasScriptExtension = scriptExtensions.some((ext) =>
fileName.toLowerCase().endsWith(ext),
);
const executableExtensions = [".bin", ".exe", ".out"];
const hasExecutableExtension = executableExtensions.some((ext) =>
fileName.toLowerCase().endsWith(ext),
);
const hasNoExtension = !fileName.includes(".") && hasExecutePermission;
return (
hasExecutePermission &&
(hasScriptExtension || hasExecutableExtension || hasNoExtension)
);
}
export function modeToPermissions(mode: number): string {
const S_IFDIR = 0o040000;
const S_IFLNK = 0o120000;
const S_IFMT = 0o170000;
const type = mode & S_IFMT;
const prefix = type === S_IFDIR ? "d" : type === S_IFLNK ? "l" : "-";
const perms = [
mode & 0o400 ? "r" : "-",
mode & 0o200 ? "w" : "-",
mode & 0o100 ? "x" : "-",
mode & 0o040 ? "r" : "-",
mode & 0o020 ? "w" : "-",
mode & 0o010 ? "x" : "-",
mode & 0o004 ? "r" : "-",
mode & 0o002 ? "w" : "-",
mode & 0o001 ? "x" : "-",
].join("");
return prefix + perms;
}
export function parseLsDateToTimestamp(dateStr: string): number {
const parts = dateStr.trim().split(/\s+/);
if (parts.length < 3) return 0;
const [month, day, timeOrYear] = parts;
const monthIndex = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
].indexOf(month);
if (monthIndex === -1) return 0;
const dayNum = parseInt(day, 10);
const now = new Date();
if (timeOrYear.includes(":")) {
const [hours, minutes] = timeOrYear.split(":").map(Number);
let year = now.getFullYear();
const candidate = new Date(year, monthIndex, dayNum, hours, minutes);
if (candidate > now) year -= 1;
return new Date(year, monthIndex, dayNum, hours, minutes).getTime() / 1000;
}
const year = parseInt(timeOrYear, 10);
if (isNaN(year)) return 0;
return new Date(year, monthIndex, dayNum).getTime() / 1000;
}
export function formatMtime(mtime: number): string {
const date = new Date(mtime * 1000);
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const month = months[date.getMonth()];
const day = date.getDate().toString().padStart(2, " ");
const now = new Date();
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
if (date > sixMonthsAgo) {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${month} ${day} ${hours}:${minutes}`;
}
return `${month} ${day} ${date.getFullYear()}`;
}
export function getMimeType(fileName: string): string {
const ext = fileName.split(".").pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
txt: "text/plain",
json: "application/json",
js: "text/javascript",
html: "text/html",
css: "text/css",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
pdf: "application/pdf",
zip: "application/zip",
tar: "application/x-tar",
gz: "application/gzip",
};
return mimeTypes[ext || ""] || "application/octet-stream";
}
export function detectBinary(buffer: Buffer): boolean {
if (buffer.length === 0) return false;
const sampleSize = Math.min(buffer.length, 8192);
let nullBytes = 0;
for (let i = 0; i < sampleSize; i++) {
const byte = buffer[i];
if (byte === 0) {
nullBytes++;
}
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
if (++nullBytes > 1) return true;
}
}
return nullBytes / sampleSize > 0.01;
}
-7
View File
@@ -109,13 +109,6 @@ export async function resolveHostById(
host.jumpHosts = [];
}
}
if (typeof host.tunnelConnections === "string") {
try {
host.tunnelConnections = JSON.parse(host.tunnelConnections as string);
} catch {
host.tunnelConnections = [];
}
}
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
try {
host.terminalConfig = JSON.parse(host.terminalConfig as string);
@@ -68,6 +68,8 @@ export interface HostStatusDeps {
userId?: string;
hostIds?: number[];
}) => Promise<StatusTarget[]>;
/** Hosts other users shared with this one, directly or through a role. */
loadSharedHostIds?: (userId: string) => Promise<number[]>;
ping: (host: string, port: number) => Promise<boolean>;
pingThroughJumpHosts: (
target: StatusTarget,
@@ -110,6 +112,17 @@ const defaultDeps: HostStatusDeps = {
(
await createCurrentHostResolutionRepository().listStatusTargets(filter)
).map(toStatusTarget),
loadSharedHostIds: async (userId) => {
const { createCurrentRbacAccessRepository, createCurrentRoleRepository } =
await import("../../database/repositories/factory.js");
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const entries =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
return [...new Set(entries.map((entry) => entry.hostId))];
},
ping: (host, port) => tcpPing(host, port, 5000),
pingThroughJumpHosts: async (target, port) => {
const { createJumpHostChain } = await import("../jump-host-chain.js");
@@ -266,6 +279,35 @@ export class HostStatusService {
for (const target of targets) {
if (!this.polled.has(target.id)) this.startPolling(target);
}
await this.startShared(userId, null);
}
/**
* Hosts shared with the user are checked as their owners, even when no
* owner has asked for statuses since the server started.
*/
private async startShared(
userId: string,
allowed: Set<number> | null,
): Promise<void> {
if (!this.deps.loadSharedHostIds) return;
try {
const shared = (await this.deps.loadSharedHostIds(userId)).filter(
(hostId) =>
!this.polled.has(hostId) && (allowed === null || allowed.has(hostId)),
);
if (shared.length === 0) return;
const targets = await this.deps.loadTargets({ hostIds: shared });
for (const target of targets) {
if (!this.polled.has(target.id)) this.startPolling(target);
}
} catch (error) {
sshLogger.warn("Could not start status checks for shared hosts", {
operation: "host_status_shared",
userId,
error: error instanceof Error ? error.message : String(error),
});
}
}
private async reconcile(userId: string, allowed: Set<number>): Promise<void> {
@@ -278,6 +320,7 @@ export class HostStatusService {
}
if (!this.polled.has(target.id)) this.startPolling(target);
}
await this.startShared(userId, allowed);
}
private async reload(hostId: number): Promise<void> {
+72
View File
@@ -0,0 +1,72 @@
/**
* Boot copies of 2.8 data into plugin settings and plugin tables.
*
* They run after the plugins are seeded and activated: plugin_settings has a
* foreign key to plugins, and some copies write into tables a plugin adopts
* in its own migrations. Each one is idempotent, so they also run again when
* an admin enables a plugin without a restart.
*/
import { databaseLogger } from "../utils/logger.js";
import { getErrorMessage } from "../utils/error-message.js";
import { runTailscaleSettingsMigration } from "../utils/crypto-migration/tailscale-settings-migration.js";
import { runProxmoxSettingsMigration } from "../utils/crypto-migration/proxmox-settings-migration.js";
import { runFileManagerSettingsMigration } from "../utils/crypto-migration/file-manager-settings-migration.js";
import { runTunnelsSettingsMigration } from "../utils/crypto-migration/tunnels-settings-migration.js";
import { runWebEndpointSettingsMigration } from "../utils/crypto-migration/web-endpoint-settings-migration.js";
import { runSshTerminalSettingsMigration } from "../utils/crypto-migration/ssh-terminal-settings-migration.js";
import { runTmuxMonitorSettingsMigration } from "../utils/crypto-migration/tmux-monitor-settings-migration.js";
import { runSessionSharingSettingsMigration } from "../utils/crypto-migration/session-sharing-settings-migration.js";
import { runSessionRecordingSettingsMigration } from "../utils/crypto-migration/session-recording-settings-migration.js";
import { runRemoteDesktopSettingsMigration } from "../utils/crypto-migration/remote-desktop-settings-migration.js";
import { runHostMetricsSettingsMigration } from "../utils/crypto-migration/host-metrics-settings-migration.js";
import { runDockerSettingsMigration } from "../utils/crypto-migration/docker-settings-migration.js";
import { runAiSettingsMigration } from "../utils/crypto-migration/ai-settings-migration.js";
import { runWakeOnLanSettingsMigration } from "../utils/crypto-migration/wake-on-lan-settings-migration.js";
import { runWarpgateSettingsMigration } from "../utils/crypto-migration/warpgate-settings-migration.js";
import { runStepCaSettingsMigration } from "../utils/crypto-migration/step-ca-settings-migration.js";
import { runAcmeSslSettingsMigration } from "../utils/crypto-migration/acme-ssl-settings-migration.js";
import { runVaultSettingsMigration } from "../utils/crypto-migration/vault-settings-migration.js";
import { runSecretSourcesTokenMigration } from "../utils/crypto-migration/secret-sources-token-migration.js";
import { runTotpMigration } from "../utils/crypto-migration/totp-migration.js";
import { runTermixIdentityCaMigration } from "../utils/crypto-migration/termix-identity-ca-migration.js";
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
["runTailscaleSettingsMigration", runTailscaleSettingsMigration],
["runProxmoxSettingsMigration", runProxmoxSettingsMigration],
["runFileManagerSettingsMigration", runFileManagerSettingsMigration],
["runTunnelsSettingsMigration", runTunnelsSettingsMigration],
["runWebEndpointSettingsMigration", runWebEndpointSettingsMigration],
["runSshTerminalSettingsMigration", runSshTerminalSettingsMigration],
["runTmuxMonitorSettingsMigration", runTmuxMonitorSettingsMigration],
["runSessionSharingSettingsMigration", runSessionSharingSettingsMigration],
[
"runSessionRecordingSettingsMigration",
runSessionRecordingSettingsMigration,
],
["runRemoteDesktopSettingsMigration", runRemoteDesktopSettingsMigration],
["runHostMetricsSettingsMigration", runHostMetricsSettingsMigration],
["runDockerSettingsMigration", runDockerSettingsMigration],
["runAiSettingsMigration", runAiSettingsMigration],
["runWakeOnLanSettingsMigration", runWakeOnLanSettingsMigration],
["runWarpgateSettingsMigration", runWarpgateSettingsMigration],
["runStepCaSettingsMigration", runStepCaSettingsMigration],
["runAcmeSslSettingsMigration", runAcmeSslSettingsMigration],
["runVaultSettingsMigration", runVaultSettingsMigration],
["runSecretSourcesTokenMigration", runSecretSourcesTokenMigration],
["runTotpMigration", runTotpMigration],
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
];
export async function runPluginDataMigrations(): Promise<void> {
for (const [name, run] of MIGRATIONS) {
try {
await run();
} catch (error) {
databaseLogger.warn(`Plugin data migration ${name} failed`, {
operation: "plugin_data_migration",
error: getErrorMessage(error),
});
}
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Registrations that clashed with another plugin's: a service provider name,
* a registry key or a permission namespace two plugins both claimed. Each is
* refused or logged where it happens; this keeps the list so an admin view or
* a test can see them all.
*/
export interface RegistrationConflict {
kind: "service" | "registry" | "permission" | "ws";
pluginId: string;
/** Who already held it, when known. */
heldBy?: string;
name: string;
}
const conflicts: RegistrationConflict[] = [];
export function recordConflict(conflict: RegistrationConflict): void {
conflicts.push(conflict);
}
export function listRegistrationConflicts(): RegistrationConflict[] {
return [...conflicts];
}
/** Test seam. */
export function clearRegistrationConflicts(): void {
conflicts.length = 0;
}
-3
View File
@@ -82,9 +82,6 @@ function toRecord(host: Record<string, unknown>): PluginHostRecord {
folder: (host.folder as string | null) ?? null,
jumpHosts: host.jumpHosts,
enableSsh: (host.enableSsh as boolean | null) ?? null,
enableTerminal: (host.enableTerminal as boolean | null) ?? null,
enableFileManager: (host.enableFileManager as boolean | null) ?? null,
enableTunnel: (host.enableTunnel as boolean | null) ?? null,
createdAt: (host.createdAt as string | null) ?? null,
updatedAt: (host.updatedAt as string | null) ?? null,
...host,
+45
View File
@@ -434,7 +434,15 @@ export function createPluginContext(
pluginEvents.emit(topic, payload);
},
// Core topics (host.*, user.*) carry other users' data, so listening
// needs events:core too. Any plugin may listen to plugin.* topics.
on: (topic, listener) => {
if (!topic.startsWith("plugin.") && !declared.includes("events:core")) {
throw new Error(
`Plugin ${pluginId} may only listen to "plugin.*" topics. ` +
`Declare the events:core capability to listen to core topics.`,
);
}
const unsubscribe = pluginEvents.on(topic, listener);
handle.bag.add(unsubscribe, `event listener for "${topic}"`);
return unsubscribe;
@@ -766,6 +774,11 @@ export function createPluginContext(
if (error) throw new Error(error);
},
listHostValues: async (key) => {
await assertCapability(pluginId, "hosts:read", manifest.capabilities);
return pluginSettings.listHostValues(manifest, key) as never;
},
getAll: (scope, scopeId) =>
pluginSettings.getAllSettings(manifest, scope, scopeId ?? null),
@@ -779,6 +792,16 @@ export function createPluginContext(
return unsubscribe;
},
onValidate: (scope, validator) => {
const unsubscribe = pluginSettings.onSettingsValidate(
pluginId,
scope,
validator,
);
handle.bag.add(unsubscribe, `settings validator for ${scope}`);
return unsubscribe;
},
readCore: (key) => settingsReadCore(key),
},
@@ -849,7 +872,9 @@ export function createPluginContext(
try {
const { logAudit } = await import("../utils/audit-logger.js");
const actor = getActor() ?? "system";
const meta = requestMeta(entry.request);
await logAudit({
...meta,
userId: actor,
username: actor,
action: entry.action,
@@ -1012,3 +1037,23 @@ export async function disposePluginHandle(
}
}
}
/** IP address and user agent off an express request or upgrade request. */
function requestMeta(request: unknown): {
ipAddress?: string;
userAgent?: string;
} {
const req = request as
| {
ip?: string;
headers?: Record<string, unknown>;
socket?: { remoteAddress?: string };
}
| undefined;
if (!req || typeof req !== "object" || !req.headers) return {};
const userAgent = req.headers["user-agent"];
return {
ipAddress: req.ip || req.socket?.remoteAddress || undefined,
userAgent: typeof userAgent === "string" ? userAgent : undefined,
};
}
-12
View File
@@ -21,9 +21,6 @@ export interface PluginHostView {
folder: string | null;
tags: string[];
authType: string | null;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
createdAt: string | null;
updatedAt: string | null;
}
@@ -38,11 +35,6 @@ const STRING_FIELDS = [
"createdAt",
"updatedAt",
] as const;
const BOOLEAN_FIELDS = [
"enableTerminal",
"enableTunnel",
"enableFileManager",
] as const;
export function toPluginHostView(
host: Record<string, unknown>,
@@ -56,10 +48,6 @@ export function toPluginHostView(
const value = host[field];
view[field] = typeof value === "string" ? value : null;
}
for (const field of BOOLEAN_FIELDS) {
view[field] = Boolean(host[field]);
}
view.tags = Array.isArray(host.tags)
? host.tags.filter((tag): tag is string => typeof tag === "string")
: [];
+13 -1
View File
@@ -16,6 +16,7 @@ import type { PluginPermissionContribution } from "./manifest.js";
import { invalidatePluginPermissionCache } from "./permissions.js";
import { setSshAuthTypeOwnerSource } from "../hosts/connect/auth-provider-registry.js";
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
import { recordConflict } from "./conflicts.js";
let loader: PluginLoader | null = null;
@@ -258,8 +259,15 @@ async function registerPluginPermissions(plugin: LoadedPlugin): Promise<void> {
const accepted = declared.filter((permission) => {
const head = permission.name.split(".")[0];
if (coreGroups.has(head) || otherPluginIds.has(head)) {
pluginLogger.warn(
recordConflict({
kind: "permission",
pluginId: plugin.id,
heldBy: coreGroups.has(head) ? "core" : head,
name: permission.name,
});
pluginLogger.error(
`Ignoring ${plugin.id} permission "${permission.name}": it starts with "${head}", which belongs to someone else`,
undefined,
{ operation: "plugin_permissions" },
);
return false;
@@ -421,6 +429,10 @@ export async function activatePlugin(pluginId: string): Promise<void> {
if (plugin) await registerPluginPermissions(plugin);
await pluginLoader.activate(pluginId);
// A plugin enabled without a restart still needs its 2.8 data copied.
const { runPluginDataMigrations } = await import("./boot-migrations.js");
await runPluginDataMigrations();
}
export async function deactivatePlugin(pluginId: string): Promise<void> {
+14
View File
@@ -37,6 +37,7 @@
import semver from "semver";
import { pluginLogger } from "../utils/logger.js";
import { recordConflict } from "./conflicts.js";
import { runAsActor } from "./actor.js";
import type { PluginManifest, PluginServiceRequire } from "./manifest.js";
@@ -135,6 +136,19 @@ export function provideService(
): ServiceRegistration {
const name = registration.name ?? "";
const existing = lookup(registration.service, name);
// Two plugins cannot answer to the same service and provider name. The
// same plugin replacing its own registration (a restart) is fine.
if (existing && existing.pluginId !== registration.pluginId) {
recordConflict({
kind: "service",
pluginId: registration.pluginId,
heldBy: existing.pluginId,
name: `${registration.service}${name ? `:${name}` : ""}`,
});
throw new Error(
`Service "${registration.service}"${name ? ` (${name})` : ""} is already provided by ${existing.pluginId}`,
);
}
if (existing) {
pluginLogger.warn(
`Service "${registration.service}"${name ? ` (${name})` : ""} is already provided by ${existing.pluginId} and is being replaced by ${registration.pluginId}`,
+97 -2
View File
@@ -20,7 +20,10 @@ import {
isRedactedSecret,
validateSettingValue,
} from "@termix/plugin-sdk/settings";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
import {
createCurrentHostRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
import type { PluginSettingsScope } from "../database/repositories/plugin-settings-repository.js";
import {
decryptSystemSecret,
@@ -87,9 +90,67 @@ function notify(pluginId: string, key: string, value: unknown): void {
}
}
export type SettingsValidator = (
values: Record<string, unknown>,
context: { hostId?: number },
) =>
| Record<string, string>
| void
| undefined
| Promise<Record<string, string> | void | undefined>;
const validators = new Map<
string,
Set<{ scope: PluginSettingsScope; run: SettingsValidator }>
>();
/** A plugin's own check on a settings save, before anything is written. */
export function onSettingsValidate(
pluginId: string,
scope: PluginSettingsScope,
run: SettingsValidator,
): () => void {
let set = validators.get(pluginId);
if (!set) {
set = new Set();
validators.set(pluginId, set);
}
const entry = { scope, run };
set.add(entry);
return () => {
set!.delete(entry);
};
}
/**
* Runs the plugin's validators over a save. Returns field key to message;
* a validator that throws rejects the whole save under "_".
*/
export async function validateSettingsSave(
pluginId: string,
scope: PluginSettingsScope,
scopeId: string | null,
values: Record<string, unknown>,
): Promise<Record<string, string>> {
const errors: Record<string, string> = {};
for (const entry of validators.get(pluginId) ?? []) {
if (entry.scope !== scope) continue;
try {
const result = await entry.run(values, {
hostId: scope === "host" && scopeId ? Number(scopeId) : undefined,
});
Object.assign(errors, result ?? {});
} catch (error) {
errors._ = error instanceof Error ? error.message : String(error);
}
}
return errors;
}
/** Drops every listener a plugin registered. Called when it deactivates. */
export function clearSettingsListeners(pluginId: string): void {
listeners.delete(pluginId);
validators.delete(pluginId);
}
/** The fields a manifest declares for one scope. */
@@ -114,7 +175,7 @@ export function declaredFields(
key: host.enableKey,
type: "boolean",
labelKey: host.enableLabelKey,
default: false,
default: host.enableDefault ?? false,
},
]
: [];
@@ -241,6 +302,40 @@ export async function setSetting(
return null;
}
/** Every stored value of one host field, with each host's owner. */
export async function listHostValues(
manifest: PluginManifest,
key: string,
): Promise<Array<{ hostId: number; userId: string; value: unknown }>> {
const field = findField(manifest, "host", key);
if (!field) {
throw new Error(
`"${key}" is not a host settings field this plugin declares`,
);
}
if (field.type === "secret") {
throw new Error(`"${key}" is a secret and cannot be listed`);
}
const rows = await createCurrentPluginSettingsRepository().listByKey(
manifest.id,
"host",
key,
);
const hosts = createCurrentHostRepository();
const result: Array<{ hostId: number; userId: string; value: unknown }> = [];
for (const row of rows) {
const hostId = Number(row.scopeId);
if (!Number.isInteger(hostId) || row.value === null) continue;
const host = await hosts.findById(hostId);
if (!host?.userId) continue;
const value = decode(row.value);
if (value === undefined) continue;
result.push({ hostId, userId: host.userId as string, value });
}
return result;
}
export interface GetAllOptions {
/** Replaces every secret with { set } so the value never leaves the server. */
redactSecrets?: boolean;
+13
View File
@@ -27,6 +27,7 @@ import type {
PluginWebSocketOptions,
} from "@termix/plugin-sdk/backend";
import { pluginLogger } from "../utils/logger.js";
import { recordConflict } from "./conflicts.js";
import { extractWebSocketToken } from "../utils/ws-auth.js";
import { runAsActor } from "./actor.js";
@@ -245,6 +246,12 @@ export function registerPluginWsRoute(
): () => void {
const routeKey = key(pluginId, path);
const normalized = normalizePath(path);
if (routes.has(routeKey)) {
recordConflict({ kind: "ws", pluginId, name: normalized });
throw new Error(
`/plugin-ws/${pluginId}${normalized} is already registered`,
);
}
routes.set(routeKey, {
pluginId,
@@ -271,6 +278,12 @@ export function registerPluginWsUpgrade(
): () => void {
const routeKey = key(pluginId, path);
const normalized = normalizePath(path);
if (routes.has(routeKey)) {
recordConflict({ kind: "ws", pluginId, name: normalized });
throw new Error(
`/plugin-ws/${pluginId}${normalized} is already registered`,
);
}
routes.set(routeKey, {
pluginId,
-102
View File
@@ -1,102 +0,0 @@
#!/bin/bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
ENV_FILE="$PROJECT_ROOT/.env"
log_info() {
echo -e "${BLUE}[SSL Setup]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SSL Setup]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[SSL Setup]${NC} $1"
}
log_error() {
echo -e "${RED}[SSL Setup]${NC} $1"
}
log_header() {
echo -e "${CYAN}$1${NC}"
}
generate_keys() {
log_info "Generating security keys..."
JWT_SECRET=$(openssl rand -hex 32)
log_success "Generated JWT secret"
DATABASE_KEY=$(openssl rand -hex 32)
log_success "Generated database encryption key"
echo "JWT_SECRET=$JWT_SECRET" >> "$ENV_FILE"
echo "DATABASE_KEY=$DATABASE_KEY" >> "$ENV_FILE"
log_success "Security keys added to .env file"
}
setup_env_file() {
log_info "Setting up environment configuration..."
if [[ -f "$ENV_FILE" ]]; then
log_warn ".env file already exists, creating backup..."
cp "$ENV_FILE" "$ENV_FILE.backup.$(date +%s)"
fi
cat > "$ENV_FILE" << EOF
# Termix SSL Configuration - Auto-generated $(date)
# SSL/TLS Configuration
ENABLE_SSL=true
SSL_PORT=8443
SSL_DOMAIN=localhost
PORT=8080
# Node environment
NODE_ENV=production
# CORS configuration
ALLOWED_ORIGINS=*
EOF
generate_keys
log_success "Environment configuration created at $ENV_FILE"
}
setup_ssl_certificates() {
log_info "Setting up SSL certificates..."
if [[ -f "$SCRIPT_DIR/setup-ssl.sh" ]]; then
bash "$SCRIPT_DIR/setup-ssl.sh"
else
log_error "SSL setup script not found at $SCRIPT_DIR/setup-ssl.sh"
exit 1
fi
}
main() {
if ! command -v openssl &> /dev/null; then
log_error "OpenSSL is not installed. Please install OpenSSL first."
exit 1
fi
setup_env_file
setup_ssl_certificates
}
main "$@"
-121
View File
@@ -1,121 +0,0 @@
#!/bin/bash
set -e
SSL_DIR="$(dirname "$0")/../ssl"
CERT_FILE="$SSL_DIR/termix.crt"
KEY_FILE="$SSL_DIR/termix.key"
DAYS_VALID=365
DOMAIN=${SSL_DOMAIN:-"localhost"}
ALT_NAMES=${SSL_ALT_NAMES:-"DNS:localhost,DNS:127.0.0.1,DNS:*.localhost,IP:127.0.0.1"}
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() {
echo -e "${BLUE}[SSL Setup]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SSL Setup]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[SSL Setup]${NC} $1"
}
log_error() {
echo -e "${RED}[SSL Setup]${NC} $1"
}
check_existing_cert() {
if [[ -f "$CERT_FILE" && -f "$KEY_FILE" ]]; then
if openssl x509 -in "$CERT_FILE" -checkend 2592000 -noout 2>/dev/null; then
log_success "Valid SSL certificate already exists"
local expiry=$(openssl x509 -in "$CERT_FILE" -noout -enddate 2>/dev/null | cut -d= -f2)
log_info "Expires: $expiry"
return 0
else
log_warn "Existing certificate is expired or expiring soon"
fi
fi
return 1
}
generate_certificate() {
log_info "Generating new SSL certificate for domain: $DOMAIN"
mkdir -p "$SSL_DIR"
local config_file="$SSL_DIR/openssl.conf"
cat > "$config_file" << EOF
[req]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = v3_req
[dn]
C=US
ST=State
L=City
O=Termix
OU=IT Department
CN=$DOMAIN
[v3_req]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
DNS.2 = 127.0.0.1
DNS.3 = *.localhost
IP.1 = 127.0.0.1
EOF
if [[ -n "$SSL_ALT_NAMES" ]]; then
local counter=2
IFS=',' read -ra NAMES <<< "$SSL_ALT_NAMES"
for name in "${NAMES[@]}"; do
name=$(echo "$name" | xargs)
if [[ "$name" == DNS:* ]]; then
echo "DNS.$((counter++)) = ${name#DNS:}" >> "$config_file"
elif [[ "$name" == IP:* ]]; then
echo "IP.$((counter++)) = ${name#IP:}" >> "$config_file"
fi
done
fi
log_info "Generating private key..."
openssl genrsa -out "$KEY_FILE" 2048
log_info "Generating certificate..."
openssl req -new -x509 -key "$KEY_FILE" -out "$CERT_FILE" -days $DAYS_VALID -config "$config_file" -extensions v3_req
chmod 600 "$KEY_FILE"
chmod 644 "$CERT_FILE"
rm -f "$config_file"
log_success "SSL certificate generated successfully"
log_info "Valid for: $DAYS_VALID days"
}
main() {
if ! command -v openssl &> /dev/null; then
log_error "OpenSSL is not installed. Please install OpenSSL first."
exit 1
fi
generate_certificate
}
main "$@"
+5 -127
View File
@@ -4,8 +4,6 @@ import { promises as fs, readFileSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { AutoSSLSetup } from "./tls/self-signed.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";
@@ -221,44 +219,8 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
});
}
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 { runExternalIdentityMigration } =
await import("./utils/crypto-migration/external-identity-migration.js");
await runExternalIdentityMigration();
const { runHostStatusConfigMigration } =
await import("./utils/crypto-migration/host-status-config-migration.js");
await runHostStatusConfigMigration();
const { runCoreBootMigrations } = await import("./boot.js");
await runCoreBootMigrations();
const { hostStatusService } =
await import("./hosts/status/host-status-service.js");
@@ -315,93 +277,9 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
});
}
// After seeding: plugin_settings has a foreign key to plugins, so there
// is nothing to migrate into until the plugin row exists.
const { runTailscaleSettingsMigration } =
await import("./utils/crypto-migration/tailscale-settings-migration.js");
await runTailscaleSettingsMigration();
const { runProxmoxSettingsMigration } =
await import("./utils/crypto-migration/proxmox-settings-migration.js");
await runProxmoxSettingsMigration();
const { runFileManagerSettingsMigration } =
await import("./utils/crypto-migration/file-manager-settings-migration.js");
await runFileManagerSettingsMigration();
const { runTunnelsSettingsMigration } =
await import("./utils/crypto-migration/tunnels-settings-migration.js");
await runTunnelsSettingsMigration();
const { runWebEndpointSettingsMigration } =
await import("./utils/crypto-migration/web-endpoint-settings-migration.js");
await runWebEndpointSettingsMigration();
const { runSshTerminalSettingsMigration } =
await import("./utils/crypto-migration/ssh-terminal-settings-migration.js");
await runSshTerminalSettingsMigration();
const { runTmuxMonitorSettingsMigration } =
await import("./utils/crypto-migration/tmux-monitor-settings-migration.js");
await runTmuxMonitorSettingsMigration();
const { runSessionSharingSettingsMigration } =
await import("./utils/crypto-migration/session-sharing-settings-migration.js");
await runSessionSharingSettingsMigration();
const { runSessionRecordingSettingsMigration } =
await import("./utils/crypto-migration/session-recording-settings-migration.js");
await runSessionRecordingSettingsMigration();
const { runRemoteDesktopSettingsMigration } =
await import("./utils/crypto-migration/remote-desktop-settings-migration.js");
await runRemoteDesktopSettingsMigration();
const { runHostMetricsSettingsMigration } =
await import("./utils/crypto-migration/host-metrics-settings-migration.js");
await runHostMetricsSettingsMigration();
const { runDockerSettingsMigration } =
await import("./utils/crypto-migration/docker-settings-migration.js");
await runDockerSettingsMigration();
const { runAiSettingsMigration } =
await import("./utils/crypto-migration/ai-settings-migration.js");
await runAiSettingsMigration();
const { runWakeOnLanSettingsMigration } =
await import("./utils/crypto-migration/wake-on-lan-settings-migration.js");
await runWakeOnLanSettingsMigration();
const { runWarpgateSettingsMigration } =
await import("./utils/crypto-migration/warpgate-settings-migration.js");
await runWarpgateSettingsMigration();
const { runStepCaSettingsMigration } =
await import("./utils/crypto-migration/step-ca-settings-migration.js");
await runStepCaSettingsMigration();
const { runAcmeSslSettingsMigration } =
await import("./utils/crypto-migration/acme-ssl-settings-migration.js");
await runAcmeSslSettingsMigration();
const { runVaultSettingsMigration } =
await import("./utils/crypto-migration/vault-settings-migration.js");
await runVaultSettingsMigration();
const { runSecretSourcesTokenMigration } =
await import("./utils/crypto-migration/secret-sources-token-migration.js");
await runSecretSourcesTokenMigration();
const { runTotpMigration } =
await import("./utils/crypto-migration/totp-migration.js");
await runTotpMigration();
// Also after the plugin moves, for a CA whose owner key was not open
// when the table was adopted.
const { runTermixIdentityCaMigration } =
await import("./utils/crypto-migration/termix-identity-ca-migration.js");
await runTermixIdentityCaMigration();
const { runPluginDataMigrations } =
await import("./plugins/boot-migrations.js");
await runPluginDataMigrations();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -48,14 +48,17 @@ describe("the encrypted-export ssh_data DDL agrees with its INSERT", () => {
expect(missing).toEqual([]);
});
it("carries the web endpoint columns in both halves", () => {
// Named explicitly rather than derived: the generic check above passes if
// BOTH halves omit a column, which is precisely how this feature's columns
// would go missing.
for (const column of ["enable_web_ui", "web_ui_config"]) {
expect(createBody).toContain(column);
expect(insertMatch?.[1]).toContain(column);
it("leaves plugin host settings to their own table", () => {
// Moved columns must not creep back: plugins own these values now.
for (const column of [
"enable_web_ui",
"web_ui_config",
"enable_terminal",
]) {
expect(createBody).not.toContain(column);
expect(insertMatch?.[1]).not.toContain(column);
}
expect(text).toContain("CREATE TABLE plugin_settings (");
});
it("binds exactly one placeholder per column", () => {
@@ -76,8 +79,8 @@ describe("the encrypted-export ssh_data DDL agrees with its INSERT", () => {
expect((values ?? "").split("?").length - 1).toBe(columns.length);
});
it("carries the web endpoint values in the bound parameter list", () => {
expect(text).toContain("decrypted.enableWebUi");
expect(text).toContain("decrypted.webUiConfig");
it("copies non-secret host plugin settings into the export", () => {
expect(text).toContain("INSERT INTO plugin_settings");
expect(text).toContain("if (row.encrypted) continue;");
});
});
@@ -243,29 +243,6 @@ describe("HostResolutionRepository", () => {
);
});
it("lists tunnel-enabled hosts with tunnel data through each owner decryption boundary", async () => {
vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) =>
Buffer.from(`${userId}-key`),
);
const repository = await createRepository();
const rows = await repository.listHostsWithTunnelConnections();
expect(rows.map((row) => row.id)).toEqual([1, 3]);
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"ssh_data",
expect.objectContaining({ id: 1 }),
"user-1",
Buffer.from("user-1-key"),
);
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"ssh_data",
expect.objectContaining({ id: 3 }),
"user-2",
Buffer.from("user-2-key"),
);
});
it("skips owner-scoped host list rows when that user's data is locked", async () => {
vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) =>
userId === "user-1" ? Buffer.from("user-1-key") : null,
@@ -98,7 +98,6 @@ describe("applyHostEnrollmentDefaults", () => {
ip: "server.example",
port: 22,
authType: "none",
enableTerminal: true,
enableSsh: true,
});
});
@@ -109,12 +108,12 @@ describe("applyHostEnrollmentDefaults", () => {
ip: "server.example",
port: 2222,
authType: "password",
enableTerminal: false,
enableSsh: false,
}),
).toMatchObject({
port: 2222,
authType: "password",
enableTerminal: false,
enableSsh: false,
});
});
});
@@ -5,6 +5,11 @@ const mocks = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
list: vi.fn(),
applyPluginSettings: vi.fn(),
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
applyPluginHostImportSettings: mocks.applyPluginSettings,
setHostPluginEnabled: vi.fn(),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentCredentialRepository: () => ({
@@ -37,8 +42,12 @@ const host = {
port: 22,
username: "alice",
authType: "none",
enableCommandHistory: false,
enableTerminalToolbar: false,
pluginSettings: {
"ssh-terminal": {
enableCommandHistory: false,
enableTerminalToolbar: false,
},
},
terminalConfig: { macOptionIsMeta: false },
};
beforeEach(() => {
@@ -49,7 +58,7 @@ beforeEach(() => {
});
it.each([false, true])(
"preserves disabled terminal settings through export selection and import (overwrite=%s)",
"carries plugin host settings through export selection and import (overwrite=%s)",
async (overwrite) => {
const payload = buildExportPayload(
{ hosts: [host] },
@@ -71,10 +80,10 @@ it.each([false, true])(
const write = overwrite ? mocks.update : mocks.create;
expect(write).toHaveBeenCalledTimes(1);
const saved = write.mock.calls[0].at(-1);
expect(saved).toMatchObject({
enableCommandHistory: false,
enableTerminalToolbar: false,
});
expect(mocks.applyPluginSettings).toHaveBeenCalledWith(
overwrite ? 19 : 71,
expect.objectContaining({ pluginSettings: host.pluginSettings }),
);
expect(JSON.parse(saved.terminalConfig)).toEqual({
macOptionIsMeta: false,
});
@@ -291,28 +291,24 @@ describe("stripSensitiveFields", () => {
});
describe("transformHostResponse", () => {
it("parses tags and coerces enable flags to booleans", () => {
it("parses tags and coerces flags to booleans", () => {
const result = transformHostResponse({
tags: "a,b,c",
enableTerminal: 1,
enableTunnel: 0,
shareSshAuth: 1,
pin: 1,
});
expect(result.tags).toEqual(["a", "b", "c"]);
expect(result.enableTerminal).toBe(true);
expect(result.enableTunnel).toBe(false);
expect(result.shareSshAuth).toBe(true);
expect(result.pin).toBe(true);
});
it("parses JSON array fields and defaults them to []", () => {
const result = transformHostResponse({
tunnelConnections: '[{"sourcePort":8080}]',
jumpHosts: null,
jumpHosts: '[{"hostId":8}]',
quickActions: null,
});
expect(result.tunnelConnections).toEqual([{ sourcePort: 8080 }]);
expect(result.jumpHosts).toEqual([]);
expect(result.jumpHosts).toEqual([{ hostId: 8 }]);
expect(result.quickActions).toEqual([]);
});
it("passes the stored SSH switch through", () => {
@@ -62,6 +62,10 @@ vi.mock("../../../utils/system-secret-crypto.js", () => ({
decryptSystemSecret: async (stored: string) => stored,
}));
vi.mock("../../../utils/crypto-migration/raw-rows.js", () => ({
runStatement: vi.fn(async () => {}),
}));
const registryProviders = new Map<string, unknown>();
vi.mock("../../../plugins/registry.js", () => ({
consume: (key: string) => registryProviders.get(key),
@@ -73,6 +77,7 @@ const {
withHostPluginSettings,
writeHostPluginSettings,
applyPluginHostImportSettings,
setHostPluginEnabled,
} = await import("../../../database/routes/host-plugin-settings.js");
function manifest(id: string, host: unknown): PluginManifest {
@@ -342,3 +347,88 @@ describe("applyPluginHostImportSettings", () => {
]);
});
});
describe("share levels, legacy fields and generic writes", () => {
const DESKTOP = manifest("remote-desktop", {
fields: [
{ key: "enableRdp", type: "boolean", labelKey: "k" },
{
key: "guacamoleConfig",
type: "json",
labelKey: "k",
shareRead: "edit",
},
{ key: "gatewayToken", type: "secret", labelKey: "k" },
],
});
it("hides a field above a recipient's share level", () => {
loaded.push({ id: "remote-desktop", manifest: DESKTOP, state: "active" });
const values = new Map([
[
1,
{
"remote-desktop": { enableRdp: true, guacamoleConfig: { a: 1 } },
},
],
]);
const connect = { id: 1, isShared: true, permissionLevel: "connect" };
const edit = { id: 1, isShared: true, permissionLevel: "edit" };
const owner = { id: 1 };
attachHostPluginSettings([connect, edit, owner], values);
expect(connect).toMatchObject({
pluginSettings: { "remote-desktop": { enableRdp: true } },
});
expect(
(connect as { pluginSettings: Record<string, Record<string, unknown>> })
.pluginSettings["remote-desktop"],
).not.toHaveProperty("guacamoleConfig");
expect(edit).toMatchObject({
pluginSettings: { "remote-desktop": { guacamoleConfig: { a: 1 } } },
});
expect(owner).toMatchObject({
pluginSettings: { "remote-desktop": { guacamoleConfig: { a: 1 } } },
});
});
it("adds a plugin's legacy payload fields without overwriting core's", () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
registryProviders.set(
"docker.hostPayloadLegacy",
(own: Record<string, unknown>) => ({
enableDocker: own.enableDocker,
name: "overwritten?",
}),
);
const host: Record<string, unknown> = { id: 2, name: "web" };
attachHostPluginSettings(
[host],
new Map([[2, { docker: { enableDocker: true } }]]),
);
expect(host.enableDocker).toBe(true);
expect(host.name).toBe("web");
});
it("imports an export's plugin values, leaving secrets out", async () => {
loaded.push({ id: "remote-desktop", manifest: DESKTOP, state: "active" });
await applyPluginHostImportSettings(9, {
pluginSettings: {
"remote-desktop": { enableRdp: true, gatewayToken: { set: true } },
},
});
expect(setCalls).toEqual([
["remote-desktop", "host", "9", "enableRdp", "true"],
]);
});
it("flips a plugin's host switch for many hosts", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
expect(await setHostPluginEnabled("docker", [3, 4], true)).toBe(true);
expect(setCalls.map((call) => [call[2], call[3], call[4]])).toEqual([
["3", "enableDocker", "true"],
["4", "enableDocker", "true"],
]);
expect(await setHostPluginEnabled("missing", [3], true)).toBe(false);
});
});
@@ -0,0 +1,69 @@
/**
* POST /notification-channels/:id/test sends through deliverNotification,
* the same path an automation's notify step takes.
*/
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
deliver: vi.fn(),
channel: null as null | { id: number; type: string; config: string },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
req.userId = "user-1";
next();
},
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentNotificationChannelRepository: () => ({
findNotificationChannelForUser: async () => h.channel,
}),
}));
vi.mock("../../../utils/notification-sender.js", () => ({
deliverNotification: h.deliver,
}));
const { default: router } =
await import("../../../database/routes/notification-channels-routes.js");
const app = express().use(express.json()).use(router);
beforeEach(() => {
h.deliver.mockReset();
h.channel = { id: 3, type: "ntfy", config: '{"url":"https://ntfy.sh"}' };
});
describe("POST /notification-channels/:id/test", () => {
it("delivers a test notification to the channel", async () => {
h.deliver.mockResolvedValue(undefined);
const response = await request(app).post("/notification-channels/3/test");
expect(response.body).toEqual({ success: true });
expect(h.deliver).toHaveBeenCalledWith(
h.channel,
expect.objectContaining({ severity: "info" }),
);
});
it("reports the delivery error", async () => {
h.deliver.mockRejectedValue(new Error("ntfy channel is missing a topic"));
const response = await request(app).post("/notification-channels/3/test");
expect(response.body).toEqual({
success: false,
error: "ntfy channel is missing a topic",
});
});
it("answers 404 for a channel the user does not own", async () => {
h.channel = null;
const response = await request(app).post("/notification-channels/9/test");
expect(response.status).toBe(404);
});
});
@@ -0,0 +1,60 @@
/**
* contributes.http.legacyPaths: an old URL under "/<plugin id>/" reaches the
* plugin's router with that prefix removed, and nothing else is caught.
*/
import express from "express";
import request from "supertest";
import { describe, expect, it, vi } from "vitest";
const routers = vi.hoisted(() => new Map<string, express.Router>());
vi.mock("../../../plugins/http.js", () => ({
getPluginRouter: (id: string) => routers.get(id),
}));
const { mountPluginLegacyPaths } =
await import("../../../database/routes/plugin-api-routes.js");
function appWith(active: Array<{ id: string; legacyPaths: string[] }>) {
const app = express();
app.get("/core/route", (_req, res) => res.json({ from: "core" }));
mountPluginLegacyPaths(app, () => active);
app.use((_req, res) => res.status(404).json({ from: "fallthrough" }));
return app;
}
describe("mountPluginLegacyPaths", () => {
const router = express.Router();
router.post("/webhook/:token", (req, res) =>
res.json({ token: req.params.token }),
);
routers.set("automations", router);
it("serves an old URL through the plugin's router", async () => {
const app = appWith([
{ id: "automations", legacyPaths: ["/automations/webhook"] },
]);
const response = await request(app).post("/automations/webhook/abc123");
expect(response.status).toBe(200);
expect(response.body).toEqual({ token: "abc123" });
});
it("leaves paths outside the declared prefixes alone", async () => {
const app = appWith([
{ id: "automations", legacyPaths: ["/automations/webhook"] },
]);
expect((await request(app).get("/automations/other")).body).toEqual({
from: "fallthrough",
});
expect((await request(app).get("/core/route")).body).toEqual({
from: "core",
});
});
it("answers 404 while the plugin serves no router", async () => {
const app = appWith([{ id: "gone", legacyPaths: ["/gone/hook"] }]);
const response = await request(app).post("/gone/hook/x");
expect(response.status).toBe(404);
});
});
@@ -35,6 +35,14 @@ const state = vi.hoisted(() => ({
active: new Set<string>(),
}));
vi.mock("../../../plugins/boot-migrations.js", () => ({
runPluginDataMigrations: async () => {},
}));
vi.mock("../../../utils/crypto-migration/raw-rows.js", () => ({
runStatement: vi.fn(async () => {}),
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: {
debug: vi.fn(),
@@ -0,0 +1,101 @@
/**
* Host plugin settings over remote sync: non-secret values travel with the
* host, and a plugin can translate a local row id to its syncId and back.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
const h = vi.hoisted(() => ({
stored: new Map<string, unknown>(),
writes: [] as Array<[string, number, string, unknown]>,
hooks: new Map<string, unknown>(),
manifests: [] as PluginManifest[],
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => h.manifests,
}));
vi.mock("../../../plugins/registry.js", () => ({
consume: (key: string) => h.hooks.get(key),
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
vi.mock("../../../plugins/settings.js", () => ({
declaredFields: (manifest: PluginManifest) =>
manifest.contributes!.settings!.host!.fields,
getAllSettings: async (
manifest: PluginManifest,
_scope: string,
id: number,
) =>
Object.fromEntries(
manifest.contributes!.settings!.host!.fields.map((field) => [
field.key,
h.stored.get(`${manifest.id}:${id}:${field.key}`),
]),
),
setSetting: async (
manifest: PluginManifest,
_scope: string,
id: number,
key: string,
value: unknown,
) => {
h.writes.push([manifest.id, id, key, value]);
return null;
},
}));
const { exportHostPluginSettings, importHostPluginSettings } =
await import("../../../database/routes/sync-host-plugin-settings.js");
const VAULT = {
id: "vault",
contributes: {
settings: {
host: {
fields: [
{ key: "profileId", type: "number", labelKey: "k" },
{ key: "token", type: "secret", labelKey: "k" },
],
},
},
},
} as unknown as PluginManifest;
beforeEach(() => {
h.stored.clear();
h.writes.length = 0;
h.hooks.clear();
h.manifests = [VAULT];
h.hooks.set("vault.hostSettingsSync", {
exportValue: (key: string, value: unknown) =>
key === "profileId" && value === 4 ? "profile-sync-4" : value,
importValue: (key: string, value: unknown) =>
key === "profileId" && value === "profile-sync-4" ? 11 : value,
});
});
describe("host plugin settings over sync", () => {
it("exports non-secret values through the plugin's translation", async () => {
h.stored.set("vault:1:profileId", 4);
h.stored.set("vault:1:token", "secret");
expect(await exportHostPluginSettings(1)).toEqual({
vault: { profileId: "profile-sync-4" },
});
});
it("imports them back to local ids", async () => {
await importHostPluginSettings(9, {
vault: { profileId: "profile-sync-4", token: "ignored" },
});
expect(h.writes).toEqual([["vault", 9, "profileId", 11]]);
});
it("ignores a payload without plugin settings", async () => {
await importHostPluginSettings(9, undefined);
expect(h.writes).toEqual([]);
});
});
File diff suppressed because it is too large. Load diff
@@ -1,215 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Express, Request, Response } from "express";
import type { SSHSession } from "../../../hosts/file-manager/session.js";
const commandMocks = vi.hoisted(() => ({
execBuffer: vi.fn(),
execWithSudoBuffer: vi.fn(),
}));
vi.mock("../../../hosts/file-manager/session.js", async (importOriginal) => ({
...(await importOriginal<
typeof import("../../../hosts/file-manager/session.js")
>()),
execBuffer: commandMocks.execBuffer,
execWithSudoBuffer: commandMocks.execWithSudoBuffer,
}));
import { registerFileContentRoutes } from "../../../hosts/file-manager/content-routes.js";
type RouteHandler = (request: Request, response: Response) => Promise<unknown>;
function setupReadRoute() {
const routes = new Map<string, RouteHandler>();
const app = {
get: (path: string, handler: RouteHandler) => routes.set(path, handler),
post: vi.fn(),
} as unknown as Express;
const session = {
isConnected: true,
lastActive: 0,
sudoPassword: "sudo-secret",
} as SSHSession;
registerFileContentRoutes(app, {
sshSessions: { session: session },
verifySessionOwnership: () => true,
});
const handler = routes.get("/ssh/file_manager/ssh/readFile");
if (!handler) throw new Error("readFile route was not registered");
const request = {
query: { sessionId: "session", path: "/root/secret.txt" },
userId: "user",
} as unknown as Request;
const response = {
status: vi.fn(),
json: vi.fn(),
} as unknown as Response;
vi.mocked(response.status).mockReturnValue(response);
vi.mocked(response.json).mockReturnValue(response);
return { handler, request, response, session };
}
describe("file manager readFile", () => {
beforeEach(() => {
commandMocks.execBuffer.mockReset();
commandMocks.execWithSudoBuffer.mockReset();
});
it("falls back to sudo for both the size check and content read", async () => {
const { handler, request, response, session } = setupReadRoute();
commandMocks.execBuffer
.mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: "Permission denied",
code: 1,
})
.mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: "Permission denied",
code: 1,
});
commandMocks.execWithSudoBuffer
.mockResolvedValueOnce({
stdout: Buffer.from("6\n"),
stderr: "",
code: 0,
})
.mockResolvedValueOnce({
stdout: Buffer.from("secret"),
stderr: "",
code: 0,
});
await handler(request, response);
expect(commandMocks.execWithSudoBuffer).toHaveBeenNthCalledWith(
1,
session,
"stat -c%s '/root/secret.txt'",
"sudo-secret",
);
expect(commandMocks.execWithSudoBuffer).toHaveBeenNthCalledWith(
2,
session,
"cat '/root/secret.txt'",
"sudo-secret",
500 * 1024 * 1024,
);
expect(response.json).toHaveBeenCalledWith({
content: "secret",
path: "/root/secret.txt",
encoding: "utf8",
});
expect(commandMocks.execBuffer).toHaveBeenNthCalledWith(
2,
session,
"cat '/root/secret.txt'",
500 * 1024 * 1024,
);
});
it("rejects a file that grows beyond the limit while being read", async () => {
const { handler, request, response } = setupReadRoute();
commandMocks.execBuffer
.mockResolvedValueOnce({
stdout: Buffer.from("5\n"),
stderr: "",
code: 0,
})
.mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: "Command output exceeds 524288000 bytes",
code: 1,
exceededLimit: true,
});
await handler(request, response);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
maxSize: 500 * 1024 * 1024,
tooLarge: true,
}),
);
});
it("keeps readable files on the unprivileged path", async () => {
const { handler, request, response } = setupReadRoute();
commandMocks.execBuffer
.mockResolvedValueOnce({
stdout: Buffer.from("5\n"),
stderr: "",
code: 0,
})
.mockResolvedValueOnce({
stdout: Buffer.from("hello"),
stderr: "",
code: 0,
});
await handler(request, response);
expect(commandMocks.execWithSudoBuffer).not.toHaveBeenCalled();
expect(response.json).toHaveBeenCalledWith({
content: "hello",
path: "/root/secret.txt",
encoding: "utf8",
});
});
it("preserves percent escapes that are literal path characters", async () => {
const { handler, request, response, session } = setupReadRoute();
request.query.path = "/tmp/100%/literal%2Fname";
commandMocks.execBuffer
.mockResolvedValueOnce({
stdout: Buffer.from("5\n"),
stderr: "",
code: 0,
})
.mockResolvedValueOnce({
stdout: Buffer.from("hello"),
stderr: "",
code: 0,
});
await handler(request, response);
expect(commandMocks.execBuffer).toHaveBeenNthCalledWith(
2,
session,
"cat '/tmp/100%/literal%2Fname'",
500 * 1024 * 1024,
);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({ path: "/tmp/100%/literal%2Fname" }),
);
});
it("rejects oversized sudo-only files before reading their content", async () => {
const { handler, request, response } = setupReadRoute();
commandMocks.execBuffer.mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: "Permission denied",
code: 1,
});
commandMocks.execWithSudoBuffer.mockResolvedValueOnce({
stdout: Buffer.from(String(501 * 1024 * 1024)),
stderr: "",
code: 0,
});
await handler(request, response);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({ tooLarge: true }),
);
expect(commandMocks.execBuffer).toHaveBeenCalledTimes(1);
expect(commandMocks.execWithSudoBuffer).toHaveBeenCalledTimes(1);
});
});
@@ -1,96 +0,0 @@
import { beforeEach, expect, it, vi } from "vitest";
import type { Express, Request, Response } from "express";
import type { SSHSession } from "../../../hosts/file-manager/session.js";
const mocks = vi.hoisted(() => ({
getSessionSftp: vi.fn(),
execChannel: vi.fn(),
}));
vi.mock("../../../hosts/file-manager/session.js", () => ({
...mocks,
execWithSudo: vi.fn(),
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: { getInstance: () => ({}) },
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: vi.fn(),
getCurrentSettingValue: vi.fn(),
}));
import { registerFileOperationRoutes } from "../../../hosts/file-manager/operation-routes.js";
type Handler = (req: Request, res: Response) => Promise<unknown>;
function setup(owned = true) {
const routes = new Map<string, Handler>();
const app = {
get: vi.fn(),
put: vi.fn(),
post: (url: string, fn: Handler) => routes.set(url, fn),
delete: vi.fn(),
} as unknown as Express;
registerFileOperationRoutes(app, {
sshSessions: { test: { isConnected: true } as SSHSession },
verifySessionOwnership: () => owned,
});
const res = { status: vi.fn(), json: vi.fn() };
res.status.mockReturnValue(res);
const req = {
userId: "owner",
body: { sessionId: "test", path: "/tmp", folderName: "parent/child" },
} as unknown as Request;
return {
run: () =>
routes.get("/ssh/file_manager/ssh/createFolder")!(
req,
res as unknown as Response,
),
res,
};
}
beforeEach(() => vi.clearAllMocks());
it("creates nested folders over SFTP when the server cannot execute shell commands", async () => {
const mkdir = vi.fn((path, _attrs, cb) =>
cb(path === "/tmp" ? { code: 4 } : null),
);
mocks.getSessionSftp.mockResolvedValue({
mkdir,
stat: (
_path: string,
cb: (error: unknown, stats?: { isDirectory: () => boolean }) => void,
) => cb(null, { isDirectory: () => true }),
});
const { run, res } = setup();
await run();
expect(mkdir.mock.calls.map((call) => call[0])).toEqual([
"/tmp",
"/tmp/parent",
"/tmp/parent/child",
]);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ path: "/tmp/parent/child" }),
);
expect(mocks.execChannel).not.toHaveBeenCalled();
});
it("reports permission denial instead of success", async () => {
const error = Object.assign(new Error("Permission denied"), { code: 3 });
mocks.getSessionSftp.mockResolvedValue({
mkdir: (
_p: string,
_a: unknown,
cb: (error: unknown, stats?: { isDirectory: () => boolean }) => void,
) => cb(error),
stat: (
_p: string,
cb: (error: unknown, stats?: { isDirectory: () => boolean }) => void,
) => cb(error),
});
const { run, res } = setup();
await run();
expect(res.status).toHaveBeenCalledWith(403);
});
it("rejects a session belonging to another user before opening SFTP", async () => {
const { run, res } = setup(false);
await run();
expect(res.status).toHaveBeenCalledWith(403);
expect(mocks.getSessionSftp).not.toHaveBeenCalled();
});
@@ -1,54 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildDirectProbeCommand,
buildDirectRsyncCommand,
quoteShell,
shouldBenchmarkDirectTransfer,
shouldUseDirectTransfer,
} from "../../../hosts/file-manager/direct-transfer-routing.js";
const endpoint = { host: "10.0.0.2", port: 2222, username: "deploy" };
describe("direct transfer routing", () => {
it("keeps small transfers on the relay without benchmarking", () => {
expect(shouldBenchmarkDirectTransfer(32 * 1024 * 1024 - 1)).toBe(false);
expect(shouldBenchmarkDirectTransfer(32 * 1024 * 1024)).toBe(true);
});
it("requires a meaningful speed advantage", () => {
expect(shouldUseDirectTransfer(700, 1000)).toBe(true);
expect(shouldUseDirectTransfer(850, 1000)).toBe(false);
expect(shouldUseDirectTransfer(0, 1000)).toBe(false);
});
it("probes without accepting passwords or unknown host keys", () => {
const command = buildDirectProbeCommand(endpoint);
expect(command).toContain("BatchMode=yes");
expect(command).toContain("StrictHostKeyChecking=yes");
expect(command).toContain("ConnectTimeout=5");
expect(command).toContain("-p 2222");
});
it("quotes source and destination paths for rsync", () => {
const command = buildDirectRsyncCommand(
endpoint,
["/srv/a file", "/srv/it's-safe"],
"/opt/releases",
true,
);
expect(command).toContain("--partial --append-verify");
expect(command).toContain("--protect-args");
expect(command).toContain(quoteShell("/srv/a file"));
expect(command).toContain(quoteShell("/srv/it's-safe"));
expect(command).toContain("deploy@10.0.0.2:/opt/releases/");
});
it("brackets IPv6 destinations", () => {
const command = buildDirectProbeCommand({
host: "2001:db8::2",
port: 22,
username: "root",
});
expect(command).toContain("root@[2001:db8::2]");
});
});
@@ -1,45 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildDeleteCommand } from "../../../hosts/file-manager/operation-commands.js";
describe("buildDeleteCommand", () => {
it("builds a PowerShell 5.1 compatible delete command for Windows files", () => {
const command = buildDeleteCommand(
"/C:/Users/Administrator/test.txt",
false,
);
expect(command.command).toBe(
"Remove-Item -LiteralPath 'C:\\Users\\Administrator\\test.txt' -Force -ErrorAction Stop",
);
expect(command.commandWithSuccess).toBe(
`${command.command}; if ($?) { Write-Output "SUCCESS" }`,
);
expect(command.commandWithSuccess).not.toContain("&&");
});
it("adds recursive deletion for Windows directories", () => {
const command = buildDeleteCommand("C:/Temp/Folder", true);
expect(command.command).toBe(
"Remove-Item -LiteralPath 'C:\\Temp\\Folder' -Recurse -Force -ErrorAction Stop",
);
});
it("escapes single quotes in Windows literal paths", () => {
const command = buildDeleteCommand("/C:/Temp/O'Brien.txt", false);
expect(command.command).toBe(
"Remove-Item -LiteralPath 'C:\\Temp\\O''Brien.txt' -Force -ErrorAction Stop",
);
});
it("keeps POSIX delete commands using shell success chaining", () => {
const command = buildDeleteCommand("/tmp/O'Brien.txt", false);
expect(command.command).toBe("rm -f '/tmp/O'\"'\"'Brien.txt'");
expect(command.commandWithSuccess).toBe(
`${command.command} && echo "SUCCESS"`,
);
});
});
@@ -1,68 +0,0 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import {
ChannelOpenSerializer,
execBuffer,
execWithSudoBuffer,
type SSHSession,
} from "../../../hosts/file-manager/session.js";
function setupSession() {
const stream = new EventEmitter() as EventEmitter & {
stderr: EventEmitter;
close: ReturnType<typeof vi.fn>;
write: ReturnType<typeof vi.fn>;
};
stream.stderr = new EventEmitter();
stream.close = vi.fn();
stream.write = vi.fn();
const exec = vi.fn(
(
_command: string,
callback: (error: undefined, channel: typeof stream) => void,
) => callback(undefined, stream),
);
const session = {
client: { exec },
channelOpener: new ChannelOpenSerializer(),
} as unknown as SSHSession;
return { exec, session, stream };
}
describe("file-manager command helpers", () => {
it("writes the sudo password to stdin instead of the command line", async () => {
const { exec, session, stream } = setupSession();
const resultPromise = execWithSudoBuffer(
session,
"cat '/root/secret.txt'",
"private password",
);
await vi.waitFor(() => expect(stream.write).toHaveBeenCalled());
expect(exec).toHaveBeenCalledWith(
"sudo -S -p '' cat '/root/secret.txt' 2>&1",
expect.any(Function),
);
expect(exec.mock.calls[0][0]).not.toContain("private password");
expect(stream.write).toHaveBeenCalledWith("private password\n");
stream.emit("close", 0);
await expect(resultPromise).resolves.toMatchObject({ code: 0 });
});
it("closes the channel when stdout exceeds the configured limit", async () => {
const { session, stream } = setupSession();
const resultPromise = execBuffer(session, "cat file", 4);
await vi.waitFor(() => expect(stream.listenerCount("data")).toBe(1));
stream.emit("data", Buffer.from("12345"));
await expect(resultPromise).resolves.toMatchObject({
code: 1,
exceededLimit: true,
});
expect(stream.close).toHaveBeenCalledOnce();
});
});
@@ -1,68 +0,0 @@
import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import {
hashSftpFile,
verifySftpFileIntegrity,
} from "../../../hosts/file-manager/transfer-integrity.js";
type SFTPWrapper = import("ssh2").SFTPWrapper;
function fakeSftp(contents: Record<string, Buffer | Buffer[]>): SFTPWrapper {
return {
createReadStream: vi.fn((path: string) => {
const value = contents[path];
if (value === undefined) {
return new Readable({
read() {
this.destroy(new Error(`Missing file: ${path}`));
},
});
}
return Readable.from(Array.isArray(value) ? value : [value]);
}),
} as unknown as SFTPWrapper;
}
describe("transfer integrity", () => {
it("hashes all chunks in an SFTP file", async () => {
const sftp = fakeSftp({
"/file": [Buffer.from("hello "), Buffer.from("world")],
});
await expect(hashSftpFile(sftp, "/file")).resolves.toBe(
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
);
});
it("accepts matching source and destination files", async () => {
const source = fakeSftp({ "/source": Buffer.from("same bytes") });
const dest = fakeSftp({ "/dest": Buffer.from("same bytes") });
await expect(
verifySftpFileIntegrity(source, dest, "/source", "/dest"),
).resolves.toMatchObject({ algorithm: "sha256" });
});
it("rejects a corrupted destination", async () => {
const source = fakeSftp({ "/source": Buffer.from("expected") });
const dest = fakeSftp({ "/dest": Buffer.from("corrupted") });
await expect(
verifySftpFileIntegrity(source, dest, "/source", "/dest"),
).rejects.toThrow("SHA-256 verification failed for /source");
});
it("aborts hashing with the caller's cancellation error", async () => {
const sftp = fakeSftp({ "/file": Buffer.from("data") });
const cancelled = new Error("cancelled by test");
await expect(
hashSftpFile(
sftp,
"/file",
() => true,
() => cancelled,
),
).rejects.toBe(cancelled);
});
});
@@ -1,39 +0,0 @@
import { describe, expect, it } from "vitest";
import {
estimateIncompressibleSample,
resolveArchiveTransferMethod,
type TransferScanSummary,
} from "../../../hosts/file-manager/transfer-routing.js";
describe("transfer content sampling", () => {
it("recognizes repetitive content as compressible", () => {
expect(estimateIncompressibleSample(Buffer.alloc(64 * 1024, 65))).toBe(
false,
);
});
it("recognizes high-entropy content as incompressible", () => {
const sample = Buffer.alloc(64 * 1024);
let state = 0x12345678;
for (let i = 0; i < sample.length; i++) {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
sample[i] = state & 0xff;
}
expect(estimateIncompressibleSample(sample)).toBe(true);
});
it("prefers sampled content over a misleading extension", () => {
const summary: TransferScanSummary = {
fileCount: 120,
totalBytes: 1024 * 1024 * 1024,
largestFileBytes: 32 * 1024 * 1024,
incompressibleRatio: 0,
sampledIncompressibleRatio: 1,
};
expect(
resolveArchiveTransferMethod("auto", summary, "unix", "unix", true, true),
).toBe("item_sftp");
});
});
@@ -1,127 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
clearTransferProfiles,
flushTransferProfiles,
getRecentDirectRouteDecision,
getTransferProfile,
initializeTransferProfiles,
recordDirectRouteBenchmark,
recordDirectRouteOutcome,
recordTransferProfile,
selectTransferTuning,
updateTransferProfile,
} from "../../../hosts/file-manager/transfer-tuning.js";
const MB = 1024 * 1024;
describe("adaptive transfer tuning", () => {
beforeEach(clearTransferProfiles);
it("keeps small files on one conservative lane", () => {
expect(selectTransferTuning(8 * MB)).toEqual({
parallelSegmentCount: 1,
pipelineConcurrency: 8,
});
});
it("uses more lanes for large transfers without exceeding segment count", () => {
expect(selectTransferTuning(2 * 1024 * MB)).toEqual({
parallelSegmentCount: 4,
pipelineConcurrency: 32,
});
expect(selectTransferTuning(300 * MB).parallelSegmentCount).toBe(2);
});
it("honours an explicit lane selection", () => {
expect(selectTransferTuning(2 * 1024 * MB, undefined, 3)).toMatchObject({
parallelSegmentCount: 3,
});
});
it("backs off after failures", () => {
const profile = updateTransferProfile(undefined, {
bytes: 256 * MB,
durationMs: 1000,
lanes: 4,
pipelineConcurrency: 32,
failed: true,
now: 1,
});
expect(profile.preferredLanes).toBe(2);
expect(profile.pipelineConcurrency).toBe(16);
});
it("learns and expires host-pair profiles", () => {
recordTransferProfile("a->b", {
bytes: 256 * MB,
durationMs: 2000,
lanes: 2,
pipelineConcurrency: 32,
failed: false,
now: 100,
});
expect(getTransferProfile("a->b", 101)?.samples).toBe(1);
expect(getTransferProfile("a->b", 8 * 24 * 60 * 60 * 1000)).toBeUndefined();
});
it("reuses recent route benchmarks and cools down failed direct paths", () => {
recordDirectRouteBenchmark("a->b", 700, 1000, 100);
expect(getRecentDirectRouteDecision("a->b", 1_000, 101)).toEqual({
useDirect: true,
directMs: 700,
relayMs: 1000,
});
recordDirectRouteOutcome("a->b", true, 102, 500);
expect(getRecentDirectRouteDecision("a->b", 1_000, 103)?.useDirect).toBe(
false,
);
expect(getRecentDirectRouteDecision("a->b", 1_000, 1_103)).toBeUndefined();
});
it("persists only anonymous local profiles across restarts", async () => {
const previousDataDir = process.env.DATA_DIR;
const dataDir = await fs.mkdtemp(path.join(tmpdir(), "termix-transfer-"));
process.env.DATA_DIR = dataDir;
const rawKey = "root@10.0.0.1->deploy@10.0.0.2";
const now = Date.now();
try {
await initializeTransferProfiles();
recordTransferProfile(rawKey, {
bytes: 256 * MB,
durationMs: 2000,
lanes: 2,
pipelineConcurrency: 16,
failed: false,
now,
});
recordDirectRouteBenchmark(rawKey, 700, 1000, now);
await flushTransferProfiles();
const stored = await fs.readFile(
path.join(dataDir, "adaptive-transfer-profiles.json"),
"utf8",
);
expect(stored).not.toContain(rawKey);
expect(Object.keys(JSON.parse(stored).profiles)[0]).toMatch(
/^[a-f0-9]{64}$/,
);
clearTransferProfiles();
await initializeTransferProfiles();
expect(getTransferProfile(rawKey, now + 1)).toMatchObject({ samples: 1 });
expect(
getRecentDirectRouteDecision(rawKey, 1_000, now + 1),
).toMatchObject({ useDirect: true });
} finally {
clearTransferProfiles();
if (previousDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = previousDataDir;
await fs.rm(dataDir, { recursive: true, force: true });
}
});
});
@@ -1,235 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { SFTPWrapper } from "ssh2";
import { afterEach, describe, expect, it } from "vitest";
import {
isSafeTrashSource,
listTrash,
moveToTrash,
permanentlyDeleteTrashItem,
restoreTrashItem,
} from "../../../hosts/file-manager/trash-service.js";
const temporaryDirectories: string[] = [];
// Windows only allows symlink creation with elevation or Developer Mode, so the
// symlink safety test is skipped where the OS refuses to create one at all.
const canCreateSymlinks = (() => {
const probe = fs.mkdtempSync(path.join(os.tmpdir(), "termix-symlink-probe-"));
try {
fs.mkdirSync(path.join(probe, "target"));
fs.symlinkSync(path.join(probe, "target"), path.join(probe, "link"), "dir");
return true;
} catch {
return false;
} finally {
fs.rmSync(probe, { recursive: true, force: true });
}
})();
function localSftp(home: string): SFTPWrapper {
const callback = <T>(
promise: Promise<T>,
done: (error: Error | undefined, value?: T) => void,
) =>
promise
.then((value) => done(undefined, value))
.catch((error) => done(error));
return {
realpath(
_target: string,
done: (error: Error | undefined, value?: string) => void,
) {
done(undefined, home);
},
stat(
target: string,
done: (error: Error | undefined, value?: fs.Stats) => void,
) {
callback(fs.promises.stat(target), done);
},
lstat(
target: string,
done: (error: Error | undefined, value?: fs.Stats) => void,
) {
callback(fs.promises.lstat(target), done);
},
readdir(
target: string,
done: (error: Error | undefined, value?: unknown[]) => void,
) {
callback(
fs.promises.readdir(target, { withFileTypes: true }).then((entries) =>
entries.map((entry) => ({
filename: entry.name,
longname: entry.name,
attrs: {},
})),
),
done,
);
},
readFile(
target: string,
done: (error: Error | undefined, value?: Buffer) => void,
) {
callback(fs.promises.readFile(target), done);
},
writeFile(target: string, data: string, done: (error?: Error) => void) {
fs.promises
.writeFile(target, data)
.then(() => done())
.catch(done);
},
rename(from: string, to: string, done: (error?: Error) => void) {
fs.promises
.rename(from, to)
.then(() => done())
.catch(done);
},
unlink(target: string, done: (error?: Error) => void) {
fs.promises
.unlink(target)
.then(() => done())
.catch(done);
},
mkdir(target: string, done: (error?: Error) => void) {
fs.promises
.mkdir(target)
.then(() => done())
.catch(done);
},
rmdir(target: string, done: (error?: Error) => void) {
fs.promises
.rmdir(target)
.then(() => done())
.catch(done);
},
} as unknown as SFTPWrapper;
}
async function fixture() {
const home = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "termix-trash-"),
);
temporaryDirectories.push(home);
return { home, sftp: localSftp(home) };
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) =>
fs.promises.rm(directory, { recursive: true, force: true }),
),
);
});
describe("file manager trash safety", () => {
it("rejects roots and paths inside the trash", () => {
expect(isSafeTrashSource("/", "/home/user/.termix-trash")).toBe(false);
expect(isSafeTrashSource("C:/", "C:/Users/user/.termix-trash")).toBe(false);
expect(
isSafeTrashSource(
"/home/user/.termix-trash/files/a",
"/home/user/.termix-trash",
),
).toBe(false);
});
it("accepts ordinary files and directories", () => {
expect(
isSafeTrashSource("/home/user/report.txt", "/home/user/.termix-trash"),
).toBe(true);
});
it("moves, lists, and restores a file without changing its contents", async () => {
const { home, sftp } = await fixture();
const original = path.join(home, "report.txt");
await fs.promises.writeFile(original, "important");
const trashed = await moveToTrash(sftp, original);
expect(fs.existsSync(original)).toBe(false);
expect(await listTrash(sftp, 7)).toEqual([trashed]);
await restoreTrashItem(sftp, trashed.id);
expect(await fs.promises.readFile(original, "utf8")).toBe("important");
expect(await listTrash(sftp, 7)).toEqual([]);
});
it("permanently deletes only the stored trash path", async () => {
const { home, sftp } = await fixture();
const original = path.join(home, "folder");
await fs.promises.mkdir(original);
await fs.promises.writeFile(path.join(original, "nested.txt"), "data");
const trashed = await moveToTrash(sftp, original);
await permanentlyDeleteTrashItem(sftp, trashed.id);
expect(await listTrash(sftp, 7)).toEqual([]);
});
it.skipIf(!canCreateSymlinks)(
"does not follow directory symlinks during permanent deletion",
async () => {
const { home, sftp } = await fixture();
const target = path.join(home, "target");
const link = path.join(home, "link");
await fs.promises.mkdir(target);
await fs.promises.writeFile(path.join(target, "keep.txt"), "keep");
await fs.promises.symlink(target, link, "dir");
const trashed = await moveToTrash(sftp, link);
await permanentlyDeleteTrashItem(sftp, trashed.id);
expect(
await fs.promises.readFile(path.join(target, "keep.txt"), "utf8"),
).toBe("keep");
},
);
it("refuses tampered metadata instead of deleting an arbitrary path", async () => {
const { home, sftp } = await fixture();
const original = path.join(home, "discard.txt");
const protectedFile = path.join(home, "keep.txt");
await fs.promises.writeFile(original, "discard");
await fs.promises.writeFile(protectedFile, "keep");
const trashed = await moveToTrash(sftp, original);
const metadata = path.join(
home,
".termix-trash",
"info",
`${trashed.id}.json`,
);
const data = JSON.parse(await fs.promises.readFile(metadata, "utf8"));
data.trashPath = protectedFile;
await fs.promises.writeFile(metadata, JSON.stringify(data));
await expect(permanentlyDeleteTrashItem(sftp, trashed.id)).rejects.toThrow(
"Invalid trash metadata",
);
expect(await fs.promises.readFile(protectedFile, "utf8")).toBe("keep");
});
it("prunes items after the configured retention period", async () => {
const { home, sftp } = await fixture();
const original = path.join(home, "old.txt");
await fs.promises.writeFile(original, "old");
const trashed = await moveToTrash(sftp, original);
const metadata = path.join(
home,
".termix-trash",
"info",
`${trashed.id}.json`,
);
const data = JSON.parse(await fs.promises.readFile(metadata, "utf8"));
data.deletedAt = "2020-01-01T00:00:00.000Z";
await fs.promises.writeFile(metadata, JSON.stringify(data));
expect(await listTrash(sftp, 7)).toEqual([]);
expect(
fs.existsSync(path.join(home, ".termix-trash", "files", trashed.id)),
).toBe(false);
});
});
@@ -1,198 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Express, Request, Response } from "express";
import { PassThrough, Writable } from "node:stream";
import type { SSHSession } from "../../../hosts/file-manager/session.js";
const mocks = vi.hoisted(() => ({ getSessionSftp: vi.fn() }));
vi.mock("../../../hosts/file-manager/session.js", async (importOriginal) => ({
...(await importOriginal<
typeof import("../../../hosts/file-manager/session.js")
>()),
getSessionSftp: mocks.getSessionSftp,
}));
import { registerFileContentRoutes } from "../../../hosts/file-manager/content-routes.js";
type RouteHandler = (request: Request, response: Response) => unknown;
/**
* Behaves like ssh2's SFTP WriteStream on current Node: it destroys itself
* inside `_final` (autoClose), so a `finish` event is never emitted; only
* `close` follows the last acknowledged write.
*/
class Ssh2LikeWriteStream extends Writable {
chunks: Buffer[] = [];
constructor() {
super({ emitClose: false, autoDestroy: false });
}
_write(chunk: Buffer, _enc: BufferEncoding, cb: () => void) {
this.chunks.push(Buffer.from(chunk));
cb();
}
_final(cb: () => void) {
this.destroy();
cb();
}
_destroy(err: Error | null, cb: (err: Error | null) => void) {
// The handle close round-trip happens later; 'close' is asynchronous.
setTimeout(() => {
cb(err);
this.emit("close");
}, 5);
}
get body() {
return Buffer.concat(this.chunks);
}
}
function setup() {
const routes = new Map<string, RouteHandler>();
const app = {
get: vi.fn(),
post: (path: string, handler: RouteHandler) => routes.set(path, handler),
} as unknown as Express;
const session = { isConnected: true, lastActive: 0 } as SSHSession;
registerFileContentRoutes(app, {
sshSessions: { s1: session },
verifySessionOwnership: () => true,
});
const writeStream = new Ssh2LikeWriteStream();
const unlink = vi.fn((_p: string, cb: (e: null) => void) => cb(null));
mocks.getSessionSftp.mockResolvedValue({
createWriteStream: vi.fn(() => writeStream),
unlink,
});
const response = { status: vi.fn(), json: vi.fn() } as unknown as Response;
vi.mocked(response.status).mockReturnValue(response);
const responded = new Promise<unknown>((resolve) => {
vi.mocked(response.json).mockImplementation((payload: unknown) => {
resolve(payload);
return response;
});
});
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("route never responded")), 2000),
);
return {
routes,
writeStream,
unlink,
response,
responded: Promise.race([responded, timeout]),
};
}
beforeEach(() => mocks.getSessionSftp.mockReset());
describe("uploadFileStream completion", () => {
function multipartRequest(body: Buffer, fields: Record<string, string>) {
const boundary = "----termixTestBoundary";
let preamble = "";
for (const [k, v] of Object.entries(fields)) {
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`;
}
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="payload.bin"\r\nContent-Type: application/octet-stream\r\n\r\n`;
const req = new PassThrough() as PassThrough & Request;
Object.assign(req, {
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
userId: "u1",
complete: false,
});
const send = async () => {
req.write(preamble);
req.write(body);
req.write(`\r\n--${boundary}--\r\n`);
(req as { complete: boolean }).complete = true;
req.end();
};
return { req, send };
}
it("answers once the SFTP stream closes, even though ssh2 never emits 'finish'", async () => {
const { routes, writeStream, response, responded } = setup();
const payload = Buffer.from("hello over sftp");
const { req, send } = multipartRequest(payload, {
sessionId: "s1",
path: "/home/ubuntu",
});
routes.get("/ssh/file_manager/ssh/uploadFileStream")!(req, response);
await send();
const answer = (await responded) as { message: string; path: string };
expect(answer.message).toBe("File uploaded successfully");
expect(answer.path).toBe("/home/ubuntu/payload.bin");
expect(writeStream.body.equals(payload)).toBe(true);
expect(vi.mocked(response.status)).not.toHaveBeenCalled();
});
it("does not report success when the client aborts before the body ended", async () => {
const { routes, writeStream, unlink, response } = setup();
const { req } = multipartRequest(Buffer.alloc(0), {
sessionId: "s1",
path: "/home/ubuntu",
});
routes.get("/ssh/file_manager/ssh/uploadFileStream")!(req, response);
// Headers + the beginning of the file part arrive, then the socket dies.
req.write(
`------termixTestBoundary\r\nContent-Disposition: form-data; name="sessionId"\r\n\r\ns1\r\n` +
`------termixTestBoundary\r\nContent-Disposition: form-data; name="path"\r\n\r\n/home/ubuntu\r\n` +
`------termixTestBoundary\r\nContent-Disposition: form-data; name="file"; filename="payload.bin"\r\n\r\npartial`,
);
await new Promise((r) => setTimeout(r, 20));
req.emit("aborted");
// The partial is removed and its stream closes; that close is not a success.
await new Promise((r) => setTimeout(r, 50));
expect(writeStream.destroyed).toBe(true);
expect(unlink).toHaveBeenCalledWith(
"/home/ubuntu/payload.bin",
expect.any(Function),
);
expect(vi.mocked(response.json)).not.toHaveBeenCalled();
});
});
describe("uploadFileChunk completion", () => {
function chunkRequest(query: Record<string, string>) {
const req = new PassThrough() as PassThrough & Request;
Object.assign(req, { query, headers: {}, userId: "u1" });
return req;
}
const query = {
sessionId: "s1",
path: "/home/ubuntu",
fileName: "big.bin",
offset: "0",
totalSize: "6",
};
it("streams the raw body and answers on the ssh2 'close' event", async () => {
const { routes, writeStream, response, responded } = setup();
const req = chunkRequest(query);
routes.get("/ssh/file_manager/ssh/uploadFileChunk")!(req, response);
await new Promise((r) => setTimeout(r, 10)); // let getSessionSftp resolve
req.write("abc");
req.end("def");
const answer = (await responded) as {
bytesWritten: number;
nextOffset: number;
complete: boolean;
};
expect(answer).toMatchObject({
bytesWritten: 6,
nextOffset: 6,
complete: true,
});
expect(writeStream.body.toString()).toBe("abcdef");
});
it("still writes the chunk when middleware already drained the body into req.body", async () => {
const { routes, writeStream, response, responded } = setup();
const req = chunkRequest(query);
// What express.raw() leaves behind: a Buffer body and an ended stream.
Object.assign(req, { body: Buffer.from("abcdef") });
req.end();
routes.get("/ssh/file_manager/ssh/uploadFileChunk")!(req, response);
const answer = (await responded) as { bytesWritten: number };
expect(answer.bytesWritten).toBe(6);
expect(writeStream.body.toString()).toBe("abcdef");
});
});
@@ -1,150 +0,0 @@
import { describe, it, expect } from "vitest";
import {
isExecutableFile,
modeToPermissions,
formatMtime,
getMimeType,
detectBinary,
parseLsDateToTimestamp,
} from "../../../hosts/file-manager/utils.js";
describe("isExecutableFile", () => {
it("flags scripts with execute permission", () => {
expect(isExecutableFile("-rwxr-xr-x", "deploy.sh")).toBe(true);
expect(isExecutableFile("-rwxr-xr-x", "run.py")).toBe(true);
});
it("flags known executable extensions with execute permission", () => {
expect(isExecutableFile("-rwxr-xr-x", "tool.bin")).toBe(true);
expect(isExecutableFile("-rwxr-xr-x", "app.exe")).toBe(true);
});
it("flags extensionless files with execute permission", () => {
expect(isExecutableFile("-rwxr-xr-x", "myprogram")).toBe(true);
});
it("does not flag files without execute permission", () => {
expect(isExecutableFile("-rw-r--r--", "deploy.sh")).toBe(false);
expect(isExecutableFile("-rw-r--r--", "myprogram")).toBe(false);
});
it("does not flag non-script data files even when executable", () => {
expect(isExecutableFile("-rwxr-xr-x", "notes.txt")).toBe(false);
});
});
describe("modeToPermissions", () => {
it("renders a regular file with rwxr-xr-x", () => {
expect(modeToPermissions(0o100755)).toBe("-rwxr-xr-x");
});
it("renders a directory prefix", () => {
expect(modeToPermissions(0o040755)).toBe("drwxr-xr-x");
});
it("renders a symlink prefix", () => {
expect(modeToPermissions(0o120777)).toBe("lrwxrwxrwx");
});
it("renders a read-only file", () => {
expect(modeToPermissions(0o100444)).toBe("-r--r--r--");
});
it("renders no permissions", () => {
expect(modeToPermissions(0o100000)).toBe("----------");
});
});
describe("formatMtime", () => {
it("uses HH:MM format for recent timestamps", () => {
// Within the last 6 months relative to now.
const recent = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 5;
const result = formatMtime(recent);
expect(result).toMatch(/^[A-Z][a-z]{2} +\d{1,2} \d{2}:\d{2}$/);
});
it("uses the year for old timestamps", () => {
// ~2 years ago is comfortably outside the 6-month window.
const old = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 365 * 2;
const result = formatMtime(old);
expect(result).toMatch(/^[A-Z][a-z]{2} +\d{1,2} +\d{4}$/);
});
});
describe("getMimeType", () => {
it("maps known extensions", () => {
expect(getMimeType("readme.txt")).toBe("text/plain");
expect(getMimeType("data.json")).toBe("application/json");
expect(getMimeType("photo.JPEG")).toBe("image/jpeg");
expect(getMimeType("archive.zip")).toBe("application/zip");
});
it("falls back to octet-stream for unknown or missing extensions", () => {
expect(getMimeType("mystery.xyz")).toBe("application/octet-stream");
expect(getMimeType("noextension")).toBe("application/octet-stream");
});
});
describe("parseLsDateToTimestamp", () => {
it("parses a year-format date string", () => {
const ts = parseLsDateToTimestamp("Dec 12 2025");
const d = new Date(ts * 1000);
expect(d.getFullYear()).toBe(2025);
expect(d.getMonth()).toBe(11);
expect(d.getDate()).toBe(12);
});
it("parses a time-format date string for a recent file", () => {
const now = new Date();
const month = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
][now.getMonth()];
const day = now.getDate();
const ts = parseLsDateToTimestamp(`${month} ${day} 10:30`);
const d = new Date(ts * 1000);
expect(d.getHours()).toBe(10);
expect(d.getMinutes()).toBe(30);
});
it("returns 0 for an empty or invalid string", () => {
expect(parseLsDateToTimestamp("")).toBe(0);
expect(parseLsDateToTimestamp("Xyz 5 12:00")).toBe(0);
});
it("produces ascending order for older vs newer dates", () => {
const older = parseLsDateToTimestamp("Jan 1 2020");
const newer = parseLsDateToTimestamp("Dec 31 2024");
expect(older).toBeLessThan(newer);
});
});
describe("detectBinary", () => {
it("returns false for empty buffers", () => {
expect(detectBinary(Buffer.from([]))).toBe(false);
});
it("returns false for plain UTF-8 text", () => {
expect(detectBinary(Buffer.from("hello world\nsecond line\t tab"))).toBe(
false,
);
});
it("returns true when null bytes are present", () => {
expect(detectBinary(Buffer.from([0x48, 0x00, 0x49, 0x00]))).toBe(true);
});
it("allows common whitespace control characters", () => {
expect(detectBinary(Buffer.from("line1\r\nline2\tend"))).toBe(false);
});
});
@@ -31,7 +31,11 @@ function target(id: number, extra: Partial<Target> = {}): Target {
};
}
function setup(targets: Target[], reachable = true) {
function setup(
targets: Target[],
reachable = true,
shared: Record<string, number[]> = {},
) {
const emitted: unknown[] = [];
const ping = vi.fn(async () => reachable);
const pingThroughJumpHosts = vi.fn(async () => reachable);
@@ -45,6 +49,7 @@ function setup(targets: Target[], reachable = true) {
);
const service = new HostStatusService({
loadTargets,
loadSharedHostIds: async (userId) => shared[userId] ?? [],
ping,
pingThroughJumpHosts,
globalInterval: () => 60,
@@ -87,6 +92,30 @@ describe("HostStatusService", () => {
});
});
it("checks a host shared with the user before its owner ever asks", async () => {
const { service, ping } = setup([target(5)], true, { recipient: [5] });
active = service;
await service.statusesFor("recipient", null);
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.5", 22);
expect(service.get(5)?.status).toBe("reachable");
});
it("only starts the shared hosts a desktop request names", async () => {
const { service, ping } = setup([target(5), target(6)], true, {
recipient: [5, 6],
});
active = service;
await service.statusesFor("recipient", new Set([6]));
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.6", 22);
expect(ping).not.toHaveBeenCalledWith("10.0.0.5", 22);
});
it("reports offline for a host that does not answer", async () => {
const { service } = setup([target(1)], false);
active = service;
+141
View File
@@ -0,0 +1,141 @@
/**
* Boots core the way starter.ts does, against a real SQLite database that
* starts in 2.8 shape, with the plugin loader pointed at the built bundles in
* dist/plugins rather than source. Shared by the boot and upgrade tests.
*
* Needs `npm run build` (or build:sdk and build:plugins) first.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
export const REPO_ROOT = path.resolve(__dirname, "../../../..");
export const BUILT_PLUGINS_DIR = path.join(REPO_ROOT, "dist", "plugins");
const SCHEMA_28 = path.join(__dirname, "../fixtures/sqlite-2.8-schema.sql");
/** Every plugin in the repo, by folder. */
export function sourcePluginIds(): string[] {
const root = path.join(REPO_ROOT, "plugins");
return fs
.readdirSync(root, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(root, entry.name, "manifest.json")),
)
.map((entry) => entry.name)
.sort();
}
/** Throws with the fix when dist/plugins is missing a plugin. */
export function assertPluginsBuilt(): void {
const missing = sourcePluginIds().filter(
(id) =>
!fs.existsSync(path.join(BUILT_PLUGINS_DIR, id, "dist", "backend.js")),
);
if (missing.length > 0) {
throw new Error(
`dist/plugins is missing ${missing.join(", ")}. Run npm run build first.`,
);
}
}
export function builtManifest(id: string): Record<string, unknown> {
return JSON.parse(
fs.readFileSync(path.join(BUILT_PLUGINS_DIR, id, "manifest.json"), "utf8"),
);
}
type Row = Record<string, unknown>;
/** A 2.8 database in memory, with a helper that ignores unknown columns. */
export function create28Database() {
const sqlite = new Database(":memory:");
sqlite.exec(fs.readFileSync(SCHEMA_28, "utf8"));
const columns = new Map<string, Set<string>>();
const insert = (table: string, row: Row): number => {
let known = columns.get(table);
if (!known) {
known = new Set(
(
sqlite.prepare(`PRAGMA table_info("${table}")`).all() as Array<{
name: string;
}>
).map((column) => column.name),
);
columns.set(table, known);
}
const entries = Object.entries(row).filter(([key]) => known!.has(key));
const sql = `INSERT INTO "${table}" (${entries
.map(([key]) => `"${key}"`)
.join(", ")}) VALUES (${entries.map(() => "?").join(", ")})`;
const values = entries.map(([, value]) =>
typeof value === "boolean"
? value
? 1
: 0
: value !== null && typeof value === "object"
? JSON.stringify(value)
: value,
);
return Number(sqlite.prepare(sql).run(...values).lastInsertRowid);
};
return { sqlite, insert };
}
export interface BootedCore {
dataDir: string;
/** The live database core and the plugins write to. */
sqlite: Database.Database;
shutdown: () => Promise<void>;
}
/**
* Writes the seeded database into a fresh DATA_DIR and runs starter.ts's boot
* sequence up to, not including, opening the HTTP server. Call
* vi.resetModules() before, so module state from other tests is gone.
*/
export async function bootCore(seed: Database.Database): Promise<BootedCore> {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-boot-"));
process.env.DATA_DIR = dataDir;
process.env.DB_FILE_ENCRYPTION = "false";
process.env.ALLOW_EMPTY_DATA_DIR = "true";
process.env.TERMIX_BUNDLED_PLUGINS_DIR = BUILT_PLUGINS_DIR;
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
const { SystemCrypto } = await import("../../utils/system-crypto.js");
const systemCrypto = SystemCrypto.getInstance();
await systemCrypto.initializeJWTSecret();
await systemCrypto.initializeDatabaseKey();
await systemCrypto.initializeEncryptionKey();
await systemCrypto.initializeInternalAuthToken();
const db = await import("../../database/db/index.js");
await db.initializeDatabase();
const { runCoreBootMigrations } = await import("../../boot.js");
await runCoreBootMigrations();
const { primeKnownPermissions } =
await import("../../utils/known-permissions.js");
await primeKnownPermissions();
const { initializePlugins, shutdownPlugins } =
await import("../../plugins/index.js");
await initializePlugins();
const { runPluginDataMigrations } =
await import("../../plugins/boot-migrations.js");
await runPluginDataMigrations();
return {
dataDir,
sqlite: db.getSqlite(),
shutdown: async () => {
await shutdownPlugins();
fs.rmSync(dataDir, { recursive: true, force: true });
},
};
}
@@ -0,0 +1,149 @@
/**
* The built plugins really load: every bundle in dist/plugins activates on a
* database that started in 2.8 shape, in dependency order, with every
* service it needs provided and nothing registered twice.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
assertPluginsBuilt,
bootCore,
builtManifest,
create28Database,
sourcePluginIds,
type BootedCore,
} from "./built-harness.js";
const BOOT_TIMEOUT = 180_000;
let core: BootedCore;
const logged = { errors: [] as string[], warnings: [] as string[] };
beforeAll(async () => {
assertPluginsBuilt();
vi.resetModules();
const { sqlite } = create28Database();
const error = vi.spyOn(console, "error").mockImplementation((...args) => {
logged.errors.push(args.map(String).join(" "));
});
const warn = vi.spyOn(console, "warn").mockImplementation((...args) => {
logged.warnings.push(args.map(String).join(" "));
});
try {
core = await bootCore(sqlite);
} finally {
error.mockRestore();
warn.mockRestore();
}
}, BOOT_TIMEOUT);
afterAll(async () => {
await core?.shutdown();
}, BOOT_TIMEOUT);
describe("the built plugins on a 2.8 database", () => {
it("boots without logging an error", () => {
expect(logged.errors).toEqual([]);
if (process.env.SHOW_BOOT_WARNINGS) {
console.log(logged.warnings.join("\n"));
}
});
it("activates every plugin, none failed or blocked", async () => {
const { getPluginRuntime } = await import("../../plugins/index.js");
const states = Object.fromEntries(
getPluginRuntime()
.loader.list()
.map((plugin) => [plugin.id, plugin.state]),
);
expect(Object.keys(states).sort()).toEqual(sourcePluginIds());
const notActive = Object.entries(states).filter(
([, state]) => state !== "active",
);
expect(notActive).toEqual([]);
});
it("gives every declared service a provider and meets every hard dependency", async () => {
const { listServices } = await import("../../plugins/service-registry.js");
const provided = new Set(
listServices().map((service) => `${service.pluginId}:${service.service}`),
);
const ids = new Set(sourcePluginIds());
for (const id of ids) {
const manifest = builtManifest(id) as {
provides?: Array<{ service: string }>;
requires?: Array<{ service: string; optional?: boolean }>;
dependencies?: Record<string, string>;
};
for (const { service } of manifest.provides ?? []) {
expect(provided, `${id} provides ${service}`).toContain(
`${id}:${service}`,
);
}
const available = new Set(
listServices().map((service) => service.service),
);
for (const requirement of manifest.requires ?? []) {
if (requirement.optional) continue;
expect(available, `${id} requires ${requirement.service}`).toContain(
requirement.service,
);
}
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
expect(ids, `${id} depends on ${dependency}`).toContain(dependency);
}
}
});
it("registers no route, socket or permission that clashes", async () => {
const { listRegistrationConflicts } =
await import("../../plugins/conflicts.js");
expect(listRegistrationConflicts()).toEqual([]);
const { getRegisteredWsRoutes } = await import("../../plugins/ws.js");
const sockets = getRegisteredWsRoutes();
expect(new Set(sockets).size).toBe(sockets.length);
// Plugin routes live under /plugin-api/<id>/, so a clash is two plugins
// claiming one id, which the loader refuses; a legacy path must start
// with its own plugin id.
for (const id of sourcePluginIds()) {
const legacy =
(
builtManifest(id) as {
contributes?: { http?: { legacyPaths?: string[] } };
}
).contributes?.http?.legacyPaths ?? [];
for (const legacyPath of legacy) {
expect(legacyPath.startsWith(`/${id}/`)).toBe(true);
}
}
const { getPermissionCatalog } =
await import("../../utils/permission-catalog.js");
const permissions = getPermissionCatalog().flatMap(
(entry) => entry.permissions,
);
expect(new Set(permissions).size).toBe(permissions.length);
});
it("records every plugin migration it ran", async () => {
const recorded = core.sqlite
.prepare(
"SELECT plugin_id, COUNT(*) AS n FROM plugin_migrations GROUP BY plugin_id",
)
.all() as Array<{ plugin_id: string; n: number }>;
const byPlugin = new Map(recorded.map((row) => [row.plugin_id, row.n]));
const fs = await import("node:fs");
const path = await import("node:path");
const { BUILT_PLUGINS_DIR } = await import("./built-harness.js");
for (const id of sourcePluginIds()) {
const dir = path.join(BUILT_PLUGINS_DIR, id, "migrations", "sqlite");
const files = fs.existsSync(dir)
? fs.readdirSync(dir).filter((name) => name.endsWith(".sql"))
: [];
expect(byPlugin.get(id) ?? 0, `${id} migrations`).toBe(files.length);
}
});
});
@@ -212,6 +212,20 @@ describe("ctx.events namespacing", () => {
expect(() => ctx.events.emit("host.status", {})).not.toThrow();
});
it("gates listening to core topics the same way", () => {
const ctx = context(["kv:own"]);
expect(() => ctx.events.on("user.deleted", () => {})).toThrow(
/may only listen to/,
);
expect(() =>
ctx.events.on("plugin.other-plugin.thing", () => {}),
).not.toThrow();
expect(() =>
context(["events:core"]).events.on("user.deleted", () => {}),
).not.toThrow();
});
});
describe("ctx.kv limits", () => {
@@ -81,3 +81,31 @@ describe("ctx.audit.record", () => {
});
});
});
describe("ctx.audit.record with a request", () => {
it("records the request's IP address and user agent", async () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({
action: "collab_room_end",
success: true,
request: {
ip: "203.0.113.4",
headers: { "user-agent": "TestAgent/1.0" },
},
});
expect(auditEntries[0]).toMatchObject({
ipAddress: "203.0.113.4",
userAgent: "TestAgent/1.0",
});
});
it("falls back to the socket address on an upgrade request", async () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({
action: "collab_join",
success: true,
request: { headers: {}, socket: { remoteAddress: "198.51.100.9" } },
});
expect(auditEntries[0]).toMatchObject({ ipAddress: "198.51.100.9" });
});
});
@@ -22,6 +22,10 @@ const state = vi.hoisted(() => ({
warnings: [] as string[],
}));
vi.mock("../../plugins/boot-migrations.js", () => ({
runPluginDataMigrations: async () => {},
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
@@ -29,7 +33,9 @@ vi.mock("../../utils/logger.js", () => ({
warn: vi.fn((message: string) => {
state.warnings.push(message);
}),
error: vi.fn(),
error: vi.fn((message: string) => {
state.warnings.push(message);
}),
success: vi.fn(),
},
databaseLogger: {
@@ -265,6 +271,11 @@ describe("namespace escalation is refused", () => {
expect(isValidPermission("docker.ps")).toBe(true);
expect(isValidPermission("docker.automations.run")).toBe(false);
expect(state.warnings.join()).toMatch(/belongs to someone else/);
const { listRegistrationConflicts } =
await import("../../plugins/conflicts.js");
expect(listRegistrationConflicts()).toContainEqual(
expect.objectContaining({ kind: "permission", heldBy: "automations" }),
);
} finally {
await loader.shutdown();
fixture.cleanup();
@@ -0,0 +1,55 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
FETCH_BLOCKED_PORTS,
installSafePorts,
} from "../../../../packages/plugin-sdk/src/testing/safe-ports.js";
let server: http.Server | null = null;
afterEach(async () => {
await new Promise<void>((resolve) =>
server?.listening ? server.close(() => resolve()) : resolve(),
);
server = null;
});
describe("installSafePorts", () => {
it("listens again when the random port is one fetch refuses", async () => {
installSafePorts();
server = http.createServer((_req, res) => res.end("ok"));
const realAddress = server.address.bind(server);
vi.spyOn(server, "address").mockImplementationOnce(() => ({
address: "127.0.0.1",
family: "IPv4",
port: 6000,
}));
const onListening = vi.fn();
await new Promise<void>((resolve) =>
server!.listen(0, "127.0.0.1", () => {
onListening();
resolve();
}),
);
expect(onListening).toHaveBeenCalledTimes(1);
const { port } = realAddress() as AddressInfo;
expect(FETCH_BLOCKED_PORTS.has(port)).toBe(false);
const response = await fetch(`http://127.0.0.1:${port}/`);
expect(await response.text()).toBe("ok");
});
it("leaves a fixed port alone", async () => {
installSafePorts();
server = http.createServer();
await new Promise<void>((resolve) => server!.listen(0, resolve));
const { port } = server.address() as AddressInfo;
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = http.createServer();
await new Promise<void>((resolve) => server!.listen(port, resolve));
expect((server.address() as AddressInfo).port).toBe(port);
});
});
@@ -395,3 +395,22 @@ describe("service registry", () => {
expect(error.message).toContain("provider");
});
});
describe("provider clashes", () => {
it("refuses a second plugin answering to the same service and name", async () => {
register();
expect(() => register({ pluginId: "other-plugin" })).toThrow(
/already provided by provider-plugin/,
);
const { listRegistrationConflicts } =
await import("../../plugins/conflicts.js");
expect(listRegistrationConflicts()).toContainEqual(
expect.objectContaining({ kind: "service", pluginId: "other-plugin" }),
);
});
it("lets a plugin replace its own registration", () => {
register();
expect(() => register()).not.toThrow();
});
});
@@ -372,3 +372,83 @@ describe("settings field permissions", () => {
expect(errors).toEqual([]);
});
});
describe("host field options", () => {
const host = (fields: unknown[], extra: Record<string, unknown> = {}) =>
withSettings({
admin: [
{ key: "defaultOn", type: "boolean", labelKey: "k" },
{ key: "token", type: "secret", labelKey: "k" },
],
host: { enableKey: "on", enableLabelKey: "k", fields, ...extra },
});
it("accepts enableDefault, defaultFrom, shareRead and ownerOnly", () => {
expect(
validateManifest(
host(
[
{
key: "mode",
type: "boolean",
labelKey: "k",
defaultFrom: "defaultOn",
shareRead: "edit",
ownerOnly: true,
},
],
{ enableDefault: true },
),
),
).toEqual([]);
});
it("rejects a defaultFrom that names a secret or unknown admin field", () => {
for (const defaultFrom of ["token", "missing"]) {
expect(
validateManifest(
host([{ key: "mode", type: "boolean", labelKey: "k", defaultFrom }]),
).join(),
).toMatch(/defaultFrom/);
}
});
it("rejects an unknown shareRead level and host options on admin fields", () => {
expect(
validateManifest(
host([
{ key: "mode", type: "boolean", labelKey: "k", shareRead: "all" },
]),
).join(),
).toMatch(/shareRead/);
expect(
validateManifest(
withSettings({
admin: [
{ key: "a", type: "boolean", labelKey: "k", ownerOnly: true },
],
}),
).join(),
).toMatch(/only valid on host fields/);
});
});
describe("contributes.http.legacyPaths", () => {
it("accepts a path under the plugin's own id", () => {
expect(
validateManifest(
base({
contributes: { http: { legacyPaths: ["/sample-plugin/hook"] } },
}),
),
).toEqual([]);
});
it("rejects a path outside the plugin's id", () => {
expect(
validateManifest(
base({ contributes: { http: { legacyPaths: ["/users/login"] } } }),
).join(),
).toMatch(/must start with "\/sample-plugin\/"/);
});
});
@@ -17,6 +17,11 @@ const state = vi.hoisted(() => ({
userId: "user-1",
permissions: new Set<string>(),
hostEditAccess: true,
hostOwner: true,
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
runStatement: vi.fn(async () => {}),
}));
vi.mock("../../utils/logger.js", () => {
@@ -67,7 +72,7 @@ vi.mock("../../utils/permission-manager.js", () => ({
},
canAccessHost: async () => ({
hasAccess: state.hostEditAccess,
isOwner: state.hostEditAccess,
isOwner: state.hostEditAccess && state.hostOwner,
isShared: false,
}),
}),
@@ -115,7 +120,10 @@ const MANIFEST: PluginManifest = {
host: {
enableKey: "enableThing",
enableLabelKey: "k",
fields: [{ key: "port", type: "number", labelKey: "k" }],
fields: [
{ key: "port", type: "number", labelKey: "k" },
{ key: "profile", type: "string", labelKey: "k", ownerOnly: true },
],
},
},
},
@@ -214,6 +222,7 @@ beforeEach(async () => {
state.userId = "user-1";
state.permissions = new Set<string>();
state.hostEditAccess = true;
state.hostOwner = true;
const app = express();
app.use(express.json());
@@ -440,6 +449,40 @@ describe("user scope", () => {
});
describe("host scope", () => {
it("keeps an owner-only field from a shared editor", async () => {
state.hostOwner = false;
const write = await call("/plugins/sample/settings/host/7", {
method: "PUT",
body: JSON.stringify({ profile: "other", port: 22 }),
});
expect(write.status).toBe(400);
expect((await write.json()).errors.profile).toMatch(/owner/);
expect(rows).toHaveLength(0);
state.hostOwner = true;
const owner = await call("/plugins/sample/settings/host/7", {
method: "PUT",
body: JSON.stringify({ profile: "mine" }),
});
expect(owner.status).toBe(200);
});
it("runs the plugin's validators before writing anything", async () => {
const { onSettingsValidate, clearSettingsListeners } =
await import("../../plugins/settings.js");
onSettingsValidate("sample", "host", (values) =>
Number(values.port) > 1000 ? { port: "Too high" } : undefined,
);
const write = await call("/plugins/sample/settings/host/7", {
method: "PUT",
body: JSON.stringify({ enableThing: true, port: 5000 }),
});
expect(write.status).toBe(400);
expect((await write.json()).errors).toEqual({ port: "Too high" });
expect(rows).toHaveLength(0);
clearSettingsListeners("sample");
});
it("lets a user with edit access read and write", async () => {
const write = await call("/plugins/sample/settings/host/7", {
method: "PUT",
@@ -72,12 +72,24 @@ const settingsRepository = {
rows.push({ pluginId, scope, scopeId, key, value, encrypted });
},
),
listByKey: vi.fn(async (pluginId: string, scope: string, key: string) =>
rows.filter(
(row) =>
row.pluginId === pluginId && row.scope === scope && row.key === key,
),
),
};
const hostOwners = new Map<number, string>();
const coreSettings = new Map<string, string>();
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => settingsRepository,
createCurrentHostRepository: () => ({
findById: async (id: number) =>
hostOwners.has(id) ? { id, userId: hostOwners.get(id) } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
@@ -106,9 +118,12 @@ const {
findField,
getAllSettings,
getSetting,
listHostValues,
onSettingsChange,
onSettingsValidate,
readCoreSetting,
setSetting,
validateSettingsSave,
} = await import("../../plugins/settings.js");
function manifest(settings: unknown): PluginManifest {
@@ -440,3 +455,86 @@ describe("readCoreSetting", () => {
}
});
});
describe("settings validators", () => {
it("collects every validator's errors for its scope only", async () => {
const stop = onSettingsValidate("validated", "admin", (values) =>
values.url === "bad" ? { url: "Nope" } : undefined,
);
onSettingsValidate("validated", "user", () => ({ other: "Wrong scope" }));
expect(
await validateSettingsSave("validated", "admin", null, { url: "bad" }),
).toEqual({ url: "Nope" });
expect(
await validateSettingsSave("validated", "admin", null, { url: "ok" }),
).toEqual({});
stop();
clearSettingsListeners("validated");
expect(await validateSettingsSave("validated", "user", null, {})).toEqual(
{},
);
});
it("passes the host id to a host validator", async () => {
let seen: number | undefined;
onSettingsValidate("validated-host", "host", (_values, context) => {
seen = context.hostId;
});
await validateSettingsSave("validated-host", "host", "12", {});
expect(seen).toBe(12);
clearSettingsListeners("validated-host");
});
});
describe("listHostValues", () => {
const withHostField = manifest({
host: {
enableKey: "on",
enableLabelKey: "x",
fields: [{ key: "token", type: "secret", labelKey: "x" }],
},
});
it("lists stored values with each host's owner", async () => {
hostOwners.set(7, "alice");
hostOwners.set(8, "bob");
await setSetting(withHostField, "host", 7, "on", true);
await setSetting(withHostField, "host", 8, "on", false);
await setSetting(withHostField, "host", 99, "on", true);
const listed = await listHostValues(withHostField, "on");
expect(listed).toEqual(
expect.arrayContaining([
{ hostId: 7, userId: "alice", value: true },
{ hostId: 8, userId: "bob", value: false },
]),
);
// A row for a host that no longer exists is skipped.
expect(listed.find((entry) => entry.hostId === 99)).toBeUndefined();
});
it("refuses a secret field", async () => {
await expect(listHostValues(withHostField, "token")).rejects.toThrow(
/secret/,
);
});
});
describe("enableDefault", () => {
it("is what the host switch reads before a host saves it", () => {
const fields = declaredFields(
manifest({
host: {
enableKey: "on",
enableLabelKey: "x",
enableDefault: true,
fields: [],
},
}),
"host",
);
expect(fields[0]).toMatchObject({ key: "on", default: true });
});
});
@@ -0,0 +1,67 @@
/**
* The CLI's ?url import: the file lands under dist/assets/ and the import
* becomes a URL relative to the bundle, which /plugin-assets/<id>/ serves.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import * as esbuild from "esbuild";
import { afterEach, describe, expect, it } from "vitest";
// @ts-expect-error the CLI is plain ESM without types
import { staticUrlImports } from "../../../../packages/plugin-sdk/cli/lib/static-url-imports.mjs";
const dirs: string[] = [];
function tempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-url-"));
dirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of dirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
async function bundle(entrySource: string, files: Record<string, string>) {
const dir = tempDir();
for (const [name, contents] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), contents);
}
fs.writeFileSync(path.join(dir, "entry.js"), entrySource);
const outDir = path.join(dir, "dist");
const result = await esbuild.build({
entryPoints: [path.join(dir, "entry.js")],
outfile: path.join(outDir, "frontend.js"),
bundle: true,
format: "esm",
write: true,
logLevel: "silent",
plugins: [staticUrlImports({ outDir })],
});
return { outDir, result };
}
describe("staticUrlImports", () => {
it("copies a worker next to the bundle and imports its URL", async () => {
const { outDir } = await bundle(
`import url from "./worker.mjs?url"; export default url;`,
{ "worker.mjs": "self.onmessage = () => {};" },
);
const assets = fs.readdirSync(path.join(outDir, "assets"));
expect(assets).toHaveLength(1);
expect(assets[0]).toMatch(/^worker-[0-9a-f]{8}\.js$/);
const code = fs.readFileSync(path.join(outDir, "frontend.js"), "utf8");
expect(code).toContain(`new URL("./assets/${assets[0]}", import.meta.url)`);
});
it("refuses a file the asset route would not serve", async () => {
await expect(
bundle(`import url from "./image.png?url"; export default url;`, {
"image.png": "not really",
}),
).rejects.toThrow(/only script files/);
});
});
@@ -0,0 +1,268 @@
/**
* The quick upgrade check: a 2.8 database with rows in the tables plugins
* adopt, host columns plugins took over and settings keys they moved boots
* into 2.9.0, and the data reads back through the plugins' own APIs.
*/
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
assertPluginsBuilt,
bootCore,
create28Database,
type BootedCore,
} from "./built-harness.js";
const BOOT_TIMEOUT = 180_000;
const USER = "user-upgrade";
let core: BootedCore;
let app: express.Express;
let token: string;
const ids: Record<string, number> = {};
function seed() {
const { sqlite, insert } = create28Database();
insert("users", {
id: USER,
username: "admin",
password_hash: "not-a-real-hash",
is_admin: 1,
});
// A host that changed every plugin-owned switch away from its default...
ids.custom = insert("ssh_data", {
user_id: USER,
name: "custom",
ip: "10.0.0.1",
port: 22,
username: "root",
auth_type: "password",
enable_terminal: 1,
enable_command_history: 0,
enable_terminal_toolbar: 0,
enable_session_logging: 0,
allow_session_sharing: 0,
enable_file_manager: 0,
default_path: "/srv/data",
scp_legacy: 1,
enable_tunnel: 1,
tunnel_connections: [
{
sourcePort: 8080,
endpointPort: 80,
endpointHost: "custom",
autoStart: false,
},
],
enable_web_ui: 1,
web_ui_config: {
endpoints: [
{ id: "grafana", label: "Grafana", port: 3000, protocol: "http" },
],
},
enable_tmux_monitor: 1,
enable_docker: 1,
});
// ...and one that never touched them, which must keep 2.8's defaults.
ids.plain = insert("ssh_data", {
user_id: USER,
name: "plain",
ip: "10.0.0.2",
port: 22,
username: "root",
auth_type: "password",
});
insert("snippets", {
user_id: USER,
name: "upgrade-snippet",
content: "uptime",
});
insert("fleets", { user_id: USER, name: "upgrade-fleet" });
insert("user_workspaces", {
user_id: USER,
name: "upgrade-workspace",
payload: {},
});
insert("network_topology", {
user_id: USER,
topology: { nodes: [{ id: "upgrade-node" }], edges: [] },
});
insert("automations", {
user_id: USER,
name: "upgrade-automation",
enabled: 0,
definition: {
version: 1,
trigger: { type: "manual" },
steps: [],
},
});
insert("homepage_items", {
user_id: USER,
type_id: "clock",
title: "upgrade-widget",
config: {},
});
insert("vault_profiles", {
user_id: USER,
name: "upgrade-vault",
vault_addr: "https://vault.example.com",
ssh_role: "admin",
});
insert("termix_identities", { user_id: USER, handle: "upgrade-handle" });
insert("sso_providers", {
name: "upgrade-sso",
type: "oidc",
config: {
issuerUrl: "https://id.example.com",
clientId: "termix",
clientSecret: "secret",
},
});
insert("settings", { key: "terminal_session_timeout_minutes", value: "45" });
insert("settings", { key: "step_ca_url", value: "https://ca.example.com" });
return sqlite;
}
beforeAll(async () => {
assertPluginsBuilt();
vi.resetModules();
core = await bootCore(seed());
const { AuthManager } = await import("../../utils/auth-manager.js");
const auth = AuthManager.getInstance();
await auth.registerUser(USER);
await auth.unlockWithSystemKey(USER);
token = await auth.generateJWTToken(USER, {
deviceType: "web",
deviceInfo: "upgrade test",
});
const { mountPluginApi } =
await import("../../database/routes/plugin-api-routes.js");
const { default: pluginRoutes } =
await import("../../database/routes/plugins.js");
app = express();
app.use(express.json());
app.use("/plugins", pluginRoutes);
mountPluginApi(app);
}, BOOT_TIMEOUT);
afterAll(async () => {
await core?.shutdown();
}, BOOT_TIMEOUT);
function get(path: string) {
return request(app).get(path).set("Authorization", `Bearer ${token}`);
}
async function hostSettings(pluginId: string, hostId: number) {
const response = await get(`/plugins/${pluginId}/settings/host/${hostId}`);
expect(response.status, `${pluginId} host settings`).toBe(200);
return response.body.values as Record<string, unknown>;
}
describe("upgrading a 2.8 database", () => {
it("renames every adopted table the 2.8 schema had", () => {
const tables = new Set(
(
core.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all() as Array<{ name: string }>
).map((row) => row.name),
);
for (const legacy of [
"snippets",
"fleets",
"user_workspaces",
"network_topology",
"automations",
"homepage_items",
"vault_profiles",
"termix_identities",
"sso_providers",
]) {
expect(tables.has(legacy), `${legacy} was adopted`).toBe(false);
}
});
it("reads the adopted rows back through each plugin's routes", async () => {
const checks: Array<[string, string]> = [
["/plugin-api/snippets/", "upgrade-snippet"],
["/plugin-api/fleets/", "upgrade-fleet"],
["/plugin-api/workspaces/", "upgrade-workspace"],
["/plugin-api/network-topology/", "upgrade-node"],
["/plugin-api/automations/", "upgrade-automation"],
["/plugin-api/homepage/items", "upgrade-widget"],
["/plugin-api/vault/profiles", "upgrade-vault"],
["/plugin-api/termix-identity/me", "upgrade-handle"],
["/plugin-api/sso/providers", "upgrade-sso"],
];
for (const [path, marker] of checks) {
const response = await get(path);
expect(response.status, path).toBe(200);
expect(JSON.stringify(response.body), path).toContain(marker);
}
});
it("moves every changed host switch into its plugin's host settings", async () => {
expect(await hostSettings("ssh-terminal", ids.custom)).toMatchObject({
enableTerminal: true,
enableCommandHistory: false,
enableTerminalToolbar: false,
});
expect(await hostSettings("session-recording", ids.custom)).toMatchObject({
enableSessionRecording: false,
});
expect(await hostSettings("session-sharing", ids.custom)).toMatchObject({
allowSessionSharing: false,
});
expect(await hostSettings("file-manager", ids.custom)).toMatchObject({
enableFileManager: false,
defaultPath: "/srv/data",
scpLegacy: true,
});
const tunnels = await hostSettings("tunnels", ids.custom);
expect(tunnels.enableTunnel).toBe(true);
expect(JSON.stringify(tunnels.tunnelConnections)).toContain("8080");
const web = await hostSettings("web-endpoint", ids.custom);
expect(web.enableWebUi).toBe(true);
expect(JSON.stringify(web.webUiConfig)).toContain("Grafana");
expect(await hostSettings("tmux-monitor", ids.custom)).toMatchObject({
enableTmuxMonitor: true,
});
expect(await hostSettings("docker", ids.custom)).toMatchObject({
enableDocker: true,
});
});
it("keeps 2.8's defaults for a host that never changed them", async () => {
expect(await hostSettings("file-manager", ids.plain)).toMatchObject({
enableFileManager: true,
});
expect(await hostSettings("ssh-terminal", ids.plain)).toMatchObject({
enableTerminal: true,
enableCommandHistory: true,
});
expect(await hostSettings("session-recording", ids.plain)).toMatchObject({
enableSessionRecording: true,
});
expect(await hostSettings("session-sharing", ids.plain)).toMatchObject({
allowSessionSharing: true,
});
});
it("moves core settings keys into plugin settings", async () => {
const terminal = await get("/plugins/ssh-terminal/settings/admin");
expect(terminal.status).toBe(200);
expect(terminal.body.values.sessionTimeoutMinutes).toBe(45);
const stepCa = await get("/plugins/step-ca/settings/admin");
expect(stepCa.status).toBe(200);
expect(stepCa.body.values.caUrl).toBe("https://ca.example.com");
});
});
+9
View File
@@ -340,3 +340,12 @@ describe("parsePluginWsUrl", () => {
expect(ws.parsePluginWsUrl(undefined)).toBeNull();
});
});
describe("ctx.ws clashes", () => {
it("refuses the same path twice", () => {
ws.registerPluginWsRoute("sample-plugin", "/twice", () => {}, DECLARED);
expect(() =>
ws.registerPluginWsRoute("sample-plugin", "/twice", () => {}, DECLARED),
).toThrow(/already registered/);
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* Custom certificate paths set in the environment survive a boot: they are
* neither replaced with a self-signed certificate nor reset in DATA_DIR/.env.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const execSync = vi.hoisted(() => vi.fn());
vi.mock("child_process", () => ({ execSync }));
vi.mock("../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
};
return { systemLogger: log };
});
const saved = { ...process.env };
let dataDir: string;
beforeEach(() => {
vi.resetModules();
execSync.mockReset();
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-ssl-"));
process.env.DATA_DIR = dataDir;
process.env.ENABLE_SSL = "true";
});
afterEach(() => {
process.env = { ...saved };
fs.rmSync(dataDir, { recursive: true, force: true });
});
describe("AutoSSLSetup with custom paths", () => {
it("keeps the operator's certificate and paths", async () => {
process.env.SSL_CERT_PATH = path.join(dataDir, "mine", "cert.pem");
process.env.SSL_KEY_PATH = path.join(dataDir, "mine", "key.pem");
process.env.SSL_DOMAIN = "termix.example.com";
const { AutoSSLSetup } = await import("../../tls/self-signed.js");
await AutoSSLSetup.initialize();
expect(execSync).not.toHaveBeenCalled();
const env = fs.readFileSync(path.join(dataDir, ".env"), "utf8");
expect(env).toContain(`SSL_CERT_PATH=${process.env.SSL_CERT_PATH}`);
expect(env).toContain("SSL_DOMAIN=termix.example.com");
});
});
@@ -0,0 +1,53 @@
/**
* An admin-allowlisted private host goes through the configured proxy; a
* public host never does, since the proxy would skip the address check.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
calls: [] as Array<{ url: string; dispatcher: unknown }>,
proxy: { kind: "proxy" } as unknown,
proxyConfigured: true,
}));
vi.mock("undici", () => ({
Agent: class {
kind = "direct";
async close() {}
},
fetch: async (url: string, init: { dispatcher: unknown }) => {
h.calls.push({ url, dispatcher: init.dispatcher });
return new Response("ok");
},
}));
vi.mock("../../utils/proxy-agent.js", () => ({
getProxyAgent: () => (h.proxyConfigured ? h.proxy : undefined),
}));
const { safeOutboundFetch } =
await import("../../utils/safe-outbound-fetch.js");
beforeEach(() => {
h.calls.length = 0;
h.proxyConfigured = true;
});
describe("safeOutboundFetch and the proxy", () => {
it("sends an allowlisted private host through the proxy", async () => {
await safeOutboundFetch("http://10.0.0.5:11434/api", {}, ["10.0.0.5"]);
expect(h.calls[0].dispatcher).toBe(h.proxy);
});
it("keeps a public host on the checked direct connection", async () => {
await safeOutboundFetch("https://api.example.com/v1", {}, []);
expect(h.calls[0].dispatcher).not.toBe(h.proxy);
});
it("connects directly when no proxy is configured", async () => {
h.proxyConfigured = false;
await safeOutboundFetch("http://10.0.0.5/api", {}, ["10.0.0.5"]);
expect(h.calls[0].dispatcher).not.toBe(h.proxy);
});
});
@@ -11,7 +11,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_session_logging: boolean | null;
enable_session_logging: boolean | number | null;
}
interface SettingsRow {
@@ -112,6 +112,16 @@ describe("runSessionRecordingSettingsMigration", () => {
expect(storedValue(3, "enableSessionRecording")).toBe(false);
});
it("reads SQLite 0 and 1 as off and on", async () => {
hostRows.push({ id: 6, enable_session_logging: 0 });
hostRows.push({ id: 7, enable_session_logging: 1 });
await runSessionRecordingSettingsMigration();
expect(storedValue(6, "enableSessionRecording")).toBe(false);
expect(storedValue(7, "enableSessionRecording")).toBe(true);
});
it("treats a null legacy value as on, matching the old column default", async () => {
hostRows.push({ id: 4, enable_session_logging: null });
@@ -167,6 +167,18 @@ describe("runTotpMigration", () => {
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 1 });
});
it("moves only the named user when a login opens their key", async () => {
setup({ pluginTable: true });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", []);
addTotpUser("bob", "KRSXG5CTMVRXEZLU", []);
expect(await runTotpMigration("alice")).toEqual({ factors: 1, secrets: 1 });
const moved = sqlite
.prepare("SELECT user_id FROM p_totp_enrollments")
.all();
expect(moved).toEqual([{ user_id: "alice" }]);
});
it("drops a duplicate core row when the plugin row already exists", async () => {
setup({ pluginTable: true });
sqlite.exec(`
Loaded 100 of 335 files, more files were not shown because too many files have changed in this diff. Show more