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

+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;
};