chore: lock the plugin boundary and remove 2.9.0 leftovers

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

No files matched your search

+17 -215
View File
@@ -7,10 +7,6 @@ import multer from "multer";
import cookieParser from "cookie-parser";
import userRoutes from "./routes/users.js";
import hostRoutes from "./routes/host.js";
import {
registerTermixIdCompatRoutes,
registerVaultCompatRoutes,
} from "./routes/host-compat-routes.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
@@ -44,6 +40,10 @@ import { DataCrypto } from "../utils/data-crypto.js";
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
import { DatabaseMigration } from "../utils/database-migration.js";
import { UserDataExport } from "../utils/user-data-export.js";
import {
importUserPluginRows,
writeUserPluginTables,
} from "../plugins/user-data.js";
import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
import {
@@ -65,8 +65,7 @@ import type {
GitHubAPIResponse,
AuthenticatedRequest,
} from "../../types/index.js";
import { DatabaseSaveTrigger, getDb } from "./db/index.js";
import { sql } from "drizzle-orm";
import { DatabaseSaveTrigger } from "./db/index.js";
import Database from "better-sqlite3";
import { fileURLToPath } from "url";
@@ -867,43 +866,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE p_file_manager_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE p_file_manager_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE p_file_manager_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE p_file_manager_transfer_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
source_host_id INTEGER NOT NULL,
dest_host_id INTEGER NOT NULL,
dest_path TEXT NOT NULL,
dest_path_label TEXT NOT NULL,
last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -1043,86 +1005,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
// The file manager plugin owns these tables now (p_file_manager_*);
// read them by raw SQL rather than importing plugin code into core.
const drizzleDb = getDb();
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
drizzleDb.all<{
id: number;
user_id: string;
host_id: number;
name: string;
path: string;
last_opened: string;
}>(
sql`SELECT id, user_id, host_id, name, path, last_opened FROM p_file_manager_recent WHERE user_id = ${userId}`,
),
drizzleDb.all<{
id: number;
user_id: string;
host_id: number;
name: string;
path: string;
pinned_at: string;
}>(
sql`SELECT id, user_id, host_id, name, path, pinned_at FROM p_file_manager_pinned WHERE user_id = ${userId}`,
),
drizzleDb.all<{
id: number;
user_id: string;
host_id: number;
name: string;
path: string;
created_at: string;
}>(
sql`SELECT id, user_id, host_id, name, path, created_at FROM p_file_manager_shortcuts WHERE user_id = ${userId}`,
),
]);
const insertRecent = exportDb.prepare(`
INSERT INTO p_file_manager_recent (id, user_id, host_id, name, path, last_opened)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of recentFiles) {
insertRecent.run(
item.id,
item.user_id,
item.host_id,
item.name,
item.path,
item.last_opened,
);
}
const insertPinned = exportDb.prepare(`
INSERT INTO p_file_manager_pinned (id, user_id, host_id, name, path, pinned_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of pinnedFiles) {
insertPinned.run(
item.id,
item.user_id,
item.host_id,
item.name,
item.path,
item.pinned_at,
);
}
const insertShortcut = exportDb.prepare(`
INSERT INTO p_file_manager_shortcuts (id, user_id, host_id, name, path, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of shortcuts) {
insertShortcut.run(
item.id,
item.user_id,
item.host_id,
item.name,
item.path,
item.created_at,
);
}
// Rows the user owns in plugin tables travel with the export.
await writeUserPluginTables(exportDb, userId);
const dismissedAlertRepository = createCurrentDismissedAlertRepository();
const alerts = await dismissedAlertRepository.listByUserId(userId);
@@ -1332,7 +1216,7 @@ app.post(
summary: {
sshHostsImported: 0,
sshCredentialsImported: 0,
fileManagerItemsImported: 0,
pluginItemsImported: 0,
dismissedAlertsImported: 0,
credentialUsageImported: 0,
settingsImported: 0,
@@ -1477,90 +1361,10 @@ app.post(
);
}
// The file manager plugin owns these tables now (p_file_manager_*).
// Older export files still have the legacy name, so try that too.
const fileManagerTables = [
{
tableNames: ["p_file_manager_recent", "file_manager_recent"],
physicalTable: "p_file_manager_recent",
dateColumn: "last_opened",
key: "fileManagerItemsImported",
},
{
tableNames: ["p_file_manager_pinned", "file_manager_pinned"],
physicalTable: "p_file_manager_pinned",
dateColumn: "pinned_at",
key: "fileManagerItemsImported",
},
{
tableNames: [
"p_file_manager_shortcuts",
"file_manager_shortcuts",
],
physicalTable: "p_file_manager_shortcuts",
dateColumn: "created_at",
key: "fileManagerItemsImported",
},
];
const importDrizzleDb = getDb();
for (const {
tableNames,
physicalTable,
dateColumn,
key,
} of fileManagerTables) {
let importedItems: Array<Record<string, unknown>> | null = null;
let sourceTable = "";
for (const tableName of tableNames) {
try {
importedItems = importDb
.prepare(`SELECT * FROM ${tableName}`)
.all() as Array<Record<string, unknown>>;
sourceTable = tableName;
break;
} catch {
// try the next name
}
}
if (importedItems === null) {
apiLogger.info(
`${physicalTable} table not found in import file, skipping`,
);
continue;
}
for (const item of importedItems) {
try {
const hostId = item.host_id as number;
const path = item.path as string;
const name =
(item.name as string) || path.split("/").pop() || "Unknown";
const dateValue =
(item[dateColumn] as string) || new Date().toISOString();
const existing = await importDrizzleDb.all<{ id: number }>(
sql`SELECT id FROM ${sql.raw(physicalTable)} WHERE user_id = ${userId} AND host_id = ${hostId} AND path = ${path}`,
);
if (existing.length > 0) {
result.summary.skippedItems++;
continue;
}
await importDrizzleDb.run(
sql`INSERT INTO ${sql.raw(physicalTable)} (user_id, host_id, path, name, ${sql.raw(dateColumn)}) VALUES (${userId}, ${hostId}, ${path}, ${name}, ${dateValue})`,
);
result.summary[key]++;
} catch (itemError) {
result.summary.errors.push(
`${sourceTable} import error: ${itemError.message}`,
);
}
}
}
const pluginRows = await importUserPluginRows(importDb, userId);
result.summary.pluginItemsImported += pluginRows.imported;
result.summary.skippedItems += pluginRows.skipped;
result.summary.errors.push(...pluginRows.errors);
const dismissedAlertRepository =
createCurrentDismissedAlertRepository();
@@ -1819,13 +1623,7 @@ app.use("/user-preferences", userPreferencesRoutes);
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes);
const termixIdCompatRoutes = express.Router();
registerTermixIdCompatRoutes(termixIdCompatRoutes);
app.use("/termix-id", termixIdCompatRoutes);
registerAuditLogRoutes(app, authenticateJWT);
const vaultCompatRoutes = express.Router();
registerVaultCompatRoutes(vaultCompatRoutes);
app.use("/vault", vaultCompatRoutes);
app.use("/", notificationChannelsRoutes);
app.use("/sync", syncRoutes);
app.use("/dashboard", dashboardRoutes);
@@ -1842,8 +1640,12 @@ mountPluginLegacyPaths(app, () =>
.map((plugin) => ({
id: plugin.id,
legacyPaths: plugin.manifest.contributes?.http?.legacyPaths ?? [],
legacyRedirects: plugin.manifest.contributes?.http?.legacyRedirects ?? [],
}))
.filter((plugin) => plugin.legacyPaths.length > 0),
.filter(
(plugin) =>
plugin.legacyPaths.length > 0 || plugin.legacyRedirects.length > 0,
),
);
const frontendDistPaths = [
+2 -48
View File
@@ -322,11 +322,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
-- file_manager_recent, file_manager_pinned, file_manager_shortcuts and
-- transfer_recent are owned by the file-manager plugin now. Its own
-- migration creates p_file_manager_* on a fresh install and adopts the
-- legacy tables by rename on an upgrade.
CREATE TABLE IF NOT EXISTS dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -391,9 +386,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
-- command_history is owned by the ssh-terminal plugin, which adopts it
-- as p_ssh_terminal_command_history in its first migration.
CREATE TABLE IF NOT EXISTS host_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
@@ -1120,10 +1112,6 @@ const migrateSchema = () => {
});
}
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
try {
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
} catch {
@@ -1315,25 +1303,6 @@ const migrateSchema = () => {
}
}
// Rename the legacy "stats" tab type to "host-metrics" so previously saved
// open tabs restore correctly after the Server Stats -> Host Metrics rename.
try {
sqlite.prepare("SELECT id FROM user_open_tabs LIMIT 1").get();
const result = sqlite
.prepare(
"UPDATE user_open_tabs SET tab_type = 'host-metrics' WHERE tab_type = 'stats'",
)
.run();
if (result.changes > 0) {
databaseLogger.info(
`Migrated ${result.changes} open tab(s) from 'stats' to 'host-metrics'`,
{ operation: "open_tabs_tab_type_migration" },
);
}
} catch {
// user_open_tabs table not present yet; nothing to migrate.
}
try {
sqlite.prepare("SELECT id FROM shared_host_secrets LIMIT 1").get();
} catch {
@@ -1619,8 +1588,6 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "sync_id", "TEXT");
addColumnIfNotExists("ssh_credentials", "sync_id", "TEXT");
addColumnIfNotExists("ssh_folders", "sync_id", "TEXT");
addColumnIfNotExists("snippets", "sync_id", "TEXT");
addColumnIfNotExists("snippet_folders", "sync_id", "TEXT");
// Plugin runtime: lastError reports why a plugin is blocked or failed, and
// grants record whether a capability came from an admin or from bundling.
@@ -1632,13 +1599,7 @@ const migrateSchema = () => {
);
relaxPluginGrantGrantedBy();
const syncIdTables = [
"ssh_data",
"ssh_credentials",
"ssh_folders",
"snippets",
"snippet_folders",
];
const syncIdTables = ["ssh_data", "ssh_credentials", "ssh_folders"];
for (const table of syncIdTables) {
try {
@@ -1978,14 +1939,7 @@ export function getSqlite(): Database.Database {
return sqlite;
}
export { db };
export { DatabaseFileEncryption };
export const databasePaths = {
main: actualDbPath,
encrypted: encryptedDbPath,
directory: dbDir,
inMemory: true,
};
export { saveMemoryDatabaseToFile };
@@ -98,16 +98,12 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
columns: "user_id",
},
// Per-user history and file manager surfaces.
{
name: "idx_recent_activity_user_ts",
table: "recent_activity",
columns: "user_id, timestamp",
},
// file_manager_recent, file_manager_pinned, file_manager_shortcuts,
// transfer_recent and command_history are owned by plugins now; their
// adopted table definitions carry these same indexes across the rename.
{
name: "idx_user_open_tabs_user_id",
table: "user_open_tabs",
+2 -20
View File
@@ -271,9 +271,6 @@ export const hosts = mysqlTable(
],
);
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
export const dismissedAlerts = mysqlTable(
"dismissed_alerts",
{
@@ -317,7 +314,6 @@ export const sshCredentials = mysqlTable(
certPublicKey: text("cert_public_key"),
usageCount: int("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: varchar("sync_id", { length: 255 }).unique(),
@@ -724,11 +720,6 @@ export const uiPreferences = mysqlTable("ui_preferences", {
.default(sql`(CURRENT_TIMESTAMP)`),
});
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
// termix-identity plugin (p_termix_identity_*).
// --- alerts begin ---
export const notificationChannels = mysqlTable("notification_channels", {
id: int("id").autoincrement().primaryKey(),
@@ -745,12 +736,6 @@ export const notificationChannels = mysqlTable("notification_channels", {
});
// --- alerts end ---
// homepage_items and homepage_layouts moved to the homepage plugin
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
@@ -768,14 +753,11 @@ export const syncTombstones = mysqlTable("sync_tombstones", {
});
// --- sync end ---
// --- collab rooms ---
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
* Who may use or manage someone else's credential. "use" attaches it to
* hosts and connects, "manage" also edits and re-shares it.
*/
export const credentialAccess = mysqlTable(
"credential_access",
+2 -20
View File
@@ -272,9 +272,6 @@ export const hosts = pgTable(
],
);
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
export const dismissedAlerts = pgTable(
"dismissed_alerts",
{
@@ -318,7 +315,6 @@ export const sshCredentials = pgTable(
certPublicKey: text("cert_public_key"),
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: varchar("sync_id", { length: 255 }).unique(),
@@ -725,11 +721,6 @@ export const uiPreferences = pgTable("ui_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
// termix-identity plugin (p_termix_identity_*).
// --- alerts begin ---
export const notificationChannels = pgTable("notification_channels", {
id: serial("id").primaryKey(),
@@ -746,12 +737,6 @@ export const notificationChannels = pgTable("notification_channels", {
});
// --- alerts end ---
// homepage_items and homepage_layouts moved to the homepage plugin
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
@@ -769,14 +754,11 @@ export const syncTombstones = pgTable("sync_tombstones", {
});
// --- sync end ---
// --- collab rooms ---
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
* Who may use or manage someone else's credential. "use" attaches it to
* hosts and connects, "manage" also edits and re-shares it.
*/
export const credentialAccess = pgTable(
"credential_access",
+2 -20
View File
@@ -264,9 +264,6 @@ export const hosts = sqliteTable(
],
);
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
export const dismissedAlerts = sqliteTable(
"dismissed_alerts",
{
@@ -310,7 +307,6 @@ export const sshCredentials = sqliteTable(
certPublicKey: text("cert_public_key", { length: 8192 }),
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: text("sync_id").unique(),
@@ -721,11 +717,6 @@ export const uiPreferences = sqliteTable("ui_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
// termix-identity plugin (p_termix_identity_*).
// --- alerts begin ---
export const notificationChannels = sqliteTable("notification_channels", {
id: integer("id").primaryKey({ autoIncrement: true }),
@@ -742,12 +733,6 @@ export const notificationChannels = sqliteTable("notification_channels", {
});
// --- alerts end ---
// homepage_items and homepage_layouts moved to the homepage plugin
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
@@ -765,14 +750,11 @@ export const syncTombstones = sqliteTable("sync_tombstones", {
});
// --- sync end ---
// --- collab rooms ---
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
* Who may use or manage someone else's credential. "use" attaches it to
* hosts and connects, "manage" also edits and re-shares it.
*/
export const credentialAccess = sqliteTable(
"credential_access",
@@ -21,8 +21,6 @@ import { PluginSettingsRepository } from "./plugin-settings-repository.js";
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
import { UserAuthRepository } from "./user-auth-repository.js";
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
import { RbacAccessRepository } from "./rbac-access-repository.js";
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
import { RecentActivityRepository } from "./recent-activity-repository.js";
@@ -163,9 +161,6 @@ export function createCurrentDismissedAlertRepository(): DismissedAlertRepositor
);
}
// File manager recent/pinned/shortcuts moved to the file-manager plugin.
// Homepage items/layout and dashboard service links moved to the homepage plugin.
export function createCurrentHostFolderRepository(): HostFolderRepository {
return new HostFolderRepository(
createCurrentRepositoryContext(),
@@ -273,22 +268,6 @@ export function createCurrentRbacPermissionRepository(): RbacPermissionRepositor
);
}
export function createCurrentPluginRegistryRepository(): PluginRegistryRepository {
return new PluginRegistryRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("plugin_registry_repository_write"),
);
}
export function createCurrentPluginInstallCountRepository(): PluginInstallCountRepository {
return new PluginInstallCountRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryLazyWriteHook(
"plugin_install_count_repository_write",
),
);
}
export function createCurrentRbacAccessRepository(): RbacAccessRepository {
return new RbacAccessRepository(
createCurrentRepositoryContext(),
@@ -347,9 +326,6 @@ export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageR
);
}
// transfer_recent moved to the file-manager plugin.
// tmux_session_tags moved to the tmux-monitor plugin.
export function createCurrentTrustedDeviceRepository(): TrustedDeviceRepository {
return new TrustedDeviceRepository(
createCurrentRepositoryContext(),
@@ -19,8 +19,8 @@ export interface AppliedDefaultInput {
/**
* The two records that make plugin permissions outlive their plugin.
*
* `rbac_known_permissions` is why a role holding `ai.use` still saves while the
* ai plugin is disabled. `rbac_applied_defaults` is why a default an admin
* `rbac_known_permissions` is why a role holding a plugin permission
* (`<plugin>.use`) still saves while its plugin is disabled. `rbac_applied_defaults` is why a default an admin
* revoked is not handed back on the next boot. Neither has a foreign key to
* `plugins`, because both have to survive the plugin going away.
*/
@@ -9,13 +9,13 @@ export type UserExternalIdentityRecord =
export type UserSecondFactorRecord = typeof userSecondFactors.$inferSelect;
/** Subjects are indexed, so they stay within the portable key length. */
export const MAX_EXTERNAL_SUBJECT_LENGTH = 255;
const MAX_EXTERNAL_SUBJECT_LENGTH = 255;
/**
* Keeps a subject within the index length. Longer ones are hashed, which is
* stable, so the same provider answer always finds the same row.
*/
export function normalizeExternalSubject(subject: string): string {
function normalizeExternalSubject(subject: string): string {
if (subject.length <= MAX_EXTERNAL_SUBJECT_LENGTH) return subject;
return `sha256:${crypto.createHash("sha256").update(subject).digest("hex")}`;
}
@@ -1,57 +1,33 @@
/**
* The 2.8 login URLs that something outside Termix still depends on:
* identity providers configured with the old callback and back-channel
* logout URLs, and clients built against 2.8 such as Termix-Mobile. Each one
* forwards to the login method or plugin route that replaced it, so none of
* them can sign anyone in on its own.
* The 2.8 login URLs clients built against 2.8 (Termix-Mobile) still call.
* Each forwards to the login pipeline for the method it names, so none of
* them can sign anyone in on its own. Old identity provider callbacks are
* redirected by the owning plugin's manifest (contributes.http.legacyRedirects).
*/
import type { Request, Router } from "express";
import type { Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { getRequestBasePath } from "../../utils/request-origin.js";
import {
listLegacySsoProviders,
startRedirectLogin,
verifyFormLogin,
} from "./auth-routes.js";
/**
* A path on this install, keeping the query string. Relative, so the browser
* stays on the scheme and host it used, whatever a proxy told us.
*/
function pluginUrl(req: Request, path: string): string {
const query = req.originalUrl.indexOf("?");
return `${getRequestBasePath(req)}${path}${
query >= 0 ? req.originalUrl.slice(query) : ""
}`;
}
export function registerAuthCompatRoutes(router: Router): void {
/**
* @openapi
* /users/oidc-config:
* /users/{method}/authorize:
* get:
* summary: Get the default SSO provider's public configuration (2.8 route)
* description: Kept for 2.8 clients. Redirects to the sso plugin's /plugin-api/sso/config.
* tags:
* - Auth
* responses:
* 307:
* description: Redirect to the sso plugin.
*/
router.get("/oidc-config", (req, res) => {
res.redirect(307, pluginUrl(req, "/plugin-api/sso/config"));
});
/**
* @openapi
* /users/oidc/authorize:
* get:
* summary: Start an SSO login (2.8 route)
* description: Kept for 2.8 clients such as Termix-Mobile. Same as /users/auth/oidc/start, with the provider in providerId.
* summary: Start a redirect login (2.8 route)
* description: Kept for 2.8 clients such as Termix-Mobile, which call /users/oidc/authorize. Same as /users/auth/{method}/start, with the provider in providerId.
* tags:
* - Auth
* parameters:
* - in: path
* name: method
* required: true
* schema:
* type: string
* - in: query
* name: providerId
* schema:
@@ -64,59 +40,19 @@ export function registerAuthCompatRoutes(router: Router): void {
* 200:
* description: The authorization URL in auth_url.
* 404:
* description: SSO is not available.
* description: That login method is not available.
*/
router.get("/oidc/authorize", (req, res) =>
router.get("/:method/authorize", (req, res) =>
startRedirectLogin(
req,
res,
"oidc",
String(req.params.method),
typeof req.query.providerId === "string" && req.query.providerId
? req.query.providerId
: null,
),
);
/**
* @openapi
* /users/oidc/callback:
* get:
* summary: SSO callback (2.8 URL)
* description: The redirect URI identity providers were set up with before 2.9. Permanently redirects to /plugin-api/sso/callback with the same query, so existing provider configurations keep working.
* tags:
* - Auth
* responses:
* 308:
* description: Redirect to the sso plugin's callback.
* post:
* summary: SSO callback, form post (2.8 URL)
* description: Same as the GET form.
* tags:
* - Auth
* responses:
* 308:
* description: Redirect to the sso plugin's callback.
*/
router.all("/oidc/callback", (req, res) => {
res.redirect(308, pluginUrl(req, "/plugin-api/sso/callback"));
});
/**
* @openapi
* /users/oidc/backchannel-logout:
* post:
* summary: OIDC back-channel logout (2.8 URL)
* description: Permanently redirects to /plugin-api/sso/backchannel-logout, so identity providers set up before 2.9 keep working.
* tags:
* - Auth
* responses:
* 308:
* description: Redirect to the sso plugin.
*/
router.post("/oidc/backchannel-logout", (req, res) => {
res.redirect(308, pluginUrl(req, "/plugin-api/sso/backchannel-logout"));
});
/**
* @openapi
* /users/sso-providers:
@@ -140,25 +76,31 @@ export function registerAuthCompatRoutes(router: Router): void {
/**
* @openapi
* /users/ldap/login:
* /users/{method}/login:
* post:
* summary: LDAP login (2.8 route)
* description: Kept for 2.8 clients. Same as /users/auth/ldap/verify, with the provider in providerId.
* summary: Form login (2.8 route)
* description: Kept for 2.8 clients, which call /users/ldap/login. Same as /users/auth/{method}/verify, with the provider in providerId.
* tags:
* - Auth
* parameters:
* - in: path
* name: method
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Login successful, or a second factor is required.
* 401:
* description: Invalid credentials.
* 404:
* description: LDAP is not available.
* description: That login method is not available.
*/
router.post("/ldap/login", (req, res) =>
router.post("/:method/login", (req, res) =>
verifyFormLogin(
req,
res,
"ldap",
String(req.params.method),
req.body?.providerId != null ? String(req.body.providerId) : null,
),
);
@@ -130,7 +130,7 @@ router.get(
* properties:
* type:
* type: string
* enum: [terminal, file_manager, server_stats, tunnel, docker, telnet, vnc, rdp]
* description: An activity type a plugin's tab declares, lowercase letters, digits and underscores.
* hostId:
* type: integer
* hostName:
@@ -177,7 +177,7 @@ router.post(
if (result.status === "invalid_type") {
return res.status(400).json({
error:
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', 'docker', 'telnet', 'vnc', or 'rdp'",
"Invalid activity type. Use lowercase letters, digits and underscores.",
});
}
if (result.status === "rate_limited") {
@@ -48,9 +48,8 @@ export async function deleteUserAndRelatedData(
userId,
);
// session_recordings is retained rather than deleted, by design: it
// outlives the account. The session-recording plugin listens for
// user.deleted and anonymizes its own rows instead of cascading.
// Plugins drop or anonymize their own rows on user.deleted, or rely on
// their refUser() foreign keys cascading.
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
pluginEvents.emit(TOPICS.userDeleted, { userId });
@@ -68,16 +67,9 @@ export async function deleteUserAndRelatedData(
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
// file manager recent/pinned/shortcuts and transfer_recent cascade on the
// user's refUser() foreign key, as the file-manager plugin's adopted
// tables.
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
// snippets, snippet_folders and snippet_access cascade on the user's
// refUser() foreign key, as the snippets plugin's adopted tables.
await createCurrentHostFolderRepository().deleteByUserId(userId);
await createCurrentHostSidebarPreferenceRepository().deleteByUserId(userId);
@@ -88,12 +80,7 @@ export async function deleteUserAndRelatedData(
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
// homepage_items, homepage_layouts, dashboard_service_links and
// secret_sources cascade on the user's refUser() foreign key, as the
// homepage and secret-sources plugins' adopted tables. The secret
// source's token in ctx.secrets is cleaned up generically below, with
// every other plugin_settings row for this user. The opkssh and vault
// plugins' tables cascade the same way, and so do termix-identity's.
// Plugin tables with a refUser() column cascade on the user row.
await createCurrentOpenTabRepository().deleteByUserId(userId);
await createCurrentUserPreferenceRepository().deleteByUserId(userId);
@@ -1,101 +0,0 @@
/**
* The 2.8 auth callback URLs something outside Termix still depends on: the
* OPKSSH and Step CA redirect URIs registered with identity providers, and
* the Vault one listed in Vault OIDC roles.
* Each forwards to the plugin route that replaced it, so with the plugin off
* it signs nobody in.
*/
import type { Request, Router } from "express";
import { getRequestBasePath } from "../../utils/request-origin.js";
/** A path on this install, keeping the query string. */
function localUrl(req: Request, path: string): string {
const query = req.originalUrl.indexOf("?");
return `${getRequestBasePath(req)}${path}${
query >= 0 ? req.originalUrl.slice(query) : ""
}`;
}
export function registerHostAuthCompatRoutes(router: Router): void {
/**
* @openapi
* /host/opkssh-callback:
* get:
* summary: OPKSSH callback (2.8 URL)
* description: The redirect URI OPKSSH identity providers were set up with before 2.9. Redirects to /plugin-api/opkssh/callback with the same subpath and query, so existing provider configurations keep working.
* tags:
* - Auth
* responses:
* 307:
* description: Redirect to the opkssh plugin's callback.
*/
router.all(["/opkssh-callback", "/opkssh-callback/*rest"], (req, res) => {
const rest = req.path.slice("/opkssh-callback".length);
res.redirect(307, localUrl(req, `/plugin-api/opkssh/callback${rest}`));
});
/**
* @openapi
* /host/step-ca-callback:
* get:
* summary: Step CA callback (2.8 URL)
* description: The redirect URI Step CA identity providers were set up with before 2.9. Redirects to /plugin-api/step-ca/callback with the same query, so existing provider configurations keep working.
* tags:
* - Auth
* responses:
* 307:
* description: Redirect to the step-ca plugin's callback.
*/
router.get("/step-ca-callback", (req, res) => {
res.redirect(307, localUrl(req, "/plugin-api/step-ca/callback"));
});
}
export function registerVaultCompatRoutes(router: Router): void {
/**
* @openapi
* /vault/oidc/callback:
* get:
* summary: Vault OIDC callback (2.8 URL)
* description: The redirect URI Vault OIDC roles allowed before 2.9. Redirects to /plugin-api/vault/oidc/callback with the same query, so existing Vault roles keep working.
* tags:
* - Vault
* responses:
* 307:
* description: Redirect to the vault plugin's callback.
*/
router.get("/oidc/callback", (req, res) => {
res.redirect(307, localUrl(req, "/plugin-api/vault/oidc/callback"));
});
}
/**
* The 2.8 Termix ID resolver URLs, which servers fetch from provisioning
* scripts. Permanently redirected to the termix-identity plugin's public
* resolver; curl -L follows them.
*/
export function registerTermixIdCompatRoutes(router: Router): void {
/**
* @openapi
* /termix-id/u/{handle}:
* get:
* summary: Termix ID resolver (2.8 URL)
* description: Permanently redirects to /plugin-api/termix-identity/u/{handle}, and likewise for /termix-id/u/{handle}/{algo} and /termix-id/u/{handle}/ca, so servers provisioned with the old URL keep working.
* tags:
* - Termix ID
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* responses:
* 308:
* description: Redirect to the termix-identity plugin's resolver.
*/
router.get(["/u/:handle", "/u/:handle/:algo"], (req, res) => {
const rest = req.path.slice("/u".length);
res.redirect(308, localUrl(req, `/plugin-api/termix-identity/u${rest}`));
});
}
@@ -396,10 +396,7 @@ export function registerHostFolderRoutes(
const hostIds = hostsToDelete.map((host) => host.id);
if (hostIds.length > 0) {
// file manager recent/pinned/shortcuts, transfer_recent, command
// history and session recordings cascade on the host's refHost()
// foreign key, as the file-manager, ssh-terminal and
// session-recording plugins' adopted tables.
// Plugin rows tied to a host cascade on their refHost() foreign keys.
await createCurrentSshCredentialUsageRepository().deleteByHostIds(
hostIds,
@@ -80,7 +80,7 @@ export function containsOwnerPrivateAuthUpdate(
);
}
export const FOLDER_PATH_SEPARATOR = " / ";
const FOLDER_PATH_SEPARATOR = " / ";
/**
* Re-paths a folder string when its ancestor folder is renamed. Returns the new
@@ -132,7 +132,7 @@ const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
* Drops the fields a shared host's recipient may not read: a field declares
* the lowest share level that sees it with shareRead. The owner sees all.
*/
export function visibleHostPluginSettings(
function visibleHostPluginSettings(
values: HostPluginSettings,
host: Record<string, unknown>,
): HostPluginSettings {
+13 -7
View File
@@ -1,4 +1,6 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { isQuickConnectAuthType } from "../../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
import { applyFolderAccessRules } from "../../utils/folder-access-inheritance.js";
import { findUsableCredential } from "../../hosts/usable-credential.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
@@ -44,7 +46,6 @@ import {
withHostPluginSettings,
} from "./host-plugin-settings.js";
import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostAuthCompatRoutes } from "./host-compat-routes.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostNetworkRoutes } from "./host-network-routes.js";
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
@@ -74,9 +75,9 @@ const upload = multer({ storage: multer.memoryStorage() });
/**
* Tells whoever is polling this host that its details changed.
*
* An event rather than the HTTP POST to localhost:30005 this used to be: host
* metrics is a plugin now and has no port of its own. Fire and forget, so a
* subscriber that throws cannot fail the host update that caused it.
* An event, so whichever plugin polls the host can listen for it. Fire and
* forget, so a subscriber that throws cannot fail the host update that
* caused it.
*/
function notifyStatsHostUpdated(
hostId: number,
@@ -556,7 +557,7 @@ router.post(
* description: SSH username
* authType:
* type: string
* enum: [password, key, credential]
* description: Any registered SSH auth type that works for an unsaved host (password, key, credential, agent, none, or one a plugin marks for Quick Connect).
* description: Authentication method
* password:
* type: string
@@ -618,6 +619,13 @@ router.post(
return res.status(400).json({ error: "Missing required fields" });
}
ensureCoreSshAuthProviders();
if (!isQuickConnectAuthType(String(authType))) {
return res
.status(400)
.json({ error: "That auth type cannot be used for Quick Connect" });
}
try {
let resolvedPassword = password;
let resolvedKey = key;
@@ -2436,8 +2444,6 @@ registerHostBulkRoutes(
requireDataAccess,
);
registerHostAuthCompatRoutes(router);
registerHostNetworkRoutes(router, {
authenticateJWT,
requireViewPermission: permissionManager.requirePermission("hosts.view"),
+1 -13
View File
@@ -28,16 +28,6 @@ function getTabTtlMs(): number {
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
}
// Legacy tab types that were renamed. Normalize on read so previously saved
// tabs still restore to the correct (renamed) tab type.
const LEGACY_TAB_TYPE_MAP: Record<string, string> = {
stats: "host-metrics",
};
function normalizeTabType(tabType: string): string {
return LEGACY_TAB_TYPE_MAP[tabType] ?? tabType;
}
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
@@ -46,9 +36,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
userId,
cutoff,
);
return res.json(
tabs.map((tab) => ({ ...tab, tabType: normalizeTabType(tab.tabType) })),
);
return res.json(tabs);
} catch (e) {
databaseLogger.error("Failed to get open tabs", e, {
operation: "get_open_tabs",
@@ -17,6 +17,8 @@
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { getPluginRouter } from "../../plugins/http.js";
import { getRequestBasePath } from "../../utils/request-origin.js";
import type { PluginLegacyRedirect } from "@termix/plugin-sdk/manifest";
const router = express.Router();
@@ -78,21 +80,44 @@ export function mountPluginApi(app: express.Express): void {
app.use("/plugin-api", router);
}
export interface ActiveLegacyRoutes {
id: string;
legacyPaths: string[];
legacyRedirects: PluginLegacyRedirect[];
}
function under(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}/`);
}
/**
* Serves the old URLs plugins declare in contributes.http.legacyPaths, each
* under "/<plugin id>/", through that plugin's router with the prefix
* removed. Mounted after every core route, so core wins a clash.
* Serves the old URLs plugins declare in contributes.http. A legacyPaths
* entry, under "/<plugin id>/", runs through that plugin's router with the
* prefix removed. A legacyRedirects entry redirects to the plugin's own URL,
* keeping the rest of the path and the query. Mounted after every core route,
* so core wins a clash.
*/
export function mountPluginLegacyPaths(
app: express.Express,
listActive: () => Array<{ id: string; legacyPaths: string[] }>,
listActive: () => ActiveLegacyRoutes[],
): void {
app.use((req: Request, res: Response, next) => {
for (const plugin of listActive()) {
const matched = plugin.legacyPaths.some(
(path) => req.path === path || req.path.startsWith(`${path}/`),
const redirect = plugin.legacyRedirects.find((entry) =>
under(req.path, entry.from),
);
if (!matched) continue;
if (redirect) {
const rest = req.path.slice(redirect.from.length);
const query = req.originalUrl.indexOf("?");
res.redirect(
redirect.status ?? 307,
`${getRequestBasePath(req)}/plugin-api/${plugin.id}${redirect.to}${rest}${
query >= 0 ? req.originalUrl.slice(query) : ""
}`,
);
return;
}
if (!plugin.legacyPaths.some((path) => under(req.path, path))) continue;
const pluginRouter = getPluginRouter(plugin.id);
if (!pluginRouter) {
res.status(404).json({ error: "Plugin not installed or not running" });
@@ -18,12 +18,14 @@ export interface SshAuthProviderSummary {
credentialType: boolean;
needsUserInteraction: boolean;
supportsBackground: boolean;
/** Offered in Quick Connect, for a host that is never saved. */
quickConnect: boolean;
available: boolean;
/** Set when the type belongs to a plugin that is disabled or missing. */
missingPlugin?: { id: string; name: string };
}
export function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
ensureCoreSshAuthProviders();
const summaries: SshAuthProviderSummary[] = listSshAuthProviders().map(
(provider) => ({
@@ -35,6 +37,7 @@ export function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
credentialType: !!provider.credentialType,
needsUserInteraction: !!provider.needsUserInteraction,
supportsBackground: provider.supportsBackground !== false,
quickConnect: !!provider.quickConnect,
available: true,
}),
);
@@ -50,6 +53,7 @@ export function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
credentialType: false,
needsUserInteraction: false,
supportsBackground: false,
quickConnect: false,
available: false,
missingPlugin: { id: owner.pluginId, name: owner.pluginName },
});
+1 -1
View File
@@ -17,7 +17,7 @@ import { sql, type SQLWrapper } from "drizzle-orm";
* `substr` are used rather than `datetime()` to keep the expression portable
* across engines.
*/
export const CANONICAL_TIMESTAMP_LENGTH = 19;
const CANONICAL_TIMESTAMP_LENGTH = 19;
export function normalizeSyncTimestamp(value: string): string {
return value.replace("T", " ").slice(0, CANONICAL_TIMESTAMP_LENGTH);