chore: lock the plugin boundary and remove 2.9.0 leftovers

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

No files matched your search

@@ -1,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 },
});