mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
chore: lock the plugin boundary and remove 2.9.0 leftovers
This commit is contained in:
1 parent
6489b62ece
commit
2961297989
399 files changed
+3964
-5627
No files matched your search
@@ -99,7 +99,7 @@ export async function verifyPasswordLogin(request: {
|
||||
};
|
||||
}
|
||||
|
||||
export const passwordLoginMethod: LoginMethod = {
|
||||
const passwordLoginMethod: LoginMethod = {
|
||||
id: "password",
|
||||
pluginId: "core",
|
||||
labelKey: "auth.password",
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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 } =
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
*
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,4 @@ export {
|
||||
dropTableSql,
|
||||
addColumnSql,
|
||||
keyedColumns,
|
||||
columnName,
|
||||
REFERENCEABLE,
|
||||
} from "@termix/plugin-sdk/ddl";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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)]));
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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);
|
||||
+1
-1
@@ -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(
|
||||
+1
-1
@@ -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(
|
||||
+5
-8
@@ -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, {
|
||||
+1
-1
@@ -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(
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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");
|
||||
+1
-1
@@ -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(
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -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(
|
||||
+1
-1
@@ -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(
|
||||
+1
-1
@@ -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(
|
||||
+1
-1
@@ -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);
|
||||
+1
-1
@@ -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);
|
||||
+1
-1
@@ -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);
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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(
|
||||
+1
-2
@@ -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;
|
||||
|
||||
+1
-1
@@ -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(
|
||||
+1
-1
@@ -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)
|
||||
+1
-1
@@ -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(
|
||||
+1
-1
@@ -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(
|
||||
+1
-1
@@ -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
Reference in new issue
Block a user