mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move opkssh and warpgate into plugins (C3)
This commit is contained in:
1 parent
925da4a661
commit
98d367d7e2
336 files changed
+22380
-4746
No files matched your search
@@ -10,7 +10,6 @@ import {
|
||||
createCurrentHostSidebarPreferenceRepository,
|
||||
createCurrentCredentialSidebarPreferenceRepository,
|
||||
createCurrentUiPreferenceRepository,
|
||||
createCurrentOpksshTokenRepository,
|
||||
createCurrentOpenTabRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
@@ -97,9 +96,9 @@ export async function deleteUserAndRelatedData(
|
||||
// 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.
|
||||
// every other plugin_settings row for this user. The opkssh plugin's
|
||||
// cached certificates cascade the same way.
|
||||
|
||||
await createCurrentOpksshTokenRepository().deleteByUserId(userId);
|
||||
await createCurrentVaultTokenRepository().deleteByUserId(userId);
|
||||
await createCurrentVaultProfileRepository().deleteByUserId(userId);
|
||||
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* The 2.8 URLs under /host that something outside Termix still depends on:
|
||||
* the OPKSSH redirect URI registered with identity providers. It 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}`));
|
||||
});
|
||||
}
|
||||
@@ -462,7 +462,6 @@ export function transformHostResponse(
|
||||
? JSON.parse(host.terminalConfig as string)
|
||||
: undefined,
|
||||
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
||||
useWarpgate: !!host.useWarpgate,
|
||||
socks5ProxyChain: host.socks5ProxyChain
|
||||
? JSON.parse(host.socks5ProxyChain as string)
|
||||
: [],
|
||||
|
||||
@@ -1,841 +0,0 @@
|
||||
import type { Router, Request, Response } from "express";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
normalizeSelectOpParam,
|
||||
renderOpksshErrorPage,
|
||||
rewriteOPKSSHHtml,
|
||||
} from "./opkssh-html.js";
|
||||
|
||||
export function registerHostOpksshRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/opkssh-chooser/{requestId}:
|
||||
* get:
|
||||
* summary: Proxy OPKSSH provider chooser page and all related resources
|
||||
* tags: [SSH]
|
||||
* parameters:
|
||||
* - name: requestId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Authentication request ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Chooser page content
|
||||
* 404:
|
||||
* description: Session not found
|
||||
* 500:
|
||||
* description: Proxy error
|
||||
*/
|
||||
|
||||
router.use(
|
||||
"/opkssh-chooser/:requestId",
|
||||
async (req: Request, res: Response) => {
|
||||
const requestId = Array.isArray(req.params.requestId)
|
||||
? req.params.requestId[0]
|
||||
: req.params.requestId;
|
||||
|
||||
const fullPath = req.originalUrl || req.url;
|
||||
const pathAfterRequestIdTemp =
|
||||
fullPath.split(`/host/opkssh-chooser/${requestId}`)[1] || "";
|
||||
|
||||
sshLogger.info("OPKSSH chooser proxy request", {
|
||||
operation: "opkssh_chooser_proxy_request",
|
||||
requestId,
|
||||
url: req.url,
|
||||
originalUrl: req.originalUrl,
|
||||
fullPath,
|
||||
pathAfterRequestId: pathAfterRequestIdTemp,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
try {
|
||||
const { getActiveAuthSession, registerOAuthState } =
|
||||
await import("../../hosts/opkssh-auth.js");
|
||||
const session = getActiveAuthSession(requestId);
|
||||
|
||||
if (!session) {
|
||||
sshLogger.error("Session not found for chooser request", {
|
||||
operation: "opkssh_chooser_session_not_found",
|
||||
requestId,
|
||||
});
|
||||
res.status(404).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Session Not Found",
|
||||
heading: "Session Not Found",
|
||||
message: "This authentication session has expired or is invalid.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const axios = (await import("axios")).default;
|
||||
|
||||
const fullPath = req.originalUrl || req.url;
|
||||
const pathAfterRequestId =
|
||||
fullPath.split(`/host/opkssh-chooser/${requestId}`)[1] || "";
|
||||
const targetPath = pathAfterRequestId || "/chooser";
|
||||
|
||||
if (!session.localPort || session.localPort === 0) {
|
||||
sshLogger.error("OPKSSH session has no local port", {
|
||||
operation: "opkssh_chooser_proxy",
|
||||
requestId,
|
||||
sessionStatus: session.status,
|
||||
});
|
||||
res.status(500).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Error",
|
||||
heading: "Authentication Error",
|
||||
message:
|
||||
"Failed to load authentication page. OPKSSH process may not be ready yet. Please try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// /select on OPKSSH's chooser redirects (possibly via multiple local hops) to the
|
||||
// external OAuth provider URL. The hops we may see:
|
||||
// 1. /select -> /select/ (Go ServeMux canonicalization, same chooser port)
|
||||
// 2. /select/?op=ALIAS -> http://localhost:CALLBACK_PORT/login (OPKSSH's separate callback listener)
|
||||
// 3. /login on the callback listener -> https://<provider>/authorize?... (external OAuth URL)
|
||||
if (targetPath.startsWith("/select")) {
|
||||
const selectaxios = (await import("axios")).default;
|
||||
const rawQs = targetPath.includes("?")
|
||||
? targetPath.slice(targetPath.indexOf("?"))
|
||||
: "";
|
||||
|
||||
let qs = rawQs;
|
||||
let opMappedFrom: string | undefined;
|
||||
if (rawQs) {
|
||||
try {
|
||||
const params = new URLSearchParams(rawQs.replace(/^\?/, ""));
|
||||
const rawOp = params.get("op");
|
||||
if (rawOp) {
|
||||
const mappedOp = normalizeSelectOpParam(
|
||||
rawOp,
|
||||
session.providers || [],
|
||||
);
|
||||
if (mappedOp !== rawOp) {
|
||||
params.set("op", mappedOp);
|
||||
qs = `?${params.toString()}`;
|
||||
opMappedFrom = rawOp;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* keep rawQs if parsing fails */
|
||||
}
|
||||
}
|
||||
|
||||
const chooserHost = `127.0.0.1:${session.localPort}`;
|
||||
const startUrl = `http://${chooserHost}/select/${qs}`;
|
||||
|
||||
sshLogger.info("Proxying OPKSSH /select", {
|
||||
operation: "opkssh_select_proxy",
|
||||
requestId,
|
||||
targetUrl: startUrl,
|
||||
opMappedFrom,
|
||||
});
|
||||
|
||||
const isLocalHostname = (host: string): boolean => {
|
||||
const bare = host.split(":")[0];
|
||||
return (
|
||||
bare === "127.0.0.1" || bare === "localhost" || bare === "[::1]"
|
||||
);
|
||||
};
|
||||
|
||||
interface UpstreamResponse {
|
||||
status: number;
|
||||
location?: string;
|
||||
contentType: string;
|
||||
body: string;
|
||||
targetUrl: string;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
const fetchUpstream = async (
|
||||
url: string,
|
||||
): Promise<UpstreamResponse> => {
|
||||
const started = Date.now();
|
||||
let hostHeader = chooserHost;
|
||||
try {
|
||||
hostHeader = new URL(url).host;
|
||||
} catch {
|
||||
/* fall back to chooser host */
|
||||
}
|
||||
const r = await selectaxios({
|
||||
method: "GET",
|
||||
url,
|
||||
maxRedirects: 0,
|
||||
validateStatus: () => true,
|
||||
timeout: 10000,
|
||||
responseType: "text",
|
||||
transformResponse: (v) => v,
|
||||
headers: { host: hostHeader },
|
||||
});
|
||||
const locHeader = r.headers["location"];
|
||||
const location = Array.isArray(locHeader)
|
||||
? locHeader[0]
|
||||
: locHeader;
|
||||
const ctHeader = r.headers["content-type"];
|
||||
const ctRaw = Array.isArray(ctHeader) ? ctHeader[0] : ctHeader;
|
||||
const contentType = typeof ctRaw === "string" ? ctRaw : "";
|
||||
const body =
|
||||
typeof r.data === "string" ? r.data : String(r.data ?? "");
|
||||
return {
|
||||
status: r.status,
|
||||
location: typeof location === "string" ? location : undefined,
|
||||
contentType,
|
||||
body,
|
||||
targetUrl: url,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
};
|
||||
|
||||
const logResponse = (response: UpstreamResponse): void => {
|
||||
sshLogger.info("OPKSSH /select upstream response", {
|
||||
operation: "opkssh_select_upstream_response",
|
||||
requestId,
|
||||
targetUrl: response.targetUrl,
|
||||
status: response.status,
|
||||
location: response.location,
|
||||
contentType: response.contentType,
|
||||
elapsedMs: response.elapsedMs,
|
||||
bodyPreview: response.body.slice(0, 256),
|
||||
});
|
||||
};
|
||||
|
||||
const MAX_HOPS = 4;
|
||||
|
||||
try {
|
||||
let response = await fetchUpstream(startUrl);
|
||||
logResponse(response);
|
||||
|
||||
for (let hop = 0; hop < MAX_HOPS; hop++) {
|
||||
if (
|
||||
response.status < 300 ||
|
||||
response.status >= 400 ||
|
||||
!response.location
|
||||
) {
|
||||
break;
|
||||
}
|
||||
const loc = response.location;
|
||||
|
||||
// Relative path: resolve against the current upstream.
|
||||
if (loc.startsWith("/")) {
|
||||
let currentHost = chooserHost;
|
||||
try {
|
||||
currentHost = new URL(response.targetUrl).host;
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
response = await fetchUpstream(`http://${currentHost}${loc}`);
|
||||
logResponse(response);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Absolute URL: if it points to a localhost OPKSSH endpoint, capture
|
||||
// the port. Then redirect the BROWSER to the proxied path so that
|
||||
// Set-Cookie headers from OPKSSH's /login handler reach the browser
|
||||
// directly — following them server-side would swallow the cookie.
|
||||
if (/^https?:\/\//i.test(loc)) {
|
||||
try {
|
||||
const parsed = new URL(loc);
|
||||
if (isLocalHostname(parsed.host)) {
|
||||
// Capture callback listener port if not yet known.
|
||||
if (!session.callbackPort) {
|
||||
const port = parseInt(parsed.port, 10);
|
||||
if (!Number.isNaN(port)) {
|
||||
session.callbackPort = port;
|
||||
sshLogger.info(
|
||||
"Captured OPKSSH callback listener port from /select redirect",
|
||||
{
|
||||
operation: "opkssh_select_callback_port_detected",
|
||||
requestId,
|
||||
callbackPort: port,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// Redirect browser through the chooser proxy so it can receive
|
||||
// the state cookie that OPKSSH sets on /login.
|
||||
const browserPath = `/host/opkssh-chooser/${requestId}${parsed.pathname}${parsed.search}`;
|
||||
sshLogger.info(
|
||||
"Redirecting browser to OPKSSH callback listener via proxy",
|
||||
{
|
||||
operation: "opkssh_select_browser_redirect_to_login",
|
||||
requestId,
|
||||
browserPath,
|
||||
callbackPort: session.callbackPort,
|
||||
},
|
||||
);
|
||||
res.redirect(302, browserPath);
|
||||
return;
|
||||
}
|
||||
// External OAuth provider URL — done, handled below.
|
||||
break;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
const isExternalRedirect =
|
||||
response.status >= 300 &&
|
||||
response.status < 400 &&
|
||||
!!response.location &&
|
||||
/^https?:\/\//i.test(response.location) &&
|
||||
(() => {
|
||||
try {
|
||||
return !isLocalHostname(
|
||||
new URL(response.location as string).host,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (isExternalRedirect) {
|
||||
const oauthUrl = response.location as string;
|
||||
try {
|
||||
const parsed = new URL(oauthUrl);
|
||||
const oauthState = parsed.searchParams.get("state");
|
||||
if (oauthState) registerOAuthState(oauthState, requestId);
|
||||
} catch {
|
||||
/* already validated above */
|
||||
}
|
||||
sshLogger.info(
|
||||
"OPKSSH /select redirecting browser to OAuth provider",
|
||||
{
|
||||
operation: "opkssh_select_redirect",
|
||||
requestId,
|
||||
oauthUrl,
|
||||
},
|
||||
);
|
||||
res.redirect(302, oauthUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const bodyPreview = response.body.slice(0, 512);
|
||||
const detailLines = [
|
||||
`Upstream: ${response.targetUrl}`,
|
||||
`Status: ${response.status}`,
|
||||
response.location ? `Location: ${response.location}` : undefined,
|
||||
`Content-Type: ${response.contentType || "(none)"}`,
|
||||
`Elapsed: ${response.elapsedMs}ms`,
|
||||
"",
|
||||
bodyPreview
|
||||
? `Body (first 512 chars):\n${bodyPreview}`
|
||||
: "Body: (empty)",
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
sshLogger.error(
|
||||
"OPKSSH /select did not produce an OAuth redirect",
|
||||
{
|
||||
operation: "opkssh_select_no_oauth_redirect",
|
||||
requestId,
|
||||
status: response.status,
|
||||
location: response.location,
|
||||
contentType: response.contentType,
|
||||
bodyPreview,
|
||||
},
|
||||
);
|
||||
|
||||
res.status(502).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "OPKSSH error",
|
||||
heading: "Failed to get OAuth redirect",
|
||||
message:
|
||||
"OPKSSH did not return an external OAuth provider URL. " +
|
||||
"This typically indicates a configuration mismatch between the provider's redirect_uris " +
|
||||
"and the Termix callback path. Check the server log for the OPKSSH response body.",
|
||||
details: detailLines.join("\n"),
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.error("Error proxying OPKSSH /select", err, {
|
||||
operation: "opkssh_select_proxy_error",
|
||||
requestId,
|
||||
targetUrl: startUrl,
|
||||
});
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
res.status(502).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "OPKSSH error",
|
||||
heading: "Failed to reach OPKSSH service",
|
||||
message:
|
||||
"Termix could not connect to the local OPKSSH authentication service. " +
|
||||
"The OPKSSH process may have exited or is not listening yet.",
|
||||
details: `Upstream: ${startUrl}\nError: ${errMsg}`,
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Paths served by the callback listener, not the chooser.
|
||||
// The browser is redirected here so it receives Set-Cookie from OPKSSH.
|
||||
const isCallbackListenerPath =
|
||||
targetPath === "/login" ||
|
||||
targetPath.startsWith("/login?") ||
|
||||
targetPath === "/login-callback" ||
|
||||
targetPath.startsWith("/login-callback?");
|
||||
|
||||
const upstreamPort =
|
||||
isCallbackListenerPath && session.callbackPort
|
||||
? session.callbackPort
|
||||
: session.localPort;
|
||||
|
||||
const targetUrl = `http://127.0.0.1:${upstreamPort}${targetPath}`;
|
||||
|
||||
sshLogger.info("Proxying to OPKSSH chooser", {
|
||||
operation: "opkssh_chooser_proxy_request_to_opkssh",
|
||||
requestId,
|
||||
targetUrl,
|
||||
upstreamPort,
|
||||
targetPath,
|
||||
});
|
||||
|
||||
const response = await axios({
|
||||
method: req.method,
|
||||
url: targetUrl,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: `127.0.0.1:${upstreamPort}`,
|
||||
},
|
||||
data: req.body,
|
||||
timeout: 10000,
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 0,
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
|
||||
sshLogger.info("OPKSSH chooser response received", {
|
||||
operation: "opkssh_chooser_proxy_response",
|
||||
requestId,
|
||||
statusCode: response.status,
|
||||
contentType: response.headers["content-type"],
|
||||
contentLength: response.headers["content-length"],
|
||||
hasLocation: !!response.headers.location,
|
||||
});
|
||||
|
||||
Object.entries(response.headers).forEach(([key, value]) => {
|
||||
if (key.toLowerCase() === "transfer-encoding") {
|
||||
return;
|
||||
}
|
||||
if (key.toLowerCase() === "location") {
|
||||
const location = value as string;
|
||||
if (location.startsWith("/")) {
|
||||
res.setHeader(
|
||||
key,
|
||||
`/host/opkssh-chooser/${requestId}${location}`,
|
||||
);
|
||||
} else {
|
||||
const localhostMatch = location.match(
|
||||
/^http:\/\/(?:localhost|127\.0\.0\.1):(\d+)(\/.*)?$/,
|
||||
);
|
||||
if (localhostMatch) {
|
||||
const port = parseInt(localhostMatch[1], 10);
|
||||
const path = localhostMatch[2] || "/";
|
||||
if (session.callbackPort && port === session.callbackPort) {
|
||||
res.setHeader(
|
||||
key,
|
||||
`/host/opkssh-callback/${requestId}${path}`,
|
||||
);
|
||||
} else if (port === session.localPort) {
|
||||
res.setHeader(
|
||||
key,
|
||||
`/host/opkssh-chooser/${requestId}${path}`,
|
||||
);
|
||||
} else {
|
||||
const isCallback =
|
||||
path.includes("login") || path.includes("callback");
|
||||
const prefix = isCallback
|
||||
? "opkssh-callback"
|
||||
: "opkssh-chooser";
|
||||
res.setHeader(key, `/host/${prefix}/${requestId}${path}`);
|
||||
}
|
||||
} else {
|
||||
// External redirect (e.g. to OIDC provider) — capture OAuth state for session binding
|
||||
try {
|
||||
const redirectUrl = new URL(location);
|
||||
const oauthState = redirectUrl.searchParams.get("state");
|
||||
if (oauthState) {
|
||||
registerOAuthState(oauthState, requestId);
|
||||
}
|
||||
} catch {
|
||||
// Not a valid URL, skip state capture
|
||||
}
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
}
|
||||
} else if (key.toLowerCase() === "set-cookie") {
|
||||
// Rewrite cookies from OPKSSH's internal listener so they are scoped
|
||||
// to the Termix proxy path instead of OPKSSH's internal path.
|
||||
// The state cookie set by /login must survive to /login-callback.
|
||||
const cookies = Array.isArray(value) ? value : [value as string];
|
||||
const rewritten = cookies.map((cookie) => {
|
||||
return cookie
|
||||
.replace(/;\s*domain=[^;]*/gi, "")
|
||||
.replace(/;\s*path=[^;]*/gi, "; Path=/host/opkssh-callback/")
|
||||
.concat(
|
||||
cookie.match(/;\s*path=/i)
|
||||
? ""
|
||||
: "; Path=/host/opkssh-callback/",
|
||||
);
|
||||
});
|
||||
res.setHeader(key, rewritten);
|
||||
} else {
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
});
|
||||
|
||||
// Set a cookie to correlate this browser with the requestId.
|
||||
// OAuth state capture from Location headers only works for 3xx redirects;
|
||||
// if OPKSSH redirects via JavaScript, the state is never registered.
|
||||
// This cookie survives the OIDC round-trip and identifies the session on callback.
|
||||
res.cookie("opkssh_request_id", requestId, {
|
||||
path: "/host/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const contentType = String(response.headers["content-type"] || "");
|
||||
if (contentType.includes("text/html")) {
|
||||
const html = rewriteOPKSSHHtml(
|
||||
response.data.toString("utf-8"),
|
||||
requestId,
|
||||
"opkssh-chooser",
|
||||
);
|
||||
res.status(response.status).send(html);
|
||||
} else {
|
||||
res.status(response.status).send(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("Error proxying OPKSSH chooser", error, {
|
||||
operation: "opkssh_chooser_proxy_error",
|
||||
requestId,
|
||||
});
|
||||
res.status(500).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Error",
|
||||
heading: "Error",
|
||||
message: "Failed to load authentication page. Please try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/opkssh-callback:
|
||||
* get:
|
||||
* summary: Static OAuth callback from OIDC provider for OPKSSH authentication
|
||||
* tags: [SSH]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Callback processed successfully
|
||||
* 404:
|
||||
* description: No active authentication session found
|
||||
* 500:
|
||||
* description: Authentication failed
|
||||
*/
|
||||
router.get("/opkssh-callback", async (req: Request, res: Response) => {
|
||||
try {
|
||||
sshLogger.info("OAuth callback received", {
|
||||
operation: "opkssh_static_callback_received",
|
||||
host: req.headers.host,
|
||||
});
|
||||
|
||||
const {
|
||||
getUserIdFromRequest,
|
||||
getActiveSessionsForUser,
|
||||
getActiveAuthSession,
|
||||
getRequestIdByOAuthState,
|
||||
clearOAuthState,
|
||||
} = await import("../../hosts/opkssh-auth.js");
|
||||
|
||||
const userId = await getUserIdFromRequest({
|
||||
cookies: req.cookies,
|
||||
headers: req.headers as Record<string, string | undefined>,
|
||||
});
|
||||
|
||||
sshLogger.info("User ID resolved", {
|
||||
operation: "opkssh_callback_user_lookup",
|
||||
userId: userId || "null",
|
||||
hasCookies: !!req.cookies?.jwt,
|
||||
cookieKeys: Object.keys(req.cookies || {}),
|
||||
});
|
||||
|
||||
let userSessions: Awaited<ReturnType<typeof getActiveSessionsForUser>> =
|
||||
[];
|
||||
|
||||
if (userId) {
|
||||
userSessions = getActiveSessionsForUser(userId);
|
||||
} else {
|
||||
// No JWT cookie (e.g. OAuth redirect landed in external browser).
|
||||
// Try to find the correct session via the OAuth state parameter.
|
||||
const oauthState = req.query.state as string | undefined;
|
||||
|
||||
if (oauthState) {
|
||||
const mappedRequestId = getRequestIdByOAuthState(oauthState);
|
||||
if (mappedRequestId) {
|
||||
const mappedSession = getActiveAuthSession(mappedRequestId);
|
||||
if (mappedSession) {
|
||||
userSessions = [mappedSession];
|
||||
clearOAuthState(oauthState);
|
||||
sshLogger.info("Resolved session via OAuth state parameter", {
|
||||
operation: "opkssh_callback_state_lookup",
|
||||
requestId: mappedRequestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use the opkssh_request_id cookie set by the chooser proxy.
|
||||
// State capture only works for 3xx redirects; if OPKSSH redirects via
|
||||
// JavaScript in the HTML, the state is never registered in the map.
|
||||
if (userSessions.length === 0) {
|
||||
const cookieRequestId = req.cookies?.opkssh_request_id;
|
||||
if (cookieRequestId) {
|
||||
const cookieSession = getActiveAuthSession(cookieRequestId);
|
||||
if (cookieSession) {
|
||||
userSessions = [cookieSession];
|
||||
res.clearCookie("opkssh_request_id", { path: "/host/" });
|
||||
sshLogger.info("Resolved session via opkssh_request_id cookie", {
|
||||
operation: "opkssh_callback_cookie_lookup",
|
||||
requestId: cookieRequestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userSessions.length === 0) {
|
||||
sshLogger.warn(
|
||||
"OAuth callback with no JWT, no matching state, and no session cookie",
|
||||
{
|
||||
operation: "opkssh_callback_no_session_match",
|
||||
hasState: !!oauthState,
|
||||
hasCookie: !!req.cookies?.opkssh_request_id,
|
||||
},
|
||||
);
|
||||
res
|
||||
.status(401)
|
||||
.send("Authentication callback failed: unable to identify session");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sshLogger.info("Active sessions for user", {
|
||||
operation: "opkssh_callback_session_lookup",
|
||||
userId,
|
||||
sessionCount: userSessions.length,
|
||||
sessions: userSessions.map((s) => ({
|
||||
requestId: s.requestId,
|
||||
status: s.status,
|
||||
hasCallbackPort: !!s.callbackPort,
|
||||
callbackPort: s.callbackPort,
|
||||
hasLocalPort: !!s.localPort,
|
||||
localPort: s.localPort,
|
||||
})),
|
||||
});
|
||||
|
||||
if (userSessions.length === 0) {
|
||||
sshLogger.error("No active sessions for callback", {
|
||||
operation: "opkssh_callback_no_sessions",
|
||||
userId,
|
||||
});
|
||||
res.status(404).send("No active authentication session found");
|
||||
return;
|
||||
}
|
||||
|
||||
const session = userSessions[userSessions.length - 1];
|
||||
|
||||
if (!session.callbackPort) {
|
||||
sshLogger.error("Session callback port not ready", {
|
||||
operation: "opkssh_callback_port_not_ready",
|
||||
userId,
|
||||
requestId: session.requestId,
|
||||
sessionStatus: session.status,
|
||||
hasLocalPort: !!session.localPort,
|
||||
});
|
||||
res.status(503).send("OPKSSH callback listener not ready yet");
|
||||
return;
|
||||
}
|
||||
|
||||
const queryString = req.url.includes("?")
|
||||
? req.url.substring(req.url.indexOf("?"))
|
||||
: "";
|
||||
// OPKSSH's internal callback listener handles `/login-callback` regardless of the
|
||||
// path used in --remote-redirect-uri. The dynamic route below defaults to that path.
|
||||
const redirectUrl = `/host/opkssh-callback/${session.requestId}${queryString}`;
|
||||
|
||||
sshLogger.info("Redirecting OAuth callback to dynamic route", {
|
||||
operation: "opkssh_static_callback_redirect",
|
||||
userId,
|
||||
requestId: session.requestId,
|
||||
callbackPort: session.callbackPort,
|
||||
queryParams: Object.keys(req.query),
|
||||
redirectUrl,
|
||||
});
|
||||
|
||||
res.redirect(302, redirectUrl);
|
||||
} catch (error) {
|
||||
sshLogger.error("Error handling OPKSSH static callback", error, {
|
||||
operation: "opkssh_static_callback_error",
|
||||
url: req.url,
|
||||
originalUrl: req.originalUrl,
|
||||
});
|
||||
res.status(500).send("Authentication callback failed");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/opkssh-callback/{requestId}:
|
||||
* get:
|
||||
* summary: OAuth callback from OIDC provider for OPKSSH authentication (handles all sub-paths)
|
||||
* tags: [SSH]
|
||||
* parameters:
|
||||
* - name: requestId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Authentication request ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Callback processed successfully
|
||||
* 404:
|
||||
* description: Invalid authentication session
|
||||
* 500:
|
||||
* description: Authentication failed
|
||||
*/
|
||||
router.use(
|
||||
"/opkssh-callback/:requestId",
|
||||
async (req: Request, res: Response) => {
|
||||
const requestId = Array.isArray(req.params.requestId)
|
||||
? req.params.requestId[0]
|
||||
: req.params.requestId;
|
||||
|
||||
try {
|
||||
const { getActiveAuthSession } =
|
||||
await import("../../hosts/opkssh-auth.js");
|
||||
const session = getActiveAuthSession(requestId);
|
||||
|
||||
if (!session) {
|
||||
res.status(404).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Session Not Found",
|
||||
heading: "Session Not Found",
|
||||
message:
|
||||
"Authentication session expired or invalid. Please close this window and try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const axios = (await import("axios")).default;
|
||||
const fullPath = req.originalUrl || req.url;
|
||||
const pathAfterRequestId =
|
||||
fullPath.split(`/host/opkssh-callback/${requestId}`)[1] || "";
|
||||
// pathAfterRequestId may be "", "?query=...", "/subpath", or "/subpath?query=..."
|
||||
// OPKSSH's internal listener serves /login-callback, so when no sub-path is present
|
||||
// (query-only or empty), prepend it.
|
||||
const targetPath =
|
||||
pathAfterRequestId === "" || pathAfterRequestId.startsWith("?")
|
||||
? `/login-callback${pathAfterRequestId}`
|
||||
: pathAfterRequestId;
|
||||
|
||||
if (!session.callbackPort || session.callbackPort === 0) {
|
||||
sshLogger.error("OPKSSH callback session has no callback port", {
|
||||
operation: "opkssh_callback_proxy",
|
||||
requestId,
|
||||
sessionStatus: session.status,
|
||||
});
|
||||
res.status(500).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Error",
|
||||
heading: "Callback Error",
|
||||
message:
|
||||
"OPKSSH callback listener not ready. Please try authenticating again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = `http://127.0.0.1:${session.callbackPort}${targetPath}`;
|
||||
|
||||
const response = await axios({
|
||||
method: req.method,
|
||||
url: targetUrl,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: `127.0.0.1:${session.callbackPort}`,
|
||||
},
|
||||
data: req.body,
|
||||
timeout: 10000,
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 0,
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
|
||||
Object.entries(response.headers).forEach(([key, value]) => {
|
||||
if (key.toLowerCase() === "transfer-encoding") {
|
||||
return;
|
||||
}
|
||||
if (key.toLowerCase() === "location") {
|
||||
const location = value as string;
|
||||
if (location.startsWith("/")) {
|
||||
res.setHeader(
|
||||
key,
|
||||
`/host/opkssh-callback/${requestId}${location}`,
|
||||
);
|
||||
} else {
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
} else {
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
});
|
||||
|
||||
const contentType = String(response.headers["content-type"] || "");
|
||||
if (contentType.includes("text/html")) {
|
||||
const html = rewriteOPKSSHHtml(
|
||||
response.data.toString("utf-8"),
|
||||
requestId,
|
||||
"opkssh-callback",
|
||||
);
|
||||
res.status(response.status).send(html);
|
||||
} else {
|
||||
res.status(response.status).send(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("Error handling OPKSSH OAuth callback", error, {
|
||||
operation: "opkssh_oauth_callback_error",
|
||||
requestId,
|
||||
});
|
||||
|
||||
res.status(500).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Error",
|
||||
heading: "Error",
|
||||
message: "An unexpected error occurred. Please try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { escapeHtml } from "./opkssh-html.js";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function resultPage(ok: boolean, message: string): string {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Termix</title>
|
||||
|
||||
@@ -18,7 +18,6 @@ import { emitInternalEvent } from "../../hosts/internal-events.js";
|
||||
import { deleteOwnedHost } from "../../hosts/delete-host.js";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentOpksshTokenRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
@@ -46,7 +45,7 @@ import {
|
||||
withHostPluginSettings,
|
||||
} from "./host-plugin-settings.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
|
||||
import { registerHostAuthCompatRoutes } from "./host-compat-routes.js";
|
||||
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
|
||||
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
||||
import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
|
||||
@@ -183,7 +182,6 @@ router.post(
|
||||
password,
|
||||
authMethod,
|
||||
authType,
|
||||
useWarpgate,
|
||||
shareSshAuth,
|
||||
credentialId,
|
||||
vaultProfileId,
|
||||
@@ -306,7 +304,6 @@ router.post(
|
||||
port,
|
||||
username: effectiveUsername,
|
||||
authType: effectiveAuthType,
|
||||
useWarpgate: useWarpgate ? 1 : 0,
|
||||
shareSshAuth: shareSshAuth === true ? 1 : 0,
|
||||
credentialId: credentialId || null,
|
||||
vaultProfileId:
|
||||
@@ -860,7 +857,6 @@ router.put(
|
||||
password,
|
||||
authMethod,
|
||||
authType,
|
||||
useWarpgate,
|
||||
shareSshAuth,
|
||||
credentialId,
|
||||
vaultProfileId,
|
||||
@@ -984,7 +980,6 @@ router.put(
|
||||
port,
|
||||
username: effectiveUsername,
|
||||
authType: effectiveAuthType,
|
||||
useWarpgate: useWarpgate ? 1 : 0,
|
||||
shareSshAuth: shareSshAuth === true ? 1 : 0,
|
||||
credentialId: credentialId || null,
|
||||
vaultProfileId:
|
||||
@@ -2665,151 +2660,7 @@ registerHostAutostartRoutes(router, {
|
||||
requireDataAccess,
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/opkssh/token/{hostId}:
|
||||
* get:
|
||||
* summary: Get OPKSSH token status for a host
|
||||
* tags: [SSH]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - name: hostId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Host ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Token status retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* exists:
|
||||
* type: boolean
|
||||
* description: Whether a valid token exists
|
||||
* expiresAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Token expiration timestamp
|
||||
* email:
|
||||
* type: string
|
||||
* description: User email from OIDC identity
|
||||
* 404:
|
||||
* description: No valid token found
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.get(
|
||||
"/ssh/opkssh/token/:hostId",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("hosts.view"),
|
||||
requireDataAccess,
|
||||
requireDataAccess,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId;
|
||||
const hostId = parseInt(
|
||||
Array.isArray(req.params.hostId)
|
||||
? req.params.hostId[0]
|
||||
: req.params.hostId,
|
||||
);
|
||||
|
||||
if (!userId || isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const opksshTokenRepository = createCurrentOpksshTokenRepository();
|
||||
const tokenData = await opksshTokenRepository.findByUserAndHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
|
||||
if (!tokenData) {
|
||||
return res.status(404).json({ exists: false });
|
||||
}
|
||||
|
||||
const expiresAt = new Date(tokenData.expiresAt);
|
||||
|
||||
if (expiresAt < new Date()) {
|
||||
await opksshTokenRepository.deleteByUserAndHost(userId, hostId);
|
||||
return res.status(404).json({ exists: false });
|
||||
}
|
||||
|
||||
res.json({
|
||||
exists: true,
|
||||
expiresAt: tokenData.expiresAt,
|
||||
email: tokenData.email,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error retrieving OPKSSH token status", error, {
|
||||
operation: "opkssh_token_status_error",
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/opkssh/token/{hostId}:
|
||||
* delete:
|
||||
* summary: Delete OPKSSH token for a host
|
||||
* tags: [SSH]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - name: hostId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Host ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Token deleted successfully
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.delete(
|
||||
"/ssh/opkssh/token/:hostId",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("hosts.edit"),
|
||||
requireDataAccess,
|
||||
requireDataAccess,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId;
|
||||
const hostId = parseInt(
|
||||
Array.isArray(req.params.hostId)
|
||||
? req.params.hostId[0]
|
||||
: req.params.hostId,
|
||||
);
|
||||
|
||||
if (!userId || isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { deleteOPKSSHToken } = await import("../../hosts/opkssh-auth.js");
|
||||
await deleteOPKSSHToken(userId, hostId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sshLogger.error("Error deleting OPKSSH token", error, {
|
||||
operation: "opkssh_token_delete_error",
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
registerHostOpksshRoutes(router);
|
||||
registerHostAuthCompatRoutes(router);
|
||||
registerHostStepCaRoutes(router);
|
||||
|
||||
registerHostNetworkRoutes(router, {
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Replicates openpubkey's client/choosers/web_chooser.go IssuerToName().
|
||||
// OPKSSH's /select handler keys its providerMap by this derived name, NOT by the
|
||||
// `alias` field in config.yml. We need the same mapping so we can normalize any
|
||||
// `op=` query param we receive (which can be alias, issuer with protocol, or
|
||||
// issuer without protocol depending on client version) to what OPKSSH expects.
|
||||
function opksshIssuerToName(issuer: string): string | null {
|
||||
if (!issuer) return null;
|
||||
const withScheme =
|
||||
issuer.startsWith("http://") || issuer.startsWith("https://")
|
||||
? issuer
|
||||
: `https://${issuer}`;
|
||||
if (withScheme.startsWith("https://accounts.google.com")) return "google";
|
||||
if (withScheme.startsWith("https://login.microsoftonline.com"))
|
||||
return "azure";
|
||||
if (withScheme.startsWith("https://gitlab.com")) return "gitlab";
|
||||
if (withScheme.startsWith("https://issuer.hello.coop")) return "hello";
|
||||
if (withScheme.startsWith("https://")) {
|
||||
const host = withScheme.slice("https://".length).split("/")[0];
|
||||
return host || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeSelectOpParam(
|
||||
rawOp: string,
|
||||
providers: Array<{ alias: string; issuer: string }>,
|
||||
): string {
|
||||
if (!rawOp) return rawOp;
|
||||
const knownNames = new Set(
|
||||
providers
|
||||
.map((p) => opksshIssuerToName(p.issuer))
|
||||
.filter((n): n is string => typeof n === "string" && n.length > 0),
|
||||
);
|
||||
if (knownNames.has(rawOp)) return rawOp;
|
||||
|
||||
const derivedFromRaw = opksshIssuerToName(rawOp);
|
||||
if (derivedFromRaw && knownNames.has(derivedFromRaw)) return derivedFromRaw;
|
||||
|
||||
const matchByAlias = providers.find((p) => p.alias === rawOp);
|
||||
if (matchByAlias) {
|
||||
const name = opksshIssuerToName(matchByAlias.issuer);
|
||||
if (name) return name;
|
||||
}
|
||||
|
||||
return rawOp;
|
||||
}
|
||||
|
||||
type OpksshErrorPageOptions = {
|
||||
title: string;
|
||||
heading: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
requestId?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
export function renderOpksshErrorPage(opts: OpksshErrorPageOptions): string {
|
||||
const title = escapeHtml(opts.title);
|
||||
const heading = escapeHtml(opts.heading);
|
||||
const message = escapeHtml(opts.message);
|
||||
const detailsBlock = opts.details
|
||||
? `<pre class="details">${escapeHtml(opts.details)}</pre>`
|
||||
: "";
|
||||
const requestIdBlock = opts.requestId
|
||||
? `<p class="request-id">Request ID: ${escapeHtml(opts.requestId)}</p>`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>${title}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #18181b;
|
||||
color: #fafafa;
|
||||
padding: 1rem;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
background: #27272a;
|
||||
padding: 3rem 2rem;
|
||||
border-radius: 0.625rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
max-width: 720px;
|
||||
width: 100%;
|
||||
}
|
||||
h1 {
|
||||
color: #fafafa;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
p {
|
||||
color: #9ca3af;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
p + p { margin-top: 0.5rem; }
|
||||
.details {
|
||||
color: #d4d4d8;
|
||||
text-align: left;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: #0f0f11;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.request-id {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>${heading}</h1>
|
||||
<p>${message}</p>
|
||||
${detailsBlock}
|
||||
${requestIdBlock}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function rewriteOPKSSHHtml(
|
||||
html: string,
|
||||
requestId: string,
|
||||
routePrefix: "opkssh-chooser" | "opkssh-callback",
|
||||
): string {
|
||||
const basePath = `/host/${routePrefix}/${requestId}`;
|
||||
const localHostPattern = "(?:localhost|127\\.0\\.0\\.1)";
|
||||
|
||||
const attrPatterns = ["action", "href", "src", "formaction"];
|
||||
for (const attr of attrPatterns) {
|
||||
html = html.replace(
|
||||
new RegExp(`${attr}="(/[^"]*)`, "g"),
|
||||
`${attr}="${basePath}$1`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(`${attr}='(/[^']*)`, "g"),
|
||||
`${attr}='${basePath}$1`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const attr of ["href", "action", "src", "formaction"]) {
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`${attr}=["']?http:\\/\\/${localHostPattern}:\\d+\\/([^"'\\s]*)`,
|
||||
"g",
|
||||
),
|
||||
`${attr}="${basePath}/$1`,
|
||||
);
|
||||
}
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(window\\.location\\.href\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(window\\.location\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(fetch\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(location\\.assign\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(location\\.replace\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
// XMLHttpRequest.open("GET", "http://localhost:PORT/path", ...)
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(\\.open\\(["']\\w+["']\\s*,\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(<meta[^>]+http-equiv=["']refresh["'][^>]+content=["'][^;]+;\\s*url=)http:\\/\\/${localHostPattern}:\\d+\\/([^"']+)(["'][^>]*>)`,
|
||||
"gi",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(data-[\\w-]+=["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
const baseTag = `<base href="${basePath}/">`;
|
||||
|
||||
if (html.includes("<base")) {
|
||||
sshLogger.info("Replacing existing base tag", {
|
||||
operation: "opkssh_html_rewrite_base_tag",
|
||||
requestId,
|
||||
basePath,
|
||||
});
|
||||
html = html.replace(/<base[^>]*>/i, baseTag);
|
||||
} else if (html.includes("<head>")) {
|
||||
sshLogger.info("Inserting base tag into head", {
|
||||
operation: "opkssh_html_rewrite_base_tag_insert",
|
||||
requestId,
|
||||
basePath,
|
||||
});
|
||||
html = html.replace(/<head>/i, `<head>${baseTag}`);
|
||||
} else {
|
||||
sshLogger.warn("No <head> tag found, wrapping HTML", {
|
||||
operation: "opkssh_html_rewrite_no_head",
|
||||
requestId,
|
||||
htmlLength: html.length,
|
||||
htmlPreview: html.substring(0, 200),
|
||||
});
|
||||
html = `<!DOCTYPE html><html><head>${baseTag}</head><body>${html}</body></html>`;
|
||||
}
|
||||
|
||||
sshLogger.info("HTML rewrite complete", {
|
||||
operation: "opkssh_html_rewrite_complete",
|
||||
requestId,
|
||||
routePrefix,
|
||||
hasBaseTag: html.includes("<base href="),
|
||||
staticAssetCount: (html.match(/\/static\//g) || []).length,
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
Reference in new issue
Block a user