chore: lock the plugin boundary and remove 2.9.0 leftovers

This commit is contained in:
LukeGus committed 2026-09-25 07:24:46 -05:00
1 parent 6489b62ece
commit 2961297989
399 files changed
+3964 -5627

No files matched your search

+1 -1
View File
@@ -99,7 +99,7 @@ export async function verifyPasswordLogin(request: {
};
}
export const passwordLoginMethod: LoginMethod = {
const passwordLoginMethod: LoginMethod = {
id: "password",
pluginId: "core",
labelKey: "auth.password",
+2 -2
View File
@@ -24,7 +24,7 @@ export function resetCoreLoginProvidersForTests(): void {
}
/** The admin setting (or ALLOW_PASSWORD_LOGIN), before the lockout guard. */
export function isPasswordLoginSettingOn(): boolean {
function isPasswordLoginSettingOn(): boolean {
const envVal = process.env.ALLOW_PASSWORD_LOGIN;
if (envVal !== undefined) return envVal.trim().toLowerCase() === "true";
try {
@@ -77,7 +77,7 @@ export function isSecondFactorAfterExternalLoginEnabled(): boolean {
* Whether anyone could still sign in without a password: trusted proxy auth,
* or a login method reporting an enabled instance (an SSO provider).
*/
export async function hasOtherEnabledLoginMethod(): Promise<boolean> {
async function hasOtherEnabledLoginMethod(): Promise<boolean> {
if (isTrustedProxyAuthEnabled()) return true;
ensureCoreLoginProviders();
for (const method of listLoginMethods()) {
+4 -5
View File
@@ -48,7 +48,7 @@ import {
} from "./types.js";
const PENDING_TTL_MS = 10 * 60 * 1000;
export const PENDING_LOGIN_COOKIE = "termix_pending_login";
const PENDING_LOGIN_COOKIE = "termix_pending_login";
const pendingLogins = new Map<string, PendingLogin>();
@@ -235,8 +235,7 @@ export async function runLogin(
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");
const { runTotpMigration } = await import("../upgrade/totp-migration.js");
await runTotpMigration(user.id);
}
await syncSharedCredentialsForUserRoles(
@@ -318,7 +317,7 @@ export async function runLogin(
}
/** JSON form of a login result, the shape the login screen expects. */
export function secondFactorBody(
function secondFactorBody(
result: Extract<LoginResult, { kind: "second-factor" }>,
): Record<string, unknown> {
return {
@@ -379,7 +378,7 @@ export async function readPendingLogin(
return { userId: decoded.userId, token, pending };
}
export function consumePendingLogin(token: string): void {
function consumePendingLogin(token: string): void {
pendingLogins.delete(pendingKey(token));
}
+3 -3
View File
@@ -31,7 +31,7 @@ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** SSO auto-provisioning: the admin setting, or OIDC_ALLOW_REGISTRATION. */
export async function isExternalProvisioningAllowed(): Promise<boolean> {
async function isExternalProvisioningAllowed(): Promise<boolean> {
try {
if (
await createCurrentSettingsRepository().getBoolean(
@@ -54,7 +54,7 @@ export async function isExternalProvisioningAllowed(): Promise<boolean> {
* approval queue yet, so nobody does; a plugin or a later release can change
* that here without touching any login method.
*/
export async function isApprovalRequired(
async function isApprovalRequired(
_identity: ExternalIdentity,
): Promise<boolean> {
return false;
@@ -103,7 +103,7 @@ async function syncAdmin(
* Adds and removes the roles a provider's group map manages. Roles assigned
* by hand, and roles outside the map, are never touched.
*/
export async function applyRoleSync(
async function applyRoleSync(
userId: string,
roleSync: { desired: string[]; managed: string[] },
): Promise<void> {
+1 -3
View File
@@ -27,9 +27,7 @@ export function isNativeAppRequest(req: Request): boolean {
}
/** Cookie lifetime: 30 days when remembered, else session_timeout_hours. */
export async function sessionCookieMaxAge(
rememberMe: boolean,
): Promise<number> {
async function sessionCookieMaxAge(rememberMe: boolean): Promise<number> {
if (rememberMe) return THIRTY_DAYS_MS;
const value = await createCurrentSettingsRepository().get(
"session_timeout_hours",
+1 -1
View File
@@ -39,7 +39,7 @@ export async function runCoreBootMigrations(): Promise<void> {
await runChannelConfigEncryptionMigration();
const { runExternalIdentityMigration } =
await import("./utils/crypto-migration/external-identity-migration.js");
await import("./upgrade/external-identity-migration.js");
await runExternalIdentityMigration();
const { runHostStatusConfigMigration } =
+17 -215
View File
@@ -7,10 +7,6 @@ import multer from "multer";
import cookieParser from "cookie-parser";
import userRoutes from "./routes/users.js";
import hostRoutes from "./routes/host.js";
import {
registerTermixIdCompatRoutes,
registerVaultCompatRoutes,
} from "./routes/host-compat-routes.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
@@ -44,6 +40,10 @@ import { DataCrypto } from "../utils/data-crypto.js";
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
import { DatabaseMigration } from "../utils/database-migration.js";
import { UserDataExport } from "../utils/user-data-export.js";
import {
importUserPluginRows,
writeUserPluginTables,
} from "../plugins/user-data.js";
import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
import {
@@ -65,8 +65,7 @@ import type {
GitHubAPIResponse,
AuthenticatedRequest,
} from "../../types/index.js";
import { DatabaseSaveTrigger, getDb } from "./db/index.js";
import { sql } from "drizzle-orm";
import { DatabaseSaveTrigger } from "./db/index.js";
import Database from "better-sqlite3";
import { fileURLToPath } from "url";
@@ -867,43 +866,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE p_file_manager_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE p_file_manager_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE p_file_manager_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE p_file_manager_transfer_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
source_host_id INTEGER NOT NULL,
dest_host_id INTEGER NOT NULL,
dest_path TEXT NOT NULL,
dest_path_label TEXT NOT NULL,
last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -1043,86 +1005,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
// The file manager plugin owns these tables now (p_file_manager_*);
// read them by raw SQL rather than importing plugin code into core.
const drizzleDb = getDb();
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
drizzleDb.all<{
id: number;
user_id: string;
host_id: number;
name: string;
path: string;
last_opened: string;
}>(
sql`SELECT id, user_id, host_id, name, path, last_opened FROM p_file_manager_recent WHERE user_id = ${userId}`,
),
drizzleDb.all<{
id: number;
user_id: string;
host_id: number;
name: string;
path: string;
pinned_at: string;
}>(
sql`SELECT id, user_id, host_id, name, path, pinned_at FROM p_file_manager_pinned WHERE user_id = ${userId}`,
),
drizzleDb.all<{
id: number;
user_id: string;
host_id: number;
name: string;
path: string;
created_at: string;
}>(
sql`SELECT id, user_id, host_id, name, path, created_at FROM p_file_manager_shortcuts WHERE user_id = ${userId}`,
),
]);
const insertRecent = exportDb.prepare(`
INSERT INTO p_file_manager_recent (id, user_id, host_id, name, path, last_opened)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of recentFiles) {
insertRecent.run(
item.id,
item.user_id,
item.host_id,
item.name,
item.path,
item.last_opened,
);
}
const insertPinned = exportDb.prepare(`
INSERT INTO p_file_manager_pinned (id, user_id, host_id, name, path, pinned_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of pinnedFiles) {
insertPinned.run(
item.id,
item.user_id,
item.host_id,
item.name,
item.path,
item.pinned_at,
);
}
const insertShortcut = exportDb.prepare(`
INSERT INTO p_file_manager_shortcuts (id, user_id, host_id, name, path, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of shortcuts) {
insertShortcut.run(
item.id,
item.user_id,
item.host_id,
item.name,
item.path,
item.created_at,
);
}
// Rows the user owns in plugin tables travel with the export.
await writeUserPluginTables(exportDb, userId);
const dismissedAlertRepository = createCurrentDismissedAlertRepository();
const alerts = await dismissedAlertRepository.listByUserId(userId);
@@ -1332,7 +1216,7 @@ app.post(
summary: {
sshHostsImported: 0,
sshCredentialsImported: 0,
fileManagerItemsImported: 0,
pluginItemsImported: 0,
dismissedAlertsImported: 0,
credentialUsageImported: 0,
settingsImported: 0,
@@ -1477,90 +1361,10 @@ app.post(
);
}
// The file manager plugin owns these tables now (p_file_manager_*).
// Older export files still have the legacy name, so try that too.
const fileManagerTables = [
{
tableNames: ["p_file_manager_recent", "file_manager_recent"],
physicalTable: "p_file_manager_recent",
dateColumn: "last_opened",
key: "fileManagerItemsImported",
},
{
tableNames: ["p_file_manager_pinned", "file_manager_pinned"],
physicalTable: "p_file_manager_pinned",
dateColumn: "pinned_at",
key: "fileManagerItemsImported",
},
{
tableNames: [
"p_file_manager_shortcuts",
"file_manager_shortcuts",
],
physicalTable: "p_file_manager_shortcuts",
dateColumn: "created_at",
key: "fileManagerItemsImported",
},
];
const importDrizzleDb = getDb();
for (const {
tableNames,
physicalTable,
dateColumn,
key,
} of fileManagerTables) {
let importedItems: Array<Record<string, unknown>> | null = null;
let sourceTable = "";
for (const tableName of tableNames) {
try {
importedItems = importDb
.prepare(`SELECT * FROM ${tableName}`)
.all() as Array<Record<string, unknown>>;
sourceTable = tableName;
break;
} catch {
// try the next name
}
}
if (importedItems === null) {
apiLogger.info(
`${physicalTable} table not found in import file, skipping`,
);
continue;
}
for (const item of importedItems) {
try {
const hostId = item.host_id as number;
const path = item.path as string;
const name =
(item.name as string) || path.split("/").pop() || "Unknown";
const dateValue =
(item[dateColumn] as string) || new Date().toISOString();
const existing = await importDrizzleDb.all<{ id: number }>(
sql`SELECT id FROM ${sql.raw(physicalTable)} WHERE user_id = ${userId} AND host_id = ${hostId} AND path = ${path}`,
);
if (existing.length > 0) {
result.summary.skippedItems++;
continue;
}
await importDrizzleDb.run(
sql`INSERT INTO ${sql.raw(physicalTable)} (user_id, host_id, path, name, ${sql.raw(dateColumn)}) VALUES (${userId}, ${hostId}, ${path}, ${name}, ${dateValue})`,
);
result.summary[key]++;
} catch (itemError) {
result.summary.errors.push(
`${sourceTable} import error: ${itemError.message}`,
);
}
}
}
const pluginRows = await importUserPluginRows(importDb, userId);
result.summary.pluginItemsImported += pluginRows.imported;
result.summary.skippedItems += pluginRows.skipped;
result.summary.errors.push(...pluginRows.errors);
const dismissedAlertRepository =
createCurrentDismissedAlertRepository();
@@ -1819,13 +1623,7 @@ app.use("/user-preferences", userPreferencesRoutes);
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes);
const termixIdCompatRoutes = express.Router();
registerTermixIdCompatRoutes(termixIdCompatRoutes);
app.use("/termix-id", termixIdCompatRoutes);
registerAuditLogRoutes(app, authenticateJWT);
const vaultCompatRoutes = express.Router();
registerVaultCompatRoutes(vaultCompatRoutes);
app.use("/vault", vaultCompatRoutes);
app.use("/", notificationChannelsRoutes);
app.use("/sync", syncRoutes);
app.use("/dashboard", dashboardRoutes);
@@ -1842,8 +1640,12 @@ mountPluginLegacyPaths(app, () =>
.map((plugin) => ({
id: plugin.id,
legacyPaths: plugin.manifest.contributes?.http?.legacyPaths ?? [],
legacyRedirects: plugin.manifest.contributes?.http?.legacyRedirects ?? [],
}))
.filter((plugin) => plugin.legacyPaths.length > 0),
.filter(
(plugin) =>
plugin.legacyPaths.length > 0 || plugin.legacyRedirects.length > 0,
),
);
const frontendDistPaths = [
+2 -48
View File
@@ -322,11 +322,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
-- file_manager_recent, file_manager_pinned, file_manager_shortcuts and
-- transfer_recent are owned by the file-manager plugin now. Its own
-- migration creates p_file_manager_* on a fresh install and adopts the
-- legacy tables by rename on an upgrade.
CREATE TABLE IF NOT EXISTS dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -391,9 +386,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
-- command_history is owned by the ssh-terminal plugin, which adopts it
-- as p_ssh_terminal_command_history in its first migration.
CREATE TABLE IF NOT EXISTS host_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
@@ -1120,10 +1112,6 @@ const migrateSchema = () => {
});
}
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
try {
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
} catch {
@@ -1315,25 +1303,6 @@ const migrateSchema = () => {
}
}
// Rename the legacy "stats" tab type to "host-metrics" so previously saved
// open tabs restore correctly after the Server Stats -> Host Metrics rename.
try {
sqlite.prepare("SELECT id FROM user_open_tabs LIMIT 1").get();
const result = sqlite
.prepare(
"UPDATE user_open_tabs SET tab_type = 'host-metrics' WHERE tab_type = 'stats'",
)
.run();
if (result.changes > 0) {
databaseLogger.info(
`Migrated ${result.changes} open tab(s) from 'stats' to 'host-metrics'`,
{ operation: "open_tabs_tab_type_migration" },
);
}
} catch {
// user_open_tabs table not present yet; nothing to migrate.
}
try {
sqlite.prepare("SELECT id FROM shared_host_secrets LIMIT 1").get();
} catch {
@@ -1619,8 +1588,6 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "sync_id", "TEXT");
addColumnIfNotExists("ssh_credentials", "sync_id", "TEXT");
addColumnIfNotExists("ssh_folders", "sync_id", "TEXT");
addColumnIfNotExists("snippets", "sync_id", "TEXT");
addColumnIfNotExists("snippet_folders", "sync_id", "TEXT");
// Plugin runtime: lastError reports why a plugin is blocked or failed, and
// grants record whether a capability came from an admin or from bundling.
@@ -1632,13 +1599,7 @@ const migrateSchema = () => {
);
relaxPluginGrantGrantedBy();
const syncIdTables = [
"ssh_data",
"ssh_credentials",
"ssh_folders",
"snippets",
"snippet_folders",
];
const syncIdTables = ["ssh_data", "ssh_credentials", "ssh_folders"];
for (const table of syncIdTables) {
try {
@@ -1978,14 +1939,7 @@ export function getSqlite(): Database.Database {
return sqlite;
}
export { db };
export { DatabaseFileEncryption };
export const databasePaths = {
main: actualDbPath,
encrypted: encryptedDbPath,
directory: dbDir,
inMemory: true,
};
export { saveMemoryDatabaseToFile };
@@ -98,16 +98,12 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
columns: "user_id",
},
// Per-user history and file manager surfaces.
{
name: "idx_recent_activity_user_ts",
table: "recent_activity",
columns: "user_id, timestamp",
},
// file_manager_recent, file_manager_pinned, file_manager_shortcuts,
// transfer_recent and command_history are owned by plugins now; their
// adopted table definitions carry these same indexes across the rename.
{
name: "idx_user_open_tabs_user_id",
table: "user_open_tabs",
+2 -20
View File
@@ -271,9 +271,6 @@ export const hosts = mysqlTable(
],
);
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
export const dismissedAlerts = mysqlTable(
"dismissed_alerts",
{
@@ -317,7 +314,6 @@ export const sshCredentials = mysqlTable(
certPublicKey: text("cert_public_key"),
usageCount: int("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: varchar("sync_id", { length: 255 }).unique(),
@@ -724,11 +720,6 @@ export const uiPreferences = mysqlTable("ui_preferences", {
.default(sql`(CURRENT_TIMESTAMP)`),
});
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
// termix-identity plugin (p_termix_identity_*).
// --- alerts begin ---
export const notificationChannels = mysqlTable("notification_channels", {
id: int("id").autoincrement().primaryKey(),
@@ -745,12 +736,6 @@ export const notificationChannels = mysqlTable("notification_channels", {
});
// --- alerts end ---
// homepage_items and homepage_layouts moved to the homepage plugin
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
@@ -768,14 +753,11 @@ export const syncTombstones = mysqlTable("sync_tombstones", {
});
// --- sync end ---
// --- collab rooms ---
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
* Who may use or manage someone else's credential. "use" attaches it to
* hosts and connects, "manage" also edits and re-shares it.
*/
export const credentialAccess = mysqlTable(
"credential_access",
+2 -20
View File
@@ -272,9 +272,6 @@ export const hosts = pgTable(
],
);
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
export const dismissedAlerts = pgTable(
"dismissed_alerts",
{
@@ -318,7 +315,6 @@ export const sshCredentials = pgTable(
certPublicKey: text("cert_public_key"),
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: varchar("sync_id", { length: 255 }).unique(),
@@ -725,11 +721,6 @@ export const uiPreferences = pgTable("ui_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
// termix-identity plugin (p_termix_identity_*).
// --- alerts begin ---
export const notificationChannels = pgTable("notification_channels", {
id: serial("id").primaryKey(),
@@ -746,12 +737,6 @@ export const notificationChannels = pgTable("notification_channels", {
});
// --- alerts end ---
// homepage_items and homepage_layouts moved to the homepage plugin
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
@@ -769,14 +754,11 @@ export const syncTombstones = pgTable("sync_tombstones", {
});
// --- sync end ---
// --- collab rooms ---
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
* Who may use or manage someone else's credential. "use" attaches it to
* hosts and connects, "manage" also edits and re-shares it.
*/
export const credentialAccess = pgTable(
"credential_access",
+2 -20
View File
@@ -264,9 +264,6 @@ export const hosts = sqliteTable(
],
);
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
export const dismissedAlerts = sqliteTable(
"dismissed_alerts",
{
@@ -310,7 +307,6 @@ export const sshCredentials = sqliteTable(
certPublicKey: text("cert_public_key", { length: 8192 }),
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: text("sync_id").unique(),
@@ -721,11 +717,6 @@ export const uiPreferences = sqliteTable("ui_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
// termix-identity plugin (p_termix_identity_*).
// --- alerts begin ---
export const notificationChannels = sqliteTable("notification_channels", {
id: integer("id").primaryKey({ autoIncrement: true }),
@@ -742,12 +733,6 @@ export const notificationChannels = sqliteTable("notification_channels", {
});
// --- alerts end ---
// homepage_items and homepage_layouts moved to the homepage plugin
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
@@ -765,14 +750,11 @@ export const syncTombstones = sqliteTable("sync_tombstones", {
});
// --- sync end ---
// --- collab rooms ---
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
* Who may use or manage someone else's credential. "use" attaches it to
* hosts and connects, "manage" also edits and re-shares it.
*/
export const credentialAccess = sqliteTable(
"credential_access",
@@ -21,8 +21,6 @@ import { PluginSettingsRepository } from "./plugin-settings-repository.js";
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
import { UserAuthRepository } from "./user-auth-repository.js";
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
import { RbacAccessRepository } from "./rbac-access-repository.js";
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
import { RecentActivityRepository } from "./recent-activity-repository.js";
@@ -163,9 +161,6 @@ export function createCurrentDismissedAlertRepository(): DismissedAlertRepositor
);
}
// File manager recent/pinned/shortcuts moved to the file-manager plugin.
// Homepage items/layout and dashboard service links moved to the homepage plugin.
export function createCurrentHostFolderRepository(): HostFolderRepository {
return new HostFolderRepository(
createCurrentRepositoryContext(),
@@ -273,22 +268,6 @@ export function createCurrentRbacPermissionRepository(): RbacPermissionRepositor
);
}
export function createCurrentPluginRegistryRepository(): PluginRegistryRepository {
return new PluginRegistryRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("plugin_registry_repository_write"),
);
}
export function createCurrentPluginInstallCountRepository(): PluginInstallCountRepository {
return new PluginInstallCountRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryLazyWriteHook(
"plugin_install_count_repository_write",
),
);
}
export function createCurrentRbacAccessRepository(): RbacAccessRepository {
return new RbacAccessRepository(
createCurrentRepositoryContext(),
@@ -347,9 +326,6 @@ export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageR
);
}
// transfer_recent moved to the file-manager plugin.
// tmux_session_tags moved to the tmux-monitor plugin.
export function createCurrentTrustedDeviceRepository(): TrustedDeviceRepository {
return new TrustedDeviceRepository(
createCurrentRepositoryContext(),
@@ -19,8 +19,8 @@ export interface AppliedDefaultInput {
/**
* The two records that make plugin permissions outlive their plugin.
*
* `rbac_known_permissions` is why a role holding `ai.use` still saves while the
* ai plugin is disabled. `rbac_applied_defaults` is why a default an admin
* `rbac_known_permissions` is why a role holding a plugin permission
* (`<plugin>.use`) still saves while its plugin is disabled. `rbac_applied_defaults` is why a default an admin
* revoked is not handed back on the next boot. Neither has a foreign key to
* `plugins`, because both have to survive the plugin going away.
*/
@@ -9,13 +9,13 @@ export type UserExternalIdentityRecord =
export type UserSecondFactorRecord = typeof userSecondFactors.$inferSelect;
/** Subjects are indexed, so they stay within the portable key length. */
export const MAX_EXTERNAL_SUBJECT_LENGTH = 255;
const MAX_EXTERNAL_SUBJECT_LENGTH = 255;
/**
* Keeps a subject within the index length. Longer ones are hashed, which is
* stable, so the same provider answer always finds the same row.
*/
export function normalizeExternalSubject(subject: string): string {
function normalizeExternalSubject(subject: string): string {
if (subject.length <= MAX_EXTERNAL_SUBJECT_LENGTH) return subject;
return `sha256:${crypto.createHash("sha256").update(subject).digest("hex")}`;
}
@@ -1,57 +1,33 @@
/**
* The 2.8 login URLs that something outside Termix still depends on:
* identity providers configured with the old callback and back-channel
* logout URLs, and clients built against 2.8 such as Termix-Mobile. Each one
* forwards to the login method or plugin route that replaced it, so none of
* them can sign anyone in on its own.
* The 2.8 login URLs clients built against 2.8 (Termix-Mobile) still call.
* Each forwards to the login pipeline for the method it names, so none of
* them can sign anyone in on its own. Old identity provider callbacks are
* redirected by the owning plugin's manifest (contributes.http.legacyRedirects).
*/
import type { Request, Router } from "express";
import type { Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { getRequestBasePath } from "../../utils/request-origin.js";
import {
listLegacySsoProviders,
startRedirectLogin,
verifyFormLogin,
} from "./auth-routes.js";
/**
* A path on this install, keeping the query string. Relative, so the browser
* stays on the scheme and host it used, whatever a proxy told us.
*/
function pluginUrl(req: Request, path: string): string {
const query = req.originalUrl.indexOf("?");
return `${getRequestBasePath(req)}${path}${
query >= 0 ? req.originalUrl.slice(query) : ""
}`;
}
export function registerAuthCompatRoutes(router: Router): void {
/**
* @openapi
* /users/oidc-config:
* /users/{method}/authorize:
* get:
* summary: Get the default SSO provider's public configuration (2.8 route)
* description: Kept for 2.8 clients. Redirects to the sso plugin's /plugin-api/sso/config.
* tags:
* - Auth
* responses:
* 307:
* description: Redirect to the sso plugin.
*/
router.get("/oidc-config", (req, res) => {
res.redirect(307, pluginUrl(req, "/plugin-api/sso/config"));
});
/**
* @openapi
* /users/oidc/authorize:
* get:
* summary: Start an SSO login (2.8 route)
* description: Kept for 2.8 clients such as Termix-Mobile. Same as /users/auth/oidc/start, with the provider in providerId.
* summary: Start a redirect login (2.8 route)
* description: Kept for 2.8 clients such as Termix-Mobile, which call /users/oidc/authorize. Same as /users/auth/{method}/start, with the provider in providerId.
* tags:
* - Auth
* parameters:
* - in: path
* name: method
* required: true
* schema:
* type: string
* - in: query
* name: providerId
* schema:
@@ -64,59 +40,19 @@ export function registerAuthCompatRoutes(router: Router): void {
* 200:
* description: The authorization URL in auth_url.
* 404:
* description: SSO is not available.
* description: That login method is not available.
*/
router.get("/oidc/authorize", (req, res) =>
router.get("/:method/authorize", (req, res) =>
startRedirectLogin(
req,
res,
"oidc",
String(req.params.method),
typeof req.query.providerId === "string" && req.query.providerId
? req.query.providerId
: null,
),
);
/**
* @openapi
* /users/oidc/callback:
* get:
* summary: SSO callback (2.8 URL)
* description: The redirect URI identity providers were set up with before 2.9. Permanently redirects to /plugin-api/sso/callback with the same query, so existing provider configurations keep working.
* tags:
* - Auth
* responses:
* 308:
* description: Redirect to the sso plugin's callback.
* post:
* summary: SSO callback, form post (2.8 URL)
* description: Same as the GET form.
* tags:
* - Auth
* responses:
* 308:
* description: Redirect to the sso plugin's callback.
*/
router.all("/oidc/callback", (req, res) => {
res.redirect(308, pluginUrl(req, "/plugin-api/sso/callback"));
});
/**
* @openapi
* /users/oidc/backchannel-logout:
* post:
* summary: OIDC back-channel logout (2.8 URL)
* description: Permanently redirects to /plugin-api/sso/backchannel-logout, so identity providers set up before 2.9 keep working.
* tags:
* - Auth
* responses:
* 308:
* description: Redirect to the sso plugin.
*/
router.post("/oidc/backchannel-logout", (req, res) => {
res.redirect(308, pluginUrl(req, "/plugin-api/sso/backchannel-logout"));
});
/**
* @openapi
* /users/sso-providers:
@@ -140,25 +76,31 @@ export function registerAuthCompatRoutes(router: Router): void {
/**
* @openapi
* /users/ldap/login:
* /users/{method}/login:
* post:
* summary: LDAP login (2.8 route)
* description: Kept for 2.8 clients. Same as /users/auth/ldap/verify, with the provider in providerId.
* summary: Form login (2.8 route)
* description: Kept for 2.8 clients, which call /users/ldap/login. Same as /users/auth/{method}/verify, with the provider in providerId.
* tags:
* - Auth
* parameters:
* - in: path
* name: method
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Login successful, or a second factor is required.
* 401:
* description: Invalid credentials.
* 404:
* description: LDAP is not available.
* description: That login method is not available.
*/
router.post("/ldap/login", (req, res) =>
router.post("/:method/login", (req, res) =>
verifyFormLogin(
req,
res,
"ldap",
String(req.params.method),
req.body?.providerId != null ? String(req.body.providerId) : null,
),
);
@@ -130,7 +130,7 @@ router.get(
* properties:
* type:
* type: string
* enum: [terminal, file_manager, server_stats, tunnel, docker, telnet, vnc, rdp]
* description: An activity type a plugin's tab declares, lowercase letters, digits and underscores.
* hostId:
* type: integer
* hostName:
@@ -177,7 +177,7 @@ router.post(
if (result.status === "invalid_type") {
return res.status(400).json({
error:
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', 'docker', 'telnet', 'vnc', or 'rdp'",
"Invalid activity type. Use lowercase letters, digits and underscores.",
});
}
if (result.status === "rate_limited") {
@@ -48,9 +48,8 @@ export async function deleteUserAndRelatedData(
userId,
);
// session_recordings is retained rather than deleted, by design: it
// outlives the account. The session-recording plugin listens for
// user.deleted and anonymizes its own rows instead of cascading.
// Plugins drop or anonymize their own rows on user.deleted, or rely on
// their refUser() foreign keys cascading.
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
pluginEvents.emit(TOPICS.userDeleted, { userId });
@@ -68,16 +67,9 @@ export async function deleteUserAndRelatedData(
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
// file manager recent/pinned/shortcuts and transfer_recent cascade on the
// user's refUser() foreign key, as the file-manager plugin's adopted
// tables.
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
// snippets, snippet_folders and snippet_access cascade on the user's
// refUser() foreign key, as the snippets plugin's adopted tables.
await createCurrentHostFolderRepository().deleteByUserId(userId);
await createCurrentHostSidebarPreferenceRepository().deleteByUserId(userId);
@@ -88,12 +80,7 @@ export async function deleteUserAndRelatedData(
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
// homepage_items, homepage_layouts, dashboard_service_links and
// secret_sources cascade on the user's refUser() foreign key, as the
// homepage and secret-sources plugins' adopted tables. The secret
// source's token in ctx.secrets is cleaned up generically below, with
// every other plugin_settings row for this user. The opkssh and vault
// plugins' tables cascade the same way, and so do termix-identity's.
// Plugin tables with a refUser() column cascade on the user row.
await createCurrentOpenTabRepository().deleteByUserId(userId);
await createCurrentUserPreferenceRepository().deleteByUserId(userId);
@@ -1,101 +0,0 @@
/**
* The 2.8 auth callback URLs something outside Termix still depends on: the
* OPKSSH and Step CA redirect URIs registered with identity providers, and
* the Vault one listed in Vault OIDC roles.
* Each forwards to the plugin route that replaced it, so with the plugin off
* it signs nobody in.
*/
import type { Request, Router } from "express";
import { getRequestBasePath } from "../../utils/request-origin.js";
/** A path on this install, keeping the query string. */
function localUrl(req: Request, path: string): string {
const query = req.originalUrl.indexOf("?");
return `${getRequestBasePath(req)}${path}${
query >= 0 ? req.originalUrl.slice(query) : ""
}`;
}
export function registerHostAuthCompatRoutes(router: Router): void {
/**
* @openapi
* /host/opkssh-callback:
* get:
* summary: OPKSSH callback (2.8 URL)
* description: The redirect URI OPKSSH identity providers were set up with before 2.9. Redirects to /plugin-api/opkssh/callback with the same subpath and query, so existing provider configurations keep working.
* tags:
* - Auth
* responses:
* 307:
* description: Redirect to the opkssh plugin's callback.
*/
router.all(["/opkssh-callback", "/opkssh-callback/*rest"], (req, res) => {
const rest = req.path.slice("/opkssh-callback".length);
res.redirect(307, localUrl(req, `/plugin-api/opkssh/callback${rest}`));
});
/**
* @openapi
* /host/step-ca-callback:
* get:
* summary: Step CA callback (2.8 URL)
* description: The redirect URI Step CA identity providers were set up with before 2.9. Redirects to /plugin-api/step-ca/callback with the same query, so existing provider configurations keep working.
* tags:
* - Auth
* responses:
* 307:
* description: Redirect to the step-ca plugin's callback.
*/
router.get("/step-ca-callback", (req, res) => {
res.redirect(307, localUrl(req, "/plugin-api/step-ca/callback"));
});
}
export function registerVaultCompatRoutes(router: Router): void {
/**
* @openapi
* /vault/oidc/callback:
* get:
* summary: Vault OIDC callback (2.8 URL)
* description: The redirect URI Vault OIDC roles allowed before 2.9. Redirects to /plugin-api/vault/oidc/callback with the same query, so existing Vault roles keep working.
* tags:
* - Vault
* responses:
* 307:
* description: Redirect to the vault plugin's callback.
*/
router.get("/oidc/callback", (req, res) => {
res.redirect(307, localUrl(req, "/plugin-api/vault/oidc/callback"));
});
}
/**
* The 2.8 Termix ID resolver URLs, which servers fetch from provisioning
* scripts. Permanently redirected to the termix-identity plugin's public
* resolver; curl -L follows them.
*/
export function registerTermixIdCompatRoutes(router: Router): void {
/**
* @openapi
* /termix-id/u/{handle}:
* get:
* summary: Termix ID resolver (2.8 URL)
* description: Permanently redirects to /plugin-api/termix-identity/u/{handle}, and likewise for /termix-id/u/{handle}/{algo} and /termix-id/u/{handle}/ca, so servers provisioned with the old URL keep working.
* tags:
* - Termix ID
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* responses:
* 308:
* description: Redirect to the termix-identity plugin's resolver.
*/
router.get(["/u/:handle", "/u/:handle/:algo"], (req, res) => {
const rest = req.path.slice("/u".length);
res.redirect(308, localUrl(req, `/plugin-api/termix-identity/u${rest}`));
});
}
@@ -396,10 +396,7 @@ export function registerHostFolderRoutes(
const hostIds = hostsToDelete.map((host) => host.id);
if (hostIds.length > 0) {
// file manager recent/pinned/shortcuts, transfer_recent, command
// history and session recordings cascade on the host's refHost()
// foreign key, as the file-manager, ssh-terminal and
// session-recording plugins' adopted tables.
// Plugin rows tied to a host cascade on their refHost() foreign keys.
await createCurrentSshCredentialUsageRepository().deleteByHostIds(
hostIds,
@@ -80,7 +80,7 @@ export function containsOwnerPrivateAuthUpdate(
);
}
export const FOLDER_PATH_SEPARATOR = " / ";
const FOLDER_PATH_SEPARATOR = " / ";
/**
* Re-paths a folder string when its ancestor folder is renamed. Returns the new
@@ -132,7 +132,7 @@ 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(
function visibleHostPluginSettings(
values: HostPluginSettings,
host: Record<string, unknown>,
): HostPluginSettings {
+13 -7
View File
@@ -1,4 +1,6 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { isQuickConnectAuthType } from "../../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
import { applyFolderAccessRules } from "../../utils/folder-access-inheritance.js";
import { findUsableCredential } from "../../hosts/usable-credential.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
@@ -44,7 +46,6 @@ import {
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 { registerHostNetworkRoutes } from "./host-network-routes.js";
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
@@ -74,9 +75,9 @@ const upload = multer({ storage: multer.memoryStorage() });
/**
* Tells whoever is polling this host that its details changed.
*
* An event rather than the HTTP POST to localhost:30005 this used to be: host
* metrics is a plugin now and has no port of its own. Fire and forget, so a
* subscriber that throws cannot fail the host update that caused it.
* An event, so whichever plugin polls the host can listen for it. Fire and
* forget, so a subscriber that throws cannot fail the host update that
* caused it.
*/
function notifyStatsHostUpdated(
hostId: number,
@@ -556,7 +557,7 @@ router.post(
* description: SSH username
* authType:
* type: string
* enum: [password, key, credential]
* description: Any registered SSH auth type that works for an unsaved host (password, key, credential, agent, none, or one a plugin marks for Quick Connect).
* description: Authentication method
* password:
* type: string
@@ -618,6 +619,13 @@ router.post(
return res.status(400).json({ error: "Missing required fields" });
}
ensureCoreSshAuthProviders();
if (!isQuickConnectAuthType(String(authType))) {
return res
.status(400)
.json({ error: "That auth type cannot be used for Quick Connect" });
}
try {
let resolvedPassword = password;
let resolvedKey = key;
@@ -2436,8 +2444,6 @@ registerHostBulkRoutes(
requireDataAccess,
);
registerHostAuthCompatRoutes(router);
registerHostNetworkRoutes(router, {
authenticateJWT,
requireViewPermission: permissionManager.requirePermission("hosts.view"),
+1 -13
View File
@@ -28,16 +28,6 @@ function getTabTtlMs(): number {
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
}
// Legacy tab types that were renamed. Normalize on read so previously saved
// tabs still restore to the correct (renamed) tab type.
const LEGACY_TAB_TYPE_MAP: Record<string, string> = {
stats: "host-metrics",
};
function normalizeTabType(tabType: string): string {
return LEGACY_TAB_TYPE_MAP[tabType] ?? tabType;
}
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
@@ -46,9 +36,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
userId,
cutoff,
);
return res.json(
tabs.map((tab) => ({ ...tab, tabType: normalizeTabType(tab.tabType) })),
);
return res.json(tabs);
} catch (e) {
databaseLogger.error("Failed to get open tabs", e, {
operation: "get_open_tabs",
@@ -17,6 +17,8 @@
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { getPluginRouter } from "../../plugins/http.js";
import { getRequestBasePath } from "../../utils/request-origin.js";
import type { PluginLegacyRedirect } from "@termix/plugin-sdk/manifest";
const router = express.Router();
@@ -78,21 +80,44 @@ export function mountPluginApi(app: express.Express): void {
app.use("/plugin-api", router);
}
export interface ActiveLegacyRoutes {
id: string;
legacyPaths: string[];
legacyRedirects: PluginLegacyRedirect[];
}
function under(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}/`);
}
/**
* 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.
* Serves the old URLs plugins declare in contributes.http. A legacyPaths
* entry, under "/<plugin id>/", runs through that plugin's router with the
* prefix removed. A legacyRedirects entry redirects to the plugin's own URL,
* keeping the rest of the path and the query. Mounted after every core route,
* so core wins a clash.
*/
export function mountPluginLegacyPaths(
app: express.Express,
listActive: () => Array<{ id: string; legacyPaths: string[] }>,
listActive: () => ActiveLegacyRoutes[],
): 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}/`),
const redirect = plugin.legacyRedirects.find((entry) =>
under(req.path, entry.from),
);
if (!matched) continue;
if (redirect) {
const rest = req.path.slice(redirect.from.length);
const query = req.originalUrl.indexOf("?");
res.redirect(
redirect.status ?? 307,
`${getRequestBasePath(req)}/plugin-api/${plugin.id}${redirect.to}${rest}${
query >= 0 ? req.originalUrl.slice(query) : ""
}`,
);
return;
}
if (!plugin.legacyPaths.some((path) => under(req.path, path))) continue;
const pluginRouter = getPluginRouter(plugin.id);
if (!pluginRouter) {
res.status(404).json({ error: "Plugin not installed or not running" });
@@ -18,12 +18,14 @@ export interface SshAuthProviderSummary {
credentialType: boolean;
needsUserInteraction: boolean;
supportsBackground: boolean;
/** Offered in Quick Connect, for a host that is never saved. */
quickConnect: boolean;
available: boolean;
/** Set when the type belongs to a plugin that is disabled or missing. */
missingPlugin?: { id: string; name: string };
}
export function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
ensureCoreSshAuthProviders();
const summaries: SshAuthProviderSummary[] = listSshAuthProviders().map(
(provider) => ({
@@ -35,6 +37,7 @@ export function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
credentialType: !!provider.credentialType,
needsUserInteraction: !!provider.needsUserInteraction,
supportsBackground: provider.supportsBackground !== false,
quickConnect: !!provider.quickConnect,
available: true,
}),
);
@@ -50,6 +53,7 @@ export function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
credentialType: false,
needsUserInteraction: false,
supportsBackground: false,
quickConnect: false,
available: false,
missingPlugin: { id: owner.pluginId, name: owner.pluginName },
});
+1 -1
View File
@@ -17,7 +17,7 @@ import { sql, type SQLWrapper } from "drizzle-orm";
* `substr` are used rather than `datetime()` to keep the expression portable
* across engines.
*/
export const CANONICAL_TIMESTAMP_LENGTH = 19;
const CANONICAL_TIMESTAMP_LENGTH = 19;
export function normalizeSyncTimestamp(value: string): string {
return value.replace("T", " ").slice(0, CANONICAL_TIMESTAMP_LENGTH);
@@ -37,7 +37,7 @@ export function listSshAuthTypeOwners(): Array<
return ownerSource();
}
export function findSshAuthTypeOwner(type: string): SshAuthTypeOwner | null {
function findSshAuthTypeOwner(type: string): SshAuthTypeOwner | null {
const owner = ownerSource().find((entry) => entry.type === type);
return owner
? { pluginId: owner.pluginId, pluginName: owner.pluginName }
@@ -91,6 +91,11 @@ export function listSshAuthProviders(): SshAuthProvider[] {
return [...providers.values()];
}
/** Whether a registered provider works for a host that is never saved. */
export function isQuickConnectAuthType(type: string): boolean {
return providers.get(type)?.quickConnect === true;
}
/** Types a stored credential may use. */
export function listCredentialTypes(): string[] {
return listSshAuthProviders()
@@ -15,6 +15,7 @@ import type {
SshAuthOutcome,
SshAuthProvider,
SshConnectHost,
SshConnectProfile,
SshConnectPurpose,
} from "./types.js";
@@ -48,8 +49,7 @@ const BASIC_ENV: Record<string, string> = {
LC_ALL: "en_US.UTF-8",
};
// The values each transport used before it moved onto the pipeline.
const PURPOSE_DEFAULTS: Record<SshConnectPurpose, PurposeDefaults> = {
const PROFILE_DEFAULTS: Record<SshConnectProfile, PurposeDefaults> = {
terminal: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 5,
@@ -58,116 +58,55 @@ const PURPOSE_DEFAULTS: Record<SshConnectPurpose, PurposeDefaults> = {
socketTimeout: 120000,
env: TERMINAL_ENV,
},
"file-manager": {
session: {
keepaliveIntervalMs: 60000,
keepaliveCountMax: 5,
hostKeepalive: true,
readyTimeout: 60000,
env: TERMINAL_ENV,
},
"file-transfer": {
stream: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 120,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
tmux: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
metrics: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: BASIC_ENV,
},
fleet: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: BASIC_ENV,
},
tunnel: {
forward: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: true,
readyTimeout: 60000,
env: null,
},
docker: {
keepaliveIntervalMs: 60000,
keepaliveCountMax: 5,
hostKeepalive: true,
readyTimeout: 60000,
env: TERMINAL_ENV,
},
"docker-console": {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 120,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
proxmox: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 20000,
env: null,
},
snippet: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: null,
},
"credential-deploy": {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: null,
},
"jump-host": {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
"remote-desktop": {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
plugin: {
background: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: BASIC_ENV,
},
jump: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
};
export function getPurposeDefaults(
purpose: SshConnectPurpose,
export function getProfileDefaults(
profile: SshConnectProfile = "background",
): PurposeDefaults {
return PURPOSE_DEFAULTS[purpose];
return PROFILE_DEFAULTS[profile];
}
export interface BuildConnectConfigOptions {
/** Acting user: host key trust, certificates and tokens are per user. */
userId: string;
purpose: SshConnectPurpose;
/** Keepalive and timeout defaults. Defaults to "background". */
profile?: SshConnectProfile;
client: Client;
/** Server-side host id when it differs from host.id (sync-id lookups). */
serverHostId?: number;
@@ -188,7 +127,7 @@ export interface BuiltConnectConfig {
const noopLog: SshAuthLog = () => {};
export function stripIpv6Brackets(ip: string): string {
function stripIpv6Brackets(ip: string): string {
return ip?.replace(/^\[|\]$/g, "") || ip;
}
@@ -202,10 +141,11 @@ export async function buildConnectConfig(
): Promise<BuiltConnectConfig> {
ensureCoreSshAuthProviders();
const defaults = PURPOSE_DEFAULTS[options.purpose];
const profile = options.profile ?? "background";
const defaults = PROFILE_DEFAULTS[profile];
const authType = host.authType || "none";
const serverHostId = options.serverHostId ?? host.id;
const isJumpHost = options.purpose === "jump-host";
const isJumpHost = profile === "jump";
const terminalConfig = (host.terminalConfig ?? undefined) as
Record<string, unknown> | undefined;
+11 -6
View File
@@ -130,19 +130,21 @@ async function prepareKey(
return { status: "ready" };
}
export const passwordProvider: SshAuthProvider = {
const passwordProvider: SshAuthProvider = {
type: "password",
pluginId: "core",
labelKey: "hosts.filterAuthPassword",
quickConnect: true,
credentialType: true,
requiresSecret: true,
prepare: preparePassword,
};
export const keyProvider: SshAuthProvider = {
const keyProvider: SshAuthProvider = {
type: "key",
pluginId: "core",
labelKey: "hosts.filterAuthKey",
quickConnect: true,
credentialType: true,
requiresSecret: true,
prepare: prepareKey,
@@ -153,10 +155,11 @@ export const keyProvider: SshAuthProvider = {
* this only runs when that expansion found something odd, like an unresolved
* reference. It behaves like whichever secret is present.
*/
export const credentialProvider: SshAuthProvider = {
const credentialProvider: SshAuthProvider = {
type: "credential",
pluginId: "core",
labelKey: "hosts.filterAuthCredential",
quickConnect: true,
requiresSecret: true,
prepare: async (config, host, env) => {
if (keyText(host.key).trim()) return prepareKey(config, host, env);
@@ -169,10 +172,11 @@ export const credentialProvider: SshAuthProvider = {
},
};
export const agentProvider: SshAuthProvider = {
const agentProvider: SshAuthProvider = {
type: "agent",
pluginId: "core",
labelKey: "hosts.filterAuthAgent",
quickConnect: true,
requiresSecret: true,
prepare: async (config, host, env) => {
const result = await applyAgentAuth(
@@ -187,10 +191,11 @@ export const agentProvider: SshAuthProvider = {
},
};
export const noneProvider: SshAuthProvider = {
const noneProvider: SshAuthProvider = {
type: "none",
pluginId: "core",
labelKey: "hosts.filterAuthNone",
quickConnect: true,
// Keyboard-interactive only: a background poll would fire a live prompt
// (a RADIUS or Duo push) with nobody there to answer it.
supportsBackground: false,
@@ -200,7 +205,7 @@ export const noneProvider: SshAuthProvider = {
},
};
export const BUILTIN_SSH_AUTH_PROVIDERS: readonly SshAuthProvider[] = [
const BUILTIN_SSH_AUTH_PROVIDERS: readonly SshAuthProvider[] = [
passwordProvider,
keyProvider,
credentialProvider,
+5 -1
View File
@@ -23,6 +23,7 @@ import type {
SshAuthLog,
SshAuthOutcome,
SshConnectHost,
SshConnectProfile,
SshConnectPurpose,
SshPromptChannel,
} from "./types.js";
@@ -41,7 +42,7 @@ export class SshConnectError extends Error {
}
}
export class SshHostNotFoundError extends Error {
class SshHostNotFoundError extends Error {
constructor(readonly hostId: number) {
super("Host not found or access denied");
this.name = "SshHostNotFoundError";
@@ -52,6 +53,8 @@ export interface ConnectHostOptions {
/** Acting user. Access and credentials are resolved for them. */
userId: string;
purpose: SshConnectPurpose;
/** Keepalive and timeout defaults. Defaults to "background". */
profile?: SshConnectProfile;
/** Ask a person for keyboard-interactive answers. Without it, auto-fill. */
prompt?: SshPromptChannel;
/** Replaces the keyboard-interactive handler entirely. */
@@ -100,6 +103,7 @@ export async function connectHost(
const { config, provider, outcome, env } = await buildConnectConfig(host, {
userId: options.userId,
purpose: options.purpose,
profile: options.profile,
client,
hostKeySocket: options.prompt?.hostKeySocket ?? null,
interactive: !!options.prompt,
@@ -11,8 +11,3 @@ export function ensureCoreSshAuthProviders(): void {
registered = true;
registerBuiltinSshAuthProviders();
}
/** Test helper, pairs with resetSshAuthRegistryForTests. */
export function resetCoreSshAuthProvidersForTests(): void {
registered = false;
}
@@ -23,7 +23,7 @@ const FORTI_TOKEN_PATTERN = /type\s+['"]?push['"]?/i;
// JumpCloud Protect / DUO: a menu choice, then an empty-answerable confirm.
// Checked before TOTP because "...or [2] TOTP:" would match that pattern.
export const PUSH_PROMPT_PATTERN =
const PUSH_PROMPT_PATTERN =
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
const TOTP_PATTERN =
@@ -36,7 +36,7 @@ export interface KeyboardInteractiveRound {
}
/** Stored password for every password prompt, empty for the rest. */
export function autoResponses(
function autoResponses(
prompts: KeyboardInteractivePrompt[],
password: string | null | undefined,
): string[] {
@@ -46,7 +46,7 @@ export function autoResponses(
}
/** Puts the user's answer at one index and auto-fills the rest. */
export function responsesWithAnswer(
function responsesWithAnswer(
prompts: KeyboardInteractivePrompt[],
answerIndex: number,
answer: string,
@@ -46,7 +46,7 @@ function findSchemeOwner(scheme: string): SecretResolverOwner | null {
: null;
}
export class SecretResolverMissingError extends Error {
class SecretResolverMissingError extends Error {
readonly code = "SECRET_RESOLVER_MISSING";
constructor(
readonly scheme: string,
+2 -2
View File
@@ -21,7 +21,7 @@ import type {
SshConnectHost,
} from "./types.js";
export function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
const chain = Array.isArray(host.socks5ProxyChain)
? (host.socks5ProxyChain as ProxyNode[])
: [];
@@ -36,7 +36,7 @@ export function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
};
}
export class SshTransportError extends Error {
class SshTransportError extends Error {
constructor(
message: string,
readonly stage: "cloudflare" | "jump-host" | "jump-forward" | "proxy",
+18 -19
View File
@@ -2,25 +2,19 @@ import type { Client, ConnectConfig } from "ssh2";
import type { WebSocket } from "ws";
/**
* What a connection is for. Picks keepalive and timeout defaults so every
* transport keeps the numbers it had before the pipeline existed.
* What a connection is for, as a free label ("terminal", "metrics"). Auth
* providers see it; core attaches no meaning to any value.
*/
export type SshConnectPurpose =
| "terminal"
| "file-manager"
| "file-transfer"
| "tmux"
| "metrics"
| "fleet"
| "tunnel"
| "docker"
| "docker-console"
| "proxmox"
| "snippet"
| "credential-deploy"
| "jump-host"
| "remote-desktop"
| "plugin";
export type SshConnectPurpose = string;
/**
* Keepalive, timeout and environment defaults for a kind of connection.
* terminal: an interactive shell. session: a long-lived browsing session.
* stream: a long transfer or console. forward: port forwarding. background:
* short exec work such as polling. jump: a hop in a jump chain (core only).
*/
export type SshConnectProfile =
"terminal" | "session" | "stream" | "forward" | "background" | "jump";
/** The resolved host fields the pipeline reads. */
export interface SshConnectHost {
@@ -169,12 +163,17 @@ export interface SshAuthProvider {
* true; false for types that need a person or a fresh browser sign-in.
*/
supportsBackground?: boolean;
/**
* Works for a host that was never saved, so Quick Connect offers it.
* Default false.
*/
quickConnect?: boolean;
/** Overrides applied to the base config before prepare runs. */
connectOptions?: (
host: SshConnectHost,
purpose: SshConnectPurpose,
) => Partial<MutableConnectConfig>;
/** The interaction name this provider's outcomes use, e.g. "opkssh". */
/** The interaction name this provider's outcomes use, e.g. "browser-signin". */
interaction?: string;
/**
* Starts the browser step behind an `interaction-required` outcome, for
-15
View File
@@ -1,15 +0,0 @@
import type { ConnectionStage, LogEntry } from "../../types/connection-log.js";
export function createConnectionLog(
type: "info" | "success" | "warning" | "error",
stage: ConnectionStage,
message: string,
details?: Record<string, unknown>,
): Omit<LogEntry, "id" | "timestamp"> {
return {
type,
stage,
message,
details,
};
}
+1 -4
View File
@@ -32,10 +32,7 @@ export async function deleteOwnedHost(
);
if (!host) return null;
// file manager recent/pinned/shortcuts, transfer_recent, command history
// and session recordings cascade on the host's refHost() foreign key, as
// the file-manager, ssh-terminal and session-recording plugins' adopted
// tables.
// Plugin rows tied to a host cascade on their refHost() foreign keys.
await createCurrentSshCredentialUsageRepository().deleteByHostId(hostId);
await createCurrentRecentActivityRepository().deleteByHostId(hostId);
await createCurrentRbacAccessRepository().deleteHostAccessForHost(hostId);
+2 -10
View File
@@ -1,7 +1,4 @@
import {
getSecretResolver,
requireSecretResolver,
} from "./connect/secret-resolver-registry.js";
import { requireSecretResolver } from "./connect/secret-resolver-registry.js";
/**
* Expands "<scheme>://..." references ("op://vault/item/field" for 1Password
@@ -17,7 +14,7 @@ import {
* cached briefly in memory; a rotated secret shows up within CACHE_TTL_MS.
*/
export const SECRET_FIELDS = [
const SECRET_FIELDS = [
"password",
"key",
"keyPassword",
@@ -91,8 +88,3 @@ export async function resolveExternalSecretRefs(
host[field] = await resolveSecretReference(userId, value, deps);
}
}
/** Whether any plugin currently resolves this reference's scheme. */
export function hasSecretResolverFor(reference: string): boolean {
return !!getSecretResolver(referenceScheme(reference));
}
-27
View File
@@ -15,7 +15,6 @@ import {
expandOidcUsername,
} from "./credential-username.js";
import type { SSHHost } from "../../types/index.js";
import type { HostAction } from "../utils/permission-manager.js";
const sshLogger = logger;
@@ -316,29 +315,3 @@ async function resolveRecipientSshAuth(
return null;
}
/**
* Check if a user has access to a host (owner or shared access).
*/
export async function checkHostAccess(
hostId: number,
userId: string,
hostUserId: string,
requiredPermission: HostAction = "connect",
): Promise<boolean> {
if (userId === hostUserId) return true;
try {
const { PermissionManager } =
await import("../utils/permission-manager.js");
const permissionManager = PermissionManager.getInstance();
const accessInfo = await permissionManager.canAccessHost(
userId,
hostId,
requiredPermission,
);
return accessInfo.hasAccess;
} catch {
return false;
}
}
+2 -2
View File
@@ -47,7 +47,7 @@ async function resolveJumpHost(
}
}
export class JumpHostChainError extends Error {
class JumpHostChainError extends Error {
constructor(
message: string,
readonly hopIndex: number,
@@ -161,7 +161,7 @@ export async function createJumpHostChain(
// every auth type, not just password, key and agent.
const built = await buildConnectConfig(
jumpHostConfig as unknown as SshConnectHost,
{ userId, purpose: "jump-host", client: jumpClient },
{ userId, purpose: "jump-host", profile: "jump", client: jumpClient },
);
if (built.outcome.status !== "ready") {
clearTimeout(timeout);
+1 -1
View File
@@ -10,7 +10,7 @@
import { getServiceImplementation } from "../plugins/service-registry.js";
export const LIVE_SESSIONS_SERVICE = "sessions.live";
const LIVE_SESSIONS_SERVICE = "sessions.live";
const SSH_PROVIDER = "ssh";
export interface LiveSessionInfo {
-45
View File
@@ -1,45 +0,0 @@
import type { Client } from "ssh2";
import type { SSHHost } from "../../types/index.js";
import {
connectHost,
getConnectionPoolKey,
SshConnectError,
} from "./connect/connect-host.js";
import { buildConnectConfig } from "./connect/build-connect-config.js";
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
/**
* Non-interactive SSH connections for background work (fleets, automations,
* AI tools). A thin wrapper over the connect pipeline, kept so the pool keys
* and call sites stay the same.
*/
export function getFleetPoolKey(host: SSHHost): string {
return getConnectionPoolKey("fleet", host as unknown as SshConnectHost);
}
export async function buildFleetSshConfig(
host: SSHHost,
client?: Client,
): Promise<MutableConnectConfig> {
const { Client: SshClient } = await import("ssh2");
const built = await buildConnectConfig(host as unknown as SshConnectHost, {
userId: host.userId || "",
purpose: "fleet",
client: client ?? new SshClient(),
});
if (built.outcome.status !== "ready") {
throw new SshConnectError(built.outcome);
}
return built.config;
}
export function createFleetSshFactory(host: SSHHost): () => Promise<Client> {
return async () => {
const connection = await connectHost(host as unknown as SshConnectHost, {
userId: host.userId || "",
purpose: "fleet",
});
return connection.client;
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
import dns from "dns/promises";
import net from "net";
export const SSH_DNS_RETRY_DELAYS_MS = [250, 750, 1500];
const SSH_DNS_RETRY_DELAYS_MS = [250, 750, 1500];
type Lookup = typeof dns.lookup;
type Sleep = (ms: number) => Promise<void>;
-151
View File
@@ -1,151 +0,0 @@
export type TransferPlatform = "windows" | "unix";
/** OpenSSH SFTP on Windows commonly uses `/C:/...` or `C:/...` style paths. */
export function isWindowsSftpPath(path: string): boolean {
const normalized = path.replace(/\\/g, "/");
return /^[A-Za-z]:\//.test(normalized) || /^\/[A-Za-z]:\//.test(normalized);
}
export function inferPlatformFromPath(path: string): TransferPlatform | null {
if (isWindowsSftpPath(path)) return "windows";
if (path.startsWith("/") && !/^\/[A-Za-z]:/.test(path.replace(/\\/g, "/"))) {
return "unix";
}
return null;
}
export function normalizeSftpPath(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
if (!normalized) return path.startsWith("/") ? "/" : ".";
return normalized.replace(/([^:])\/+/g, "$1/");
}
export function basename(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
const idx = normalized.lastIndexOf("/");
if (idx < 0) return normalized;
return normalized.substring(idx + 1) || normalized;
}
export function dirname(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
if (!normalized) return normalized;
const idx = normalized.lastIndexOf("/");
if (idx < 0) return normalized;
const parent = normalized.substring(0, idx);
if (!parent) return "/";
if (/^\/[A-Za-z]:$/.test(parent) || /^[A-Za-z]:$/.test(parent)) {
return parent;
}
return parent || "/";
}
export function joinPath(base: string, ...parts: string[]): string {
let result = base.replace(/\\/g, "/");
for (const part of parts) {
if (!part) continue;
const segment = part.replace(/\\/g, "/").replace(/^\/+/, "");
if (!segment) continue;
if (result.endsWith("/")) {
result += segment;
} else {
result = `${result}/${segment}`;
}
}
return normalizeSftpPath(result);
}
export interface PathSegments {
/** Drive root prefix, e.g. `/C:` or `C:`; empty for Unix absolute paths. */
root: string;
/** Path segments below the root (may be empty). */
segments: string[];
}
export function splitPathSegments(path: string): PathSegments {
const normalized = normalizeSftpPath(path);
const posixDrive = normalized.match(/^(\/[A-Za-z]:)(?:\/(.*))?$/);
if (posixDrive) {
return {
root: posixDrive[1],
segments: (posixDrive[2] ?? "").split("/").filter(Boolean),
};
}
const drive = normalized.match(/^([A-Za-z]:)(?:\/(.*))?$/);
if (drive) {
return {
root: drive[1],
segments: (drive[2] ?? "").split("/").filter(Boolean),
};
}
if (normalized.startsWith("/")) {
return {
root: "/",
segments: normalized.split("/").filter(Boolean),
};
}
return {
root: "",
segments: normalized.split("/").filter(Boolean),
};
}
export function buildPathFromSegments(
root: string,
segments: string[],
count: number,
): string {
const slice = segments.slice(0, count);
if (/^\/[A-Za-z]:$/.test(root)) {
return slice.length ? `${root}/${slice.join("/")}` : root;
}
if (/^[A-Za-z]:$/.test(root)) {
return slice.length ? `${root}/${slice.join("/")}` : root;
}
if (root === "/") {
return slice.length ? `/${slice.join("/")}` : "/";
}
if (root === "") {
return slice.join("/");
}
return slice.length ? `${root}/${slice.join("/")}` : root;
}
export function pathsOverlap(source: string, dest: string): boolean {
const norm = (p: string) => {
const n = normalizeSftpPath(p).toLowerCase();
return n || (isWindowsSftpPath(p) ? p : "/");
};
const s = norm(source);
const d = norm(dest);
const sep = "/";
return s === d || s.startsWith(`${d}${sep}`) || d.startsWith(`${s}${sep}`);
}
export function getWorkingDir(paths: string[]): string {
if (paths.length === 0) return "/";
const parents = paths.map((p) => dirname(p));
const first = parents[0];
if (parents.every((p) => p === first)) {
return first;
}
return first;
}
/** Convert an SFTP path to a local filesystem path when Termix runs on the dest host. */
export function sftpPathToLocalPath(sftpPath: string): string {
const normalized = sftpPath.replace(/\\/g, "/");
if (/^\/[A-Za-z]:\//.test(normalized)) {
return normalized.slice(1).replace(/\//g, "\\");
}
if (/^[A-Za-z]:\//.test(normalized)) {
return normalized.replace(/\//g, "\\");
}
return sftpPath;
}
+1 -11
View File
@@ -47,11 +47,7 @@ export function getActor(): string | undefined {
return storage.getStore()?.userId;
}
export function getActorSource(): ActorStore["source"] | undefined {
return storage.getStore()?.source;
}
export class PluginActorError extends Error {
class PluginActorError extends Error {
readonly code = "EPLUGINACTOR";
constructor(pluginId: string, operation: string) {
@@ -62,9 +58,3 @@ export class PluginActorError extends Error {
this.name = "PluginActorError";
}
}
export function requireActor(pluginId: string, operation: string): string {
const userId = getActor();
if (!userId) throw new PluginActorError(pluginId, operation);
return userId;
}
-5
View File
@@ -22,8 +22,3 @@ export function recordConflict(conflict: RegistrationConflict): void {
export function listRegistrationConflicts(): RegistrationConflict[] {
return [...conflicts];
}
/** Test seam. */
export function clearRegistrationConflicts(): void {
conflicts.length = 0;
}
+16 -15
View File
@@ -39,6 +39,7 @@ import type {
MutableConnectConfig,
SshAuthProvider,
SshConnectHost,
SshConnectProfile,
SshConnectPurpose,
} from "../hosts/connect/types.js";
@@ -54,24 +55,22 @@ interface Deps {
audit: AuditFn;
}
const PLUGIN_PURPOSES = new Set<SshConnectPurpose>([
"plugin",
"docker",
"docker-console",
"metrics",
"proxmox",
"fleet",
"remote-desktop",
"tunnel",
"file-manager",
"file-transfer",
const PLUGIN_PROFILES = new Set<SshConnectProfile>([
"terminal",
"tmux",
"session",
"stream",
"forward",
"background",
]);
function purposeOf(options?: PluginSshConnectOptions): SshConnectPurpose {
const purpose = (options?.purpose ?? "plugin") as SshConnectPurpose;
return PLUGIN_PURPOSES.has(purpose) ? purpose : "plugin";
function purposeOf(options?: { purpose?: string }): SshConnectPurpose {
return options?.purpose || "plugin";
}
// "jump" belongs to core's own jump chain; a plugin gets background.
function profileOf(options?: { profile?: string }): SshConnectProfile {
const profile = options?.profile as SshConnectProfile | undefined;
return profile && PLUGIN_PROFILES.has(profile) ? profile : "background";
}
/**
@@ -141,6 +140,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
const connection = await connectHost(host as number | SshConnectHost, {
userId,
purpose: purposeOf(options),
profile: profileOf(options),
timeoutMs: options?.timeoutMs,
prompt: options?.prompt,
overrides: options?.overrides as Partial<MutableConnectConfig>,
@@ -273,6 +273,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
const built = await buildConnectConfig(host as SshConnectHost, {
userId: actingUser(host),
purpose: purposeOf(options),
profile: profileOf(options),
client: options.client as never,
serverHostId: options.serverHostId,
log: options.log,
+14 -15
View File
@@ -17,7 +17,6 @@ import { buildTable, dropTableSql } from "./table-builder.js";
import {
applyPluginMigrations,
readMigrations,
tableExistsSql,
type MigrationRunner,
} from "./migrations.js";
@@ -49,16 +48,26 @@ export function registerTable(
return table;
}
export function getTable(pluginId: string, name: string): unknown {
function getTable(pluginId: string, name: string): unknown {
return built.get(pluginId)?.get(name);
}
export function listTables(pluginId: string): PluginTableDefinition[] {
function listTables(pluginId: string): PluginTableDefinition[] {
return [...(registered.get(pluginId) ?? [])];
}
/** Every registered definition, with the plugin that owns it. */
export function listAllTables(): Array<{
pluginId: string;
definition: PluginTableDefinition;
}> {
return [...registered.entries()].flatMap(([pluginId, definitions]) =>
definitions.map((definition) => ({ pluginId, definition })),
);
}
/** Forgets a plugin's definitions. Used by the tests and by uninstall. */
export function forgetTables(pluginId: string): void {
function forgetTables(pluginId: string): void {
registered.delete(pluginId);
built.delete(pluginId);
}
@@ -84,7 +93,7 @@ async function execute(statements: string[]): Promise<void> {
* the engine does not keep. A half-applied migration is recorded statement by
* statement either way, so a retry resumes rather than repeating.
*/
export async function createMigrationRunner(
async function createMigrationRunner(
dialect: DatabaseDialect = resolveDatabaseDialect(),
): Promise<MigrationRunner> {
const { createCurrentPluginMigrationRepository } =
@@ -125,16 +134,6 @@ export async function migratePlugin(
return applyPluginMigrations(pluginId, pluginDir, runner);
}
/** Whether a table exists, asked in the active dialect's own catalog. */
export async function tableExists(
table: string,
dialect: DatabaseDialect = resolveDatabaseDialect(),
): Promise<boolean> {
const { getDb } = await import("../database/db/index.js");
const rows = (await getDb().all(tableExistsSql(dialect, table))) as unknown[];
return rows.length > 0;
}
/**
* Removes everything a plugin owns.
*
-56
View File
@@ -1,56 +0,0 @@
/**
* The only shape of a host a plugin ever sees.
*
* This is an ALLOWLIST, deliberately, not a list of fields to strip. A
* blocklist rots: the moment someone adds a new secret-bearing column to
* SSHHost, a blocklist silently starts leaking it, and nothing fails. With an
* allowlist a new field is invisible to plugins until someone consciously adds
* it here.
*
* resolveHostById returns an object carrying password, key, keyPassword,
* certPublicKey, sudoPassword and the autostart* secrets in plaintext. None of
* them are in this list, and none of them may ever be.
*/
export interface PluginHostView {
id: number;
name: string | null;
ip: string;
port: number;
username: string;
folder: string | null;
tags: string[];
authType: string | null;
createdAt: string | null;
updatedAt: string | null;
}
const NUMBER_FIELDS = ["id", "port"] as const;
const STRING_FIELDS = [
"name",
"ip",
"username",
"folder",
"authType",
"createdAt",
"updatedAt",
] as const;
export function toPluginHostView(
host: Record<string, unknown>,
): PluginHostView {
const view: Record<string, unknown> = {};
for (const field of NUMBER_FIELDS) {
view[field] = Number(host[field] ?? 0);
}
for (const field of STRING_FIELDS) {
const value = host[field];
view[field] = typeof value === "string" ? value : null;
}
view.tags = Array.isArray(host.tags)
? host.tags.filter((tag): tag is string => typeof tag === "string")
: [];
return view as unknown as PluginHostView;
}
-4
View File
@@ -42,10 +42,6 @@ export function getPluginRouter(pluginId: string): Router | undefined {
return routers.get(pluginId);
}
export function getRegisteredHttpPluginIds(): string[] {
return [...routers.keys()];
}
export function unregisterPluginHttp(pluginId: string): void {
if (!routers.delete(pluginId)) return;
pluginLogger.info(`Unmounted /plugin-api/${pluginId}`, {
+2 -1
View File
@@ -431,7 +431,8 @@ export async function activatePlugin(pluginId: string): Promise<void> {
await pluginLoader.activate(pluginId);
// A plugin enabled without a restart still needs its 2.8 data copied.
const { runPluginDataMigrations } = await import("./boot-migrations.js");
const { runPluginDataMigrations } =
await import("../upgrade/boot-migrations.js");
await runPluginDataMigrations();
}
+4 -4
View File
@@ -48,8 +48,8 @@ export type PluginSource = "bundled" | "user";
const ACTIVATION_TIMEOUT_MS = 30_000;
/** Defaults for the runtime error budget. Both are configurable. */
export const DEFAULT_ERROR_THRESHOLD = 5;
export const DEFAULT_ERROR_WINDOW_MS = 60_000;
const DEFAULT_ERROR_THRESHOLD = 5;
const DEFAULT_ERROR_WINDOW_MS = 60_000;
export interface LoadedPlugin {
id: string;
@@ -141,7 +141,7 @@ export class PluginLoader {
/**
* Scans both roots. A user-installed plugin can never shadow a bundled one:
* the collision is rejected with an error rather than silently skipped, so
* dropping an "ssh-terminal" folder into the data directory is visibly
* dropping a folder with a bundled plugin's id into the data directory is visibly
* refused instead of quietly ignored.
*
* The check is on the parsed manifest id, not the directory name, because a
@@ -347,7 +347,7 @@ export class PluginLoader {
// A new table may be where core data is waiting to move.
if (applied.length > 0) {
const { runPluginDataMoves } =
await import("../utils/crypto-migration/plugin-data-moves.js");
await import("../upgrade/plugin-data-moves.js");
await runPluginDataMoves();
}
-13
View File
@@ -11,12 +11,6 @@ export {
validateManifest,
parseManifest,
SUPPORTED_PLUGIN_API_VERSION,
PLUGIN_CATEGORIES,
PLUGIN_PLATFORMS,
ACTION_CONTRIBUTION_KINDS,
DEFAULT_BACKEND_ENTRY,
DEFAULT_FRONTEND_ENTRY,
DEFAULT_LOCALES_DIR,
SYSTEM_ROLE_NAMES,
RESERVED_PERMISSION_PREFIXES,
qualifyPermission,
@@ -41,13 +35,6 @@ export type {
ParsedManifest,
} from "@termix/plugin-sdk/manifest";
export {
CAPABILITY_CATALOG,
CAPABILITY_IDS,
isKnownCapability,
getCapabilityInfo,
} from "@termix/plugin-sdk/capabilities";
export type {
Capability,
CapabilityInfo,
+1 -9
View File
@@ -57,10 +57,7 @@ export function checksum(contents: string): string {
return crypto.createHash("sha256").update(normalised).digest("hex");
}
export function migrationsDir(
pluginDir: string,
dialect: DatabaseDialect,
): string {
function migrationsDir(pluginDir: string, dialect: DatabaseDialect): string {
return path.join(pluginDir, "migrations", dialect);
}
@@ -211,11 +208,6 @@ export async function applyPluginMigrations(
return pending.map((migration) => migration.id);
}
/** The dialect directories a plugin is expected to ship. */
export function expectedDialects(): readonly DatabaseDialect[] {
return DIALECTS;
}
/**
* Renames a legacy core table into a plugin's namespace, or creates it fresh.
*
-4
View File
@@ -47,10 +47,6 @@ export function getBundledPluginsDir(): string {
return candidates[0];
}
export function getPluginDir(pluginId: string): string {
return path.join(getPluginsDir(), pluginId);
}
/** The backend entry, from the manifest's own `backend` field. */
export function getPluginBackendEntry(
pluginDir: string,
-2
View File
@@ -15,8 +15,6 @@
import { createCurrentPluginPermissionGrantRepository } from "../database/repositories/factory.js";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
export { PluginCapabilityError };
const cache = new Map<string, Set<string>>();
export function invalidatePluginPermissionCache(pluginId?: string): void {
+1 -1
View File
@@ -29,7 +29,7 @@
* ## Authorization is the provider's own permission
*
* No fourth grant mechanism. The provider declares which of its own role
* permissions gates sharing ("ai.secrets.share", alongside "ai.services.use"),
* permissions gates sharing (for example "<plugin>.secrets.share"),
* and getShared runs the acting user through the same PermissionManager check
* that service-registry.ts and requirePermission() use. So:
*
-8
View File
@@ -12,7 +12,6 @@
* order rather than relying on registration sequence.
*/
import { pluginLogger } from "../utils/logger.js";
import type { SyncEntityRegistration } from "@termix/plugin-sdk/backend";
export interface RegisteredSyncEntity extends SyncEntityRegistration {
@@ -91,10 +90,3 @@ export function unregisterByOwner(owner: string): void {
export function resetSyncRegistry(): void {
entities.clear();
}
/** Logged once at boot so a missing entity is visible without a debugger. */
export function logRegisteredEntities(): void {
pluginLogger.info(`Sync entities: ${listEntityTypes().join(", ")}`, {
operation: "sync_registry",
});
}
-2
View File
@@ -13,6 +13,4 @@ export {
dropTableSql,
addColumnSql,
keyedColumns,
columnName,
REFERENCEABLE,
} from "@termix/plugin-sdk/ddl";
+195
View File
@@ -0,0 +1,195 @@
/**
* A user's rows in plugin tables, for the per-user exports and imports.
*
* Core never names a plugin table. Any table a running plugin declared with a
* refUser column belongs to a user, so it travels with that user's export.
* Encrypted columns stay behind: they are sealed with this server's keys.
*/
import { sql } from "drizzle-orm";
import { columnName, createTableSql } from "@termix/plugin-sdk/ddl";
import { prefixedTableName } from "@termix/plugin-sdk/db";
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
import { getDb } from "../database/db/index.js";
import { listAllTables } from "./data.js";
export interface UserOwnedTable {
pluginId: string;
/** The physical table name, p_<id>_<name>. */
table: string;
/** The legacy core name the table adopted, for older export files. */
legacyName?: string;
userColumn: string;
/** Columns that travel, as SQL names. The primary key and secrets do not. */
columns: string[];
definition: PluginTableDefinition;
}
export function listUserOwnedTables(): UserOwnedTable[] {
const owned: UserOwnedTable[] = [];
for (const { pluginId, definition } of listAllTables()) {
const entries = Object.entries(definition.columns);
const user = entries.find(([, column]) => column.type === "refUser");
if (!user) continue;
owned.push({
pluginId,
table: prefixedTableName(pluginId, definition.name),
legacyName: definition.adopts,
userColumn: columnName(user[0], user[1]),
columns: entries
.filter(
([, column]) =>
column.type !== "id" &&
!column.primaryKey &&
column.type !== "encryptedText",
)
.map(([property, column]) => columnName(property, column)),
definition,
});
}
return owned;
}
type Row = Record<string, unknown>;
/** The user's rows in every user-owned plugin table, keyed by table. */
export async function readUserPluginRows(
userId: string,
): Promise<Record<string, Row[]>> {
const db = getDb();
const result: Record<string, Row[]> = {};
for (const owned of listUserOwnedTables()) {
try {
const rows = await db.all<Row>(
sql`SELECT ${sql.join(
owned.columns.map((column) => sql.identifier(column)),
sql`, `,
)} FROM ${sql.identifier(owned.table)} WHERE ${sql.identifier(
owned.userColumn,
)} = ${userId}`,
);
if (rows.length > 0) result[owned.table] = rows;
} catch {
// The plugin's migration has not created the table yet.
}
}
return result;
}
interface SqliteLike {
exec(source: string): unknown;
prepare(source: string): {
run(...params: unknown[]): unknown;
all(...params: unknown[]): unknown[];
};
}
/** Writes the user's plugin rows into a SQLite export file. */
export async function writeUserPluginTables(
exportDb: SqliteLike,
userId: string,
): Promise<number> {
const rowsByTable = await readUserPluginRows(userId);
let written = 0;
for (const owned of listUserOwnedTables()) {
const rows = rowsByTable[owned.table];
if (!rows?.length) continue;
for (const statement of createTableSql(
"sqlite",
owned.pluginId,
owned.definition,
)) {
exportDb.exec(statement);
}
const insert = exportDb.prepare(
`INSERT INTO "${owned.table}" (${owned.columns
.map((column) => `"${column}"`)
.join(", ")}) VALUES (${owned.columns.map(() => "?").join(", ")})`,
);
for (const row of rows) {
insert.run(...owned.columns.map((column) => toSqlite(row[column])));
written++;
}
}
return written;
}
function toSqlite(value: unknown): unknown {
if (value === undefined) return null;
if (typeof value === "boolean") return value ? 1 : 0;
if (value instanceof Date) return value.toISOString();
if (value !== null && typeof value === "object") return JSON.stringify(value);
return value;
}
export interface PluginRowsImport {
imported: number;
skipped: number;
errors: string[];
}
/**
* Copies plugin rows from an import file into this server as `userId`. A row
* matching one the user already has is skipped. Files from before 2.9 carry
* the legacy table names, which are read too.
*/
export async function importUserPluginRows(
importDb: SqliteLike,
userId: string,
): Promise<PluginRowsImport> {
const db = getDb();
const summary: PluginRowsImport = { imported: 0, skipped: 0, errors: [] };
for (const owned of listUserOwnedTables()) {
let rows: Row[] | null = null;
for (const name of [owned.table, owned.legacyName].filter(Boolean)) {
try {
rows = importDb.prepare(`SELECT * FROM "${name}"`).all() as Row[];
break;
} catch {
// not in this file under that name
}
}
if (!rows) continue;
for (const source of rows) {
const columns = owned.columns.filter(
(column) => column === owned.userColumn || source[column] !== undefined,
);
const values = columns.map((column) =>
column === owned.userColumn ? userId : source[column],
);
try {
const existing = await db.all(
sql`SELECT 1 FROM ${sql.identifier(owned.table)} WHERE ${sql.join(
columns.map((column, index) =>
values[index] === null
? sql`${sql.identifier(column)} IS NULL`
: sql`${sql.identifier(column)} = ${values[index]}`,
),
sql` AND `,
)} LIMIT 1`,
);
if (existing.length > 0) {
summary.skipped++;
continue;
}
await db.run(
sql`INSERT INTO ${sql.identifier(owned.table)} (${sql.join(
columns.map((column) => sql.identifier(column)),
sql`, `,
)}) VALUES (${sql.join(
values.map((value) => sql`${value}`),
sql`, `,
)})`,
);
summary.imported++;
} catch (error) {
summary.errors.push(
`${owned.table} import error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
return summary;
}
+2 -2
View File
@@ -31,7 +31,7 @@ import { recordConflict } from "./conflicts.js";
import { extractWebSocketToken } from "../utils/ws-auth.js";
import { runAsActor } from "./actor.js";
export const PLUGIN_WS_PREFIX = "/plugin-ws/";
const PLUGIN_WS_PREFIX = "/plugin-ws/";
type RawUpgradeHandler = (
request: IncomingMessage,
@@ -127,7 +127,7 @@ function reject(socket: Duplex, status: number, message: string): void {
* Exported for the test suite and for database.ts, which wires it into both
* the HTTP and the HTTPS server.
*/
export async function handlePluginUpgrade(
async function handlePluginUpgrade(
request: IncomingMessage,
socket: Duplex,
head: Buffer,
+8 -11
View File
@@ -9,16 +9,13 @@ import { dashboardLogger } from "../utils/logger.js";
const RATE_LIMIT_MS = 1000;
const activityRateLimiter = new Map<string, number>();
export const RECENT_ACTIVITY_TYPES = [
"terminal",
"file_manager",
"server_stats",
"tunnel",
"docker",
"telnet",
"vnc",
"rdp",
] as const;
/**
* Activity types are whatever the recording plugin calls them (each tab
* declares the types it reopens), so only the shape is checked here.
*/
function isActivityType(type: unknown): type is string {
return typeof type === "string" && /^[a-z][a-z0-9_]{0,31}$/.test(type);
}
export type RecordActivityResult =
| { status: "logged"; id: number }
@@ -36,7 +33,7 @@ export async function recordRecentActivity(
entry: { type: string; hostId: number; hostName: string },
): Promise<RecordActivityResult> {
const { type, hostId, hostName } = entry;
if (!(RECENT_ACTIVITY_TYPES as readonly string[]).includes(type)) {
if (!isActivityType(type)) {
return { status: "invalid_type" };
}
+1 -1
View File
@@ -278,7 +278,7 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
}
const { runPluginDataMigrations } =
await import("./plugins/boot-migrations.js");
await import("./upgrade/boot-migrations.js");
await runPluginDataMigrations();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
@@ -1,7 +1,6 @@
/**
* The 2.8 login URLs identity providers and 2.8 clients still call. They
* forward to whatever replaced them and, with the method's plugin gone, sign
* nobody in.
* The 2.8 login URLs 2.8 clients still call. They forward to the login
* pipeline and, with the method's plugin gone, sign nobody in.
*/
import http from "node:http";
@@ -64,43 +63,6 @@ function registerSso() {
});
}
describe("the old identity provider URLs", () => {
it("forwards the callback to the sso plugin, query intact", async () => {
const response = await get("/users/oidc/callback?code=abc&state=xyz");
expect(response.status).toBe(308);
expect(response.headers.get("location")).toBe(
"/plugin-api/sso/callback?code=abc&state=xyz",
);
});
it("forwards a form-posted callback with a 308 so the body is resent", async () => {
const response = await fetch(`${base}/users/oidc/callback`, {
method: "POST",
redirect: "manual",
});
expect(response.status).toBe(308);
});
it("forwards back-channel logout to the sso plugin", async () => {
const response = await fetch(`${base}/users/oidc/backchannel-logout`, {
method: "POST",
redirect: "manual",
});
expect(response.status).toBe(308);
expect(response.headers.get("location")).toMatch(
/\/plugin-api\/sso\/backchannel-logout$/,
);
});
it("forwards the public config to the sso plugin", async () => {
const response = await get("/users/oidc-config");
expect(response.status).toBe(307);
expect(response.headers.get("location")).toMatch(
/\/plugin-api\/sso\/config$/,
);
});
});
describe("the old client routes", () => {
it("starts an SSO login through the oidc method", async () => {
registerSso();
@@ -1,116 +0,0 @@
/**
* The OPKSSH, Step CA and Vault redirect URIs identity providers were registered with before
* 2.9 must keep reaching the plugin's callback, query intact, and the Termix
* ID resolver URLs servers fetch must keep reaching the plugin's resolver.
*/
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const { registerHostAuthCompatRoutes, registerTermixIdCompatRoutes } =
await import("../../../database/routes/host-compat-routes.js");
let server: http.Server;
let base: string;
beforeEach(async () => {
const router = express.Router();
registerHostAuthCompatRoutes(router);
const app = express();
app.use("/host", router);
const termixId = express.Router();
registerTermixIdCompatRoutes(termixId);
app.use("/termix-id", termixId);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe("the old OPKSSH callback", () => {
it("forwards to the plugin's callback with the query", async () => {
const response = await fetch(
`${base}/host/opkssh-callback?code=abc&state=xyz`,
{ redirect: "manual" },
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"/plugin-api/opkssh/callback?code=abc&state=xyz",
);
});
it("keeps a subpath", async () => {
const response = await fetch(`${base}/host/opkssh-callback/req-1/done`, {
redirect: "manual",
});
expect(response.headers.get("location")).toBe(
"/plugin-api/opkssh/callback/req-1/done",
);
});
});
describe("the old Step CA callback", () => {
it("forwards to the plugin's callback with the query", async () => {
const response = await fetch(
`${base}/host/step-ca-callback?code=abc&state=xyz`,
{ redirect: "manual" },
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"/plugin-api/step-ca/callback?code=abc&state=xyz",
);
});
});
describe("the old Vault callback", () => {
it("forwards to the plugin's callback with the query", async () => {
const { registerVaultCompatRoutes } =
await import("../../../database/routes/host-compat-routes.js");
const router = express.Router();
registerVaultCompatRoutes(router);
const app = express();
app.use("/vault", router);
const vaultServer = http.createServer(app);
await new Promise<void>((resolve) => vaultServer.listen(0, resolve));
const port = (vaultServer.address() as AddressInfo).port;
try {
const response = await fetch(
`http://127.0.0.1:${port}/vault/oidc/callback?code=abc&state=xyz`,
{ redirect: "manual" },
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"/plugin-api/vault/oidc/callback?code=abc&state=xyz",
);
} finally {
await new Promise<void>((resolve) => vaultServer.close(() => resolve()));
}
});
});
describe("the old Termix ID resolver URLs", () => {
it.each([
["/termix-id/u/alice", "/plugin-api/termix-identity/u/alice"],
[
"/termix-id/u/alice/ED25519",
"/plugin-api/termix-identity/u/alice/ED25519",
],
["/termix-id/u/alice/ca", "/plugin-api/termix-identity/u/alice/ca"],
])("permanently redirects %s", async (path, target) => {
const response = await fetch(`${base}${path}`, { redirect: "manual" });
expect(response.status).toBe(308);
expect(response.headers.get("location")).toBe(target);
});
it("does not forward the old management routes", async () => {
const response = await fetch(`${base}/termix-id/me`, {
redirect: "manual",
});
expect(response.status).toBe(404);
});
});
@@ -1,6 +1,8 @@
/**
* contributes.http.legacyPaths: an old URL under "/<plugin id>/" reaches the
* plugin's router with that prefix removed, and nothing else is caught.
* contributes.http.legacyRedirects: an old URL anywhere redirects to the
* plugin's own URL, keeping the subpath and the query.
*/
import express from "express";
@@ -16,10 +18,20 @@ vi.mock("../../../plugins/http.js", () => ({
const { mountPluginLegacyPaths } =
await import("../../../database/routes/plugin-api-routes.js");
function appWith(active: Array<{ id: string; legacyPaths: string[] }>) {
type Active = Parameters<typeof mountPluginLegacyPaths>[1] extends () => Array<
infer T
>
? T
: never;
function appWith(
active: Array<Omit<Active, "legacyRedirects"> & Partial<Active>>,
) {
const app = express();
app.get("/core/route", (_req, res) => res.json({ from: "core" }));
mountPluginLegacyPaths(app, () => active);
mountPluginLegacyPaths(app, () =>
active.map((entry) => ({ legacyRedirects: [], ...entry })),
);
app.use((_req, res) => res.status(404).json({ from: "fallthrough" }));
return app;
}
@@ -58,3 +70,37 @@ describe("mountPluginLegacyPaths", () => {
expect(response.status).toBe(404);
});
});
describe("legacy redirects", () => {
const app = appWith([
{
id: "idp",
legacyPaths: [],
legacyRedirects: [
{ from: "/host/idp-callback", to: "/callback" },
{ from: "/old-id/u", to: "/u", status: 308 },
],
},
]);
it("redirects to the plugin's URL with the query", async () => {
const response = await request(app).get(
"/host/idp-callback?code=abc&state=xyz",
);
expect(response.status).toBe(307);
expect(response.headers.location).toBe(
"/plugin-api/idp/callback?code=abc&state=xyz",
);
});
it("keeps a subpath and honours a permanent status", async () => {
const response = await request(app).get("/old-id/u/alice/ca");
expect(response.status).toBe(308);
expect(response.headers.location).toBe("/plugin-api/idp/u/alice/ca");
});
it("does not catch a path that only shares a prefix", async () => {
const response = await request(app).get("/host/idp-callbacks");
expect(response.body).toEqual({ from: "fallthrough" });
});
});
@@ -35,7 +35,7 @@ const state = vi.hoisted(() => ({
active: new Set<string>(),
}));
vi.mock("../../../plugins/boot-migrations.js", () => ({
vi.mock("../../../upgrade/boot-migrations.js", () => ({
runPluginDataMigrations: async () => {},
}));
+13 -13
View File
@@ -128,7 +128,7 @@ function startBackend(
let body = "";
req.on("data", (d) => (body += d));
req.on("end", () => {
if (!opts.failAll && req.url?.endsWith("/ssh/downloadFileStream")) {
if (!opts.failAll && req.url?.endsWith("/downloadFileStream")) {
// Headers go out immediately so the client opens its temp file;
// the body may be delayed to keep the transfer in flight.
res.writeHead(200, { "Content-Length": payload.length });
@@ -150,7 +150,7 @@ function startBackend(
server.listen(0, "127.0.0.1", () => {
const address = server.address() as { port: number };
resolve({
url: `http://127.0.0.1:${address.port}/ssh/file_manager`,
url: `http://127.0.0.1:${address.port}/plugin-api/file-manager`,
seen,
close: () => new Promise((r) => server.close(() => r())),
});
@@ -160,25 +160,25 @@ function startBackend(
describe("local-files transfer target resolution", () => {
const resolve = localFiles.createTargetResolver({
localBaseUrl: "http://127.0.0.1:30004/ssh/file_manager",
localBaseUrl: "http://127.0.0.1:30001/plugin-api/file-manager",
getRemoteSyncConfig: () => ({ serverUrl: "https://termix.example.com/" }),
getRemoteSyncJwt: () => "remote-jwt",
});
it("only reaches the two file-manager streaming routes on the local backend", () => {
expect(resolve({ origin: "local", route: "uploadFileStream" })).toEqual({
url: "http://127.0.0.1:30004/ssh/file_manager/ssh/uploadFileStream",
url: "http://127.0.0.1:30001/plugin-api/file-manager/uploadFileStream",
headers: { "X-Electron-App": "true" },
});
expect(resolve({ origin: "local", route: "downloadFileStream" }).url).toBe(
"http://127.0.0.1:30004/ssh/file_manager/ssh/downloadFileStream",
"http://127.0.0.1:30001/plugin-api/file-manager/downloadFileStream",
);
});
it("derives the remote target from the configured sync server and attaches its JWT itself", () => {
const target = resolve({ origin: "remote", route: "downloadFileStream" });
expect(target.url).toBe(
"https://termix.example.com/ssh/file_manager/ssh/downloadFileStream",
"https://termix.example.com/plugin-api/file-manager/downloadFileStream",
);
expect(target.headers.Authorization).toBe("Bearer remote-jwt");
});
@@ -242,7 +242,7 @@ describe("local-files transfer target resolution", () => {
});
expect(target.headers.Authorization).toBe(`Bearer ${jwt}`);
expect(target.url).toBe(
"http://127.0.0.1:30004/ssh/file_manager/ssh/uploadFileStream",
"http://127.0.0.1:30001/plugin-api/file-manager/uploadFileStream",
);
// Absent or empty: no header at all (the session cookie is the fallback).
for (const authToken of [undefined, null, ""]) {
@@ -355,7 +355,7 @@ describe("local-files download boundary", () => {
});
expect(result.success).toBe(true);
const last = backend.seen[backend.seen.length - 1];
expect(last.url).toBe("/ssh/file_manager/ssh/downloadFileStream");
expect(last.url).toBe("/plugin-api/file-manager/downloadFileStream");
expect(last.headers.authorization).toBeUndefined();
expect(last.headers.cookie).toBeUndefined();
expect(last.headers["x-electron-app"]).toBe("true");
@@ -952,7 +952,7 @@ describe("local-files upload boundary", () => {
const handlers = localFiles.createLocalFileHandlers({
net: fakeNet,
shell: {},
localBaseUrl: `http://127.0.0.1:${port}/ssh/file_manager`,
localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`,
});
const result = await handlers[localFiles.IPC.UPLOAD](fakeEvent, {
transferId: "up-1",
@@ -964,7 +964,7 @@ describe("local-files upload boundary", () => {
headers: { Authorization: "Bearer leaked" },
});
expect(result.success).toBe(true);
expect(received.url).toBe("/ssh/file_manager/ssh/uploadFileStream");
expect(received.url).toBe("/plugin-api/file-manager/uploadFileStream");
expect(received.headers?.authorization).toBeUndefined();
expect(received.fields).toEqual({
sessionId: "42",
@@ -1006,7 +1006,7 @@ describe("local-files upload boundary", () => {
const handlers = localFiles.createLocalFileHandlers({
net: fakeNet,
shell: {},
localBaseUrl: `http://127.0.0.1:${port}/ssh/file_manager`,
localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`,
});
const withoutToken = await handlers[localFiles.IPC.UPLOAD](fakeEvent, {
transferId: "up-auth-0",
@@ -1105,7 +1105,7 @@ describe("local-files upload boundary", () => {
const handlers = localFiles.createLocalFileHandlers({
net: fakeNet,
shell: {},
localBaseUrl: `http://127.0.0.1:${port}/ssh/file_manager`,
localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`,
});
const results = await Promise.all(
files.map((f) =>
@@ -1131,7 +1131,7 @@ describe("local-files upload boundary", () => {
const handlers = localFiles.createLocalFileHandlers({
net: fakeNet,
shell: {},
localBaseUrl: "http://127.0.0.1:1/ssh/file_manager",
localBaseUrl: "http://127.0.0.1:1/plugin-api/file-manager",
});
const result = await handlers[localFiles.IPC.UPLOAD](fakeEvent, {
transferId: "up-2",
@@ -66,7 +66,7 @@ vi.mock("../../../hosts/ssh-connection-pool.js", () => ({
import {
buildConnectConfig,
getPurposeDefaults,
getProfileDefaults,
} from "../../../hosts/connect/build-connect-config.js";
import {
connectHost,
@@ -76,6 +76,7 @@ import {
} from "../../../hosts/connect/connect-host.js";
import {
getSshAuthProvider,
isQuickConnectAuthType,
listCredentialTypes,
registerSshAuthProvider,
setSshAuthTypeOwnerSource,
@@ -137,11 +138,12 @@ function registerSignInProvider(hasCert: boolean) {
async function build(
target: SshConnectHost,
purpose: Parameters<typeof buildConnectConfig>[1]["purpose"] = "terminal",
profile: Parameters<typeof buildConnectConfig>[1]["profile"] = "terminal",
) {
return buildConnectConfig(target, {
userId: "user-1",
purpose,
purpose: "test",
profile,
client: new FakeSshClient() as never,
});
}
@@ -349,7 +351,7 @@ describe("buildConnectConfig per auth type", () => {
it("jump hops skip keyboard-interactive for auth type none", async () => {
const { config } = await build(
host({ authType: "none", password: null }),
"jump-host",
"jump",
);
expect(config.tryKeyboard).toBe(false);
expect(mocks.createHostVerifier).toHaveBeenCalledWith(
@@ -364,29 +366,33 @@ describe("buildConnectConfig per auth type", () => {
});
});
describe("purpose defaults", () => {
describe("profile defaults", () => {
it("keeps each transport's old keepalive numbers", () => {
expect(getPurposeDefaults("terminal")).toMatchObject({
expect(getProfileDefaults("terminal")).toMatchObject({
keepaliveIntervalMs: 30000,
keepaliveCountMax: 5,
});
expect(getPurposeDefaults("file-manager")).toMatchObject({
expect(getProfileDefaults("session")).toMatchObject({
keepaliveIntervalMs: 60000,
keepaliveCountMax: 5,
});
expect(getPurposeDefaults("fleet")).toMatchObject({
expect(getProfileDefaults("background")).toMatchObject({
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
});
});
it("uses the background profile when none is given", () => {
expect(getProfileDefaults()).toEqual(getProfileDefaults("background"));
});
it("host keepalive settings apply where the transport honoured them", async () => {
const withSettings = host({
terminalConfig: { keepaliveInterval: 10, keepaliveCountMax: 2 },
});
const terminal = await build(withSettings, "terminal");
expect(terminal.config.keepaliveInterval).toBe(10000);
const fleet = await build(withSettings, "fleet");
const fleet = await build(withSettings, "background");
expect(fleet.config.keepaliveInterval).toBe(30000);
});
});
@@ -415,6 +421,31 @@ describe("provider registry", () => {
expect(outcome).toMatchObject({ code: "provider-missing" });
});
it("offers core types and opted-in plugin types to Quick Connect", () => {
ensureCoreSshAuthProviders();
for (const type of ["password", "key", "credential", "agent", "none"]) {
expect(isQuickConnectAuthType(type)).toBe(true);
}
const plain = registerSshAuthProvider({
type: "needs-saved-host",
pluginId: "fixture-plugin",
labelKey: "x",
prepare: async () => ({ status: "ready" }),
});
const quick = registerSshAuthProvider({
type: "works-unsaved",
pluginId: "fixture-plugin",
labelKey: "x",
quickConnect: true,
prepare: async () => ({ status: "ready" }),
});
expect(isQuickConnectAuthType("needs-saved-host")).toBe(false);
expect(isQuickConnectAuthType("works-unsaved")).toBe(true);
expect(isQuickConnectAuthType("missing")).toBe(false);
plain();
quick();
});
it("refuses to let a second plugin take over a type", () => {
ensureCoreSshAuthProviders();
expect(() =>
+1 -1
View File
@@ -127,7 +127,7 @@ export async function bootCore(seed: Database.Database): Promise<BootedCore> {
await initializePlugins();
const { runPluginDataMigrations } =
await import("../../plugins/boot-migrations.js");
await import("../../upgrade/boot-migrations.js");
await runPluginDataMigrations();
return {
@@ -653,3 +653,36 @@ describe("named service providers", () => {
}
});
});
describe("contributes.http.legacyRedirects", () => {
const withRedirects = (legacyRedirects: unknown) =>
validateManifest(base({ contributes: { http: { legacyRedirects } } }));
it("accepts an old URL outside the plugin's namespace", () => {
expect(
withRedirects([
{ from: "/users/oidc/callback", to: "/callback", status: 308 },
{ from: "/host/old-callback", to: "/callback" },
]),
).toEqual([]);
});
it("refuses the plugin framework's own prefixes", () => {
expect(
withRedirects([{ from: "/plugin-api/other/x", to: "/x" }]).join(),
).toMatch(/cannot be under plugin-api/);
});
it("refuses a relative path, a bad status and unknown keys", () => {
const errors = withRedirects([
{ from: "users/x", to: "/x" },
{ from: "/a", to: "x" },
{ from: "/a", to: "/x", status: 302 },
{ from: "/a", to: "/x", extra: true },
]).join();
expect(errors).toMatch(/from must be an absolute path/);
expect(errors).toMatch(/to must be a path/);
expect(errors).toMatch(/status must be 307 or 308/);
expect(errors).toMatch(/extra/);
});
});
@@ -22,7 +22,7 @@ const state = vi.hoisted(() => ({
warnings: [] as string[],
}));
vi.mock("../../plugins/boot-migrations.js", () => ({
vi.mock("../../upgrade/boot-migrations.js", () => ({
runPluginDataMigrations: async () => {},
}));
@@ -224,7 +224,7 @@ describe("namespace escalation is refused", () => {
["admin.users.manage", /reserved core group/],
["hosts.view", /reserved core group/],
["credentials.read", /reserved core group/],
["snippets.edit", /reserved core group/],
["admin.plugins.manage", /reserved core group/],
])("refuses the core name %s", (name, pattern) => {
const { errors } = parseManifest(manifest("sample", [permission(name)]));
+137
View File
@@ -0,0 +1,137 @@
/**
* A user's rows in plugin tables travel with their export and come back on
* import, found through the table definitions plugins registered rather than
* any table name core knows.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
defineTable,
encryptedText,
id,
refUser,
text,
varchar,
} from "@termix/plugin-sdk/db";
let sqlite: Database.Database;
vi.mock("../../database/db/index.js", () => ({
getDb: () => drizzle(sqlite),
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
const { registerTable, resetPluginData } =
await import("../../plugins/data.js");
const {
importUserPluginRows,
listUserOwnedTables,
readUserPluginRows,
writeUserPluginTables,
} = await import("../../plugins/user-data.js");
const { createTableSql } = await import("../../plugins/table-builder.js");
// Stands for an adopted table; adoptLegacyTable only accepts real core names.
const bookmarks = {
...defineTable("bookmark", {
id: id(),
userId: refUser(),
path: varchar(200).notNull(),
note: text(),
token: encryptedText(),
}),
adopts: "bookmarks_legacy",
};
const shared = defineTable("shared_thing", { id: id(), label: text() });
function createTables(target: Database.Database) {
target.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
target.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
for (const definition of [bookmarks, shared]) {
for (const statement of createTableSql("sqlite", "fixture", definition)) {
target.exec(statement);
}
}
}
beforeEach(() => {
sqlite = new Database(":memory:");
createTables(sqlite);
sqlite.exec("INSERT INTO users (id) VALUES ('u1'), ('u2')");
sqlite.exec(`INSERT INTO p_fixture_bookmark (user_id, path, note, token)
VALUES ('u1', '/etc', 'config', 'sealed'), ('u2', '/var', null, null)`);
registerTable("fixture", bookmarks);
registerTable("fixture", shared);
});
afterEach(() => {
resetPluginData();
sqlite.close();
});
describe("plugin user data", () => {
it("lists only tables with a user column, without keys or secrets", () => {
const [owned] = listUserOwnedTables();
expect(listUserOwnedTables()).toHaveLength(1);
expect(owned).toMatchObject({
pluginId: "fixture",
table: "p_fixture_bookmark",
legacyName: "bookmarks_legacy",
userColumn: "user_id",
columns: ["user_id", "path", "note"],
});
});
it("reads one user's rows", async () => {
expect(await readUserPluginRows("u1")).toEqual({
p_fixture_bookmark: [{ user_id: "u1", path: "/etc", note: "config" }],
});
});
it("round-trips through an export file into another user", async () => {
// The export file has the user row, as database.ts writes it first.
const file = new Database(":memory:");
file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
file.exec("INSERT INTO users (id) VALUES ('u1')");
expect(await writeUserPluginTables(file, "u1")).toBe(1);
sqlite.exec("DELETE FROM p_fixture_bookmark");
const result = await importUserPluginRows(file, "u2");
expect(result).toEqual({ imported: 1, skipped: 0, errors: [] });
expect(
sqlite.prepare("SELECT user_id, path FROM p_fixture_bookmark").all(),
).toEqual([{ user_id: "u2", path: "/etc" }]);
const again = await importUserPluginRows(file, "u2");
expect(again).toMatchObject({ imported: 0, skipped: 1 });
});
it("reads a pre-2.9 export by the legacy table name", async () => {
const file = new Database(":memory:");
file.exec(
"CREATE TABLE bookmarks_legacy (id INTEGER PRIMARY KEY, user_id TEXT, path TEXT, note TEXT)",
);
file.exec(
"INSERT INTO bookmarks_legacy (user_id, path, note) VALUES ('old', '/srv', 'x')",
);
const result = await importUserPluginRows(file, "u1");
expect(result.imported).toBe(1);
expect(
sqlite
.prepare(
"SELECT user_id, note FROM p_fixture_bookmark WHERE path = '/srv'",
)
.get(),
).toEqual({ user_id: "u1", note: "x" });
});
});
@@ -56,7 +56,7 @@ vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runAcmeSslSettingsMigration } from "../../utils/crypto-migration/acme-ssl-settings-migration.js";
import { runAcmeSslSettingsMigration } from "../../upgrade/acme-ssl-settings-migration.js";
const value = (key: string) => {
const row = state.pluginRows.find((candidate) => candidate.key === key);
@@ -130,7 +130,7 @@ vi.mock("../../utils/logger.js", () => ({
import {
allowlistToText,
runAiSettingsMigration,
} from "../../utils/crypto-migration/ai-settings-migration.js";
} from "../../upgrade/ai-settings-migration.js";
const value = (scope: string, scopeId: string | null, key: string) => {
const row = state.pluginRows.find(
@@ -66,7 +66,7 @@ vi.mock("../../utils/logger.js", () => ({
import {
dockerSettingsFromRow,
runDockerSettingsMigration,
} from "../../utils/crypto-migration/docker-settings-migration.js";
} from "../../upgrade/docker-settings-migration.js";
const hostValues = (hostId: number) =>
Object.fromEntries(
@@ -1,13 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createAuthState,
type AuthState,
} from "../../auth/auth-test-helpers.js";
import { createAuthState, type AuthState } from "../auth/auth-test-helpers.js";
const h = vi.hoisted(() => ({ state: null as unknown as AuthState }));
vi.mock("../../../database/repositories/factory.js", async () => {
const helpers = await import("../../auth/auth-test-helpers.js");
vi.mock("../../database/repositories/factory.js", async () => {
const helpers = await import("../auth/auth-test-helpers.js");
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
return Object.fromEntries(
names.map((name) => [
@@ -22,12 +19,12 @@ vi.mock("../../../database/repositories/factory.js", async () => {
]),
);
});
vi.mock("../../../utils/logger.js", () => ({
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
const { runExternalIdentityMigration, parseLegacyIdentifier } =
await import("../../../utils/crypto-migration/external-identity-migration.js");
await import("../../upgrade/external-identity-migration.js");
function user(id: string, extra: Record<string, unknown>) {
h.state.users.set(id, {
@@ -80,7 +80,7 @@ vi.mock("../../database/db/index.js", () => ({
}));
const { runFileManagerSettingsMigration } =
await import("../../utils/crypto-migration/file-manager-settings-migration.js");
await import("../../upgrade/file-manager-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
@@ -70,7 +70,7 @@ vi.mock("../../utils/logger.js", () => ({
}));
const { runHostMetricsSettingsMigration, hostSettingsFromStatsConfig } =
await import("../../utils/crypto-migration/host-metrics-settings-migration.js");
await import("../../upgrade/host-metrics-settings-migration.js");
let sqlite: Database.Database;
@@ -17,7 +17,7 @@ vi.mock("../../utils/logger.js", () => {
});
const { runLdapProviderMigration } =
await import("../../utils/crypto-migration/ldap-provider-migration.js");
await import("../../upgrade/ldap-provider-migration.js");
let sqlite: Database.Database;
@@ -39,7 +39,7 @@ vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runOpksshConfigMigration } from "../../utils/crypto-migration/opkssh-config-migration.js";
import { runOpksshConfigMigration } from "../../upgrade/opkssh-config-migration.js";
let dataDir: string;
const legacy = () => path.join(dataDir, ".opk", "config.yml");
@@ -81,7 +81,7 @@ vi.mock("../../database/db/index.js", () => ({
}));
const { runProxmoxSettingsMigration } =
await import("../../utils/crypto-migration/proxmox-settings-migration.js");
await import("../../upgrade/proxmox-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
@@ -88,7 +88,7 @@ vi.mock("../../utils/logger.js", () => ({
}));
const { runRemoteDesktopSettingsMigration, hostSettingsFromRow } =
await import("../../utils/crypto-migration/remote-desktop-settings-migration.js");
await import("../../upgrade/remote-desktop-settings-migration.js");
const value = (scope: string, scopeId: string | null, key: string) => {
const row = find(scope, scopeId, key) as Row | undefined;
@@ -117,7 +117,7 @@ vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runSecretSourcesTokenMigration } from "../../utils/crypto-migration/secret-sources-token-migration.js";
import { runSecretSourcesTokenMigration } from "../../upgrade/secret-sources-token-migration.js";
const value = (scopeId: string | null, key: string) => {
const row = state.pluginRows.find(
@@ -73,7 +73,7 @@ vi.mock("../../database/db/index.js", () => ({
}));
const { runSessionRecordingSettingsMigration } =
await import("../../utils/crypto-migration/session-recording-settings-migration.js");
await import("../../upgrade/session-recording-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
@@ -71,7 +71,7 @@ vi.mock("../../database/db/index.js", () => ({
}));
const { runSessionSharingSettingsMigration } =
await import("../../utils/crypto-migration/session-sharing-settings-migration.js");
await import("../../upgrade/session-sharing-settings-migration.js");
function stored(scopeId: string | null, key: string): unknown {
const row = settingsRows.find(
@@ -96,7 +96,7 @@ vi.mock("../../utils/system-secret-crypto.js", () => ({
}));
const { runSshTerminalSettingsMigration } =
await import("../../utils/crypto-migration/ssh-terminal-settings-migration.js");
await import("../../upgrade/ssh-terminal-settings-migration.js");
function admin(key: string): unknown {
const row = find("ssh-terminal", "admin", null, key);
@@ -44,7 +44,7 @@ vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runStepCaSettingsMigration } from "../../utils/crypto-migration/step-ca-settings-migration.js";
import { runStepCaSettingsMigration } from "../../upgrade/step-ca-settings-migration.js";
const value = (key: string) => {
const row = state.pluginRows.find((candidate) => candidate.key === key);
@@ -86,7 +86,7 @@ vi.mock("../../utils/system-secret-crypto.js", () => ({
}));
const { runTailscaleSettingsMigration } =
await import("../../utils/crypto-migration/tailscale-settings-migration.js");
await import("../../upgrade/tailscale-settings-migration.js");
function storedValue(key: string): unknown {
const row = pluginRows.find((entry) => entry.key === key);
@@ -29,7 +29,7 @@ vi.mock("../../utils/logger.js", () => {
});
const { runTermixIdentityCaMigration } =
await import("../../utils/crypto-migration/termix-identity-ca-migration.js");
await import("../../upgrade/termix-identity-ca-migration.js");
const PEM = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n";
@@ -72,7 +72,7 @@ vi.mock("../../database/db/index.js", () => ({
}));
const { runTmuxMonitorSettingsMigration } =
await import("../../utils/crypto-migration/tmux-monitor-settings-migration.js");
await import("../../upgrade/tmux-monitor-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
@@ -29,8 +29,7 @@ vi.mock("../../utils/logger.js", () => {
return { databaseLogger: log };
});
const { runTotpMigration } =
await import("../../utils/crypto-migration/totp-migration.js");
const { runTotpMigration } = await import("../../upgrade/totp-migration.js");
let sqlite: Database.Database;
@@ -76,7 +76,7 @@ vi.mock("../../database/db/index.js", () => ({
}));
const { runTunnelsSettingsMigration } =
await import("../../utils/crypto-migration/tunnels-settings-migration.js");
await import("../../upgrade/tunnels-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
@@ -69,7 +69,7 @@ vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runVaultSettingsMigration } from "../../utils/crypto-migration/vault-settings-migration.js";
import { runVaultSettingsMigration } from "../../upgrade/vault-settings-migration.js";
const setting = (scopeId: string | null, key: string) =>
state.pluginRows.find((row) => row.scopeId === scopeId && row.key === key)
@@ -65,7 +65,7 @@ vi.mock("../../utils/logger.js", () => ({
import {
wakeOnLanSettingsFromRow,
runWakeOnLanSettingsMigration,
} from "../../utils/crypto-migration/wake-on-lan-settings-migration.js";
} from "../../upgrade/wake-on-lan-settings-migration.js";
const hostValues = (hostId: number) =>
Object.fromEntries(
@@ -78,7 +78,7 @@ vi.mock("../../utils/logger.js", () => ({
import {
runWarpgateSettingsMigration,
usesWarpgate,
} from "../../utils/crypto-migration/warpgate-settings-migration.js";
} from "../../upgrade/warpgate-settings-migration.js";
const enabled = (hostId: number) =>
state.pluginRows.some(
@@ -74,7 +74,7 @@ vi.mock("../../database/db/index.js", () => ({
}));
const { runWebEndpointSettingsMigration } =
await import("../../utils/crypto-migration/web-endpoint-settings-migration.js");
await import("../../upgrade/web-endpoint-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
@@ -186,7 +186,6 @@ describe("analytics", () => {
properties: expect.objectContaining({
user_count: 0,
host_count: 0,
used_terminal: 0,
}),
});
});
@@ -49,26 +49,6 @@ function legacyDatabase(): Database.Database {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE session_recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
access_id INTEGER,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT,
duration INTEGER,
commands TEXT,
dangerous_actions TEXT,
recording_path TEXT,
protocol TEXT NOT NULL DEFAULT 'ssh',
format TEXT NOT NULL DEFAULT 'text',
terminated_by_owner INTEGER DEFAULT 0,
termination_reason TEXT,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
);
INSERT INTO users (id, username) VALUES ('u-1', 'alice'), ('u-2', 'bob');
INSERT INTO ssh_data (id, name) VALUES (1, 'prod-db');
@@ -78,12 +58,6 @@ function legacyDatabase(): Database.Database {
('u-1', 'alice', 'host.delete', 'host', '1', 1, '2026-07-01 10:00:00'),
('u-1', 'alice', 'credential.view', 'credential', '9', 1, '2026-07-02 11:00:00'),
('u-2', 'bob', 'host.create', 'host', '2', 1, '2026-07-03 12:00:00');
INSERT INTO session_recordings
(host_id, user_id, started_at, recording_path, protocol, format)
VALUES
(1, 'u-1', '2026-07-01 10:00:00', '/rec/a.guac', 'ssh', 'text'),
(1, 'u-2', '2026-07-03 12:00:00', '/rec/b.guac', 'ssh', 'text');
`);
return sqlite;
}
@@ -93,15 +67,10 @@ describe("audit retention migration", () => {
db = legacyDatabase();
expect(userDeleteIsDestructive(db, "audit_logs")).toBe(true);
expect(userDeleteIsDestructive(db, "session_recordings")).toBe(true);
expect(migrateAuditRetention(db)).toEqual([
"audit_logs",
"session_recordings",
]);
expect(migrateAuditRetention(db)).toEqual(["audit_logs"]);
expect(userDeleteIsDestructive(db, "audit_logs")).toBe(false);
expect(userDeleteIsDestructive(db, "session_recordings")).toBe(false);
});
it("keeps the audit trail when the user is deleted", () => {
@@ -126,23 +95,6 @@ describe("audit retention migration", () => {
expect(rows[2].user_id).toBe("u-2");
});
it("backfills a username onto recordings so they stay attributable", () => {
db = legacyDatabase();
migrateAuditRetention(db);
db.exec("DELETE FROM users WHERE id = 'u-1'");
const rows = db
.prepare(
"SELECT user_id, username, recording_path FROM session_recordings ORDER BY started_at",
)
.all() as { user_id: string | null; username: string | null }[];
expect(rows).toHaveLength(2);
expect(rows[0].user_id).toBeNull();
expect(rows[0].username).toBe("alice");
});
it("loses no data in the copy", () => {
db = legacyDatabase();
const before = db
@@ -158,18 +110,6 @@ describe("audit retention migration", () => {
expect(after).toEqual(before);
});
it("still cascades recordings when their host is deleted", () => {
db = legacyDatabase();
migrateAuditRetention(db);
db.exec("PRAGMA foreign_keys = ON");
db.exec("DELETE FROM ssh_data WHERE id = 1");
expect(
db.prepare("SELECT COUNT(*) AS n FROM session_recordings").get(),
).toEqual({ n: 0 });
});
it("is idempotent and leaves an already-migrated database alone", () => {
db = legacyDatabase();
migrateAuditRetention(db);
Loaded 100 of 399 files, more files were not shown because too many files have changed in this diff. Show more