mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
2768f11dfc
* Improve Docker container list UI * Rework SSH tunnel forwarding * Update macOS Electron packaging * Optimize frontend bundle splitting * Add beta version update status * Add client tunnel preset management * Secure cookie authentication flows * Add client tunnel bridge support * Preserve sessions on restart * Update runtime to Node 24 * Add client remote tunnel support * Fix stale frontend cache handling * Fix Docker image platforms for Node 24 * Fix Electron packaging workflows * Fix client auth cache after upgrades * chore: cleanup files * fix: npm i error * Fix OIDC auth cookie readiness * Fix Docker npm ci config * Add react-is peer dependency * Fix Electron auth and cache handling * Improve terminal clipboard and refresh actions * feat: add API keys * feat: improve lazy loading with loading spinners * feat: Introduce FolderTree component with lazy-loading and motion animations for improved file manager UX (#735) * feat: integrate FolderTree component with lazy-loading for file manager sidebar - Add motion animation library (v12.38.0) for smooth UI transitions - Create new FolderTree component with advanced keyboard navigation support - Refactor kbd component: introduce KbdKey and KbdSeparator subcomponents - Implement lazy-loading strategy for directory tree in FileManagerSidebar - Refactor FileManagerSidebar with improved code organization and better separation of concerns - Update keyboard shortcut displays across CommandPalette, FileViewer, and Dashboard - Change React/ReactDOM dependency flags from dev to devOptional in package-lock.json BREAKING CHANGE: KbdGroup component has been replaced. Use <Kbd><KbdKey>...</KbdKey><KbdSeparator /></Kbd> instead. - Improves UX with smooth animations and better folder navigation - Reduces initial load time through lazy-loading subdirectories - Enhances accessibility with ARIA labels and keyboard navigation - Maintains dark mode support and proper styling * fix: incorrect use of the theme system and linked file manger sidebar with current folder --------- Co-authored-by: suryacagur <suryacagur.dev@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Enhance VNC token generation to include optional username parameter and refactor username input handling in HostGeneralTab (#733) * Fix Docker build info generation * Remove unused node-fetch dependency * feat: prompt user for SSH key passphrase on use (#715) When an encrypted SSH key has no stored passphrase, show a lightweight dialog prompting the user to enter it at connection time instead of failing with a parse error. Supports both desktop and mobile terminals. Closes Termix-SSH/Support#354 * fix: prevent session crash when uploading to permission-denied directory (#716) - Wrap writeFile sftp.stat callback in try-catch to prevent uncaught exceptions from escaping the callback into the event loop - Add missing stream.stderr error handler in writeFile fallback to prevent unhandled error events from crashing the process - Remove bogus activeOperations decrement in both writeFile and uploadFile fallback methods (counter was never incremented) - Add res.headersSent checks in fallback disconnect paths to prevent ERR_HTTP_HEADERS_SENT crashes Closes Termix-SSH/Support#652 * feat: add LOG_TIMESTAMP_FORMAT env var for 24h/ISO log timestamps (#718) Support LOG_TIMESTAMP_FORMAT environment variable with values: - "24h": 24-hour format (14:58:45) - "iso": ISO 8601 format (2026-04-25T14:58:45.000Z) - default: locale format (2:58:45 PM) Closes Termix-SSH/Support#650 * feat: open file manager at terminal current working directory (#719) * feat: open file manager at terminal current working directory When right-clicking in the terminal and selecting "Open File Manager Here", query the current working directory via a separate SSH exec channel and pass it as the initial path to the file manager tab. Closes Termix-SSH/Support#649 * chore: sync package-lock.json with node-fetch and deps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove undefined TerminalContextMenu from bad merge resolution Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: show reconnect overlay when SSH server reboots (#720) When the remote server reboots, the SSH connection closes while the stream is still active. The close handler only sent the "disconnected" message when sshStream was null, so the frontend never received the disconnect notification and hung with a blinking cursor. Change the else-if condition to always send the "disconnected" message regardless of stream state. Closes Termix-SSH/Support#648 * feat: support read-only Docker container mode (#721) Move nginx runtime files (config, pid, logs, temp dirs) from /app/nginx/ to /tmp/nginx/ so the container can run with read_only: true. Template files remain in /app/nginx/ as read-only assets. Users can now harden the container with: read_only: true tmpfs: - /tmp Closes Termix-SSH/Support#647 * fix: allow editing host folder without re-entering password (#722) When editing an existing host, the password field is stripped by the backend for security. The form validation treated the empty password as invalid, disabling the Update Host button even for non-auth changes like folder assignment. Use an "existing_password" sentinel (mirroring the existing "existing_key" pattern) to represent an unchanged password during editing, skip validation for it, and omit it from the update payload. Closes Termix-SSH/Support#645 * fix: auto-close tab on graceful SSH disconnect (exit/Ctrl+D) (#723) Distinguish between graceful shell exit and unexpected disconnection using the stream close event's exit code. When the shell exits normally (code != null), send "session_ended" instead of "disconnected". The frontend auto-closes the tab on session_ended, and shows the reconnect overlay only on unexpected disconnections. Closes Termix-SSH/Support#643 * fix: reattach existing SSH session on WebSocket reconnect (#724) WebSocket reconnection was always creating a new SSH connection with full authentication instead of reattaching to the existing SSH session. The condition `!isReconnectingRef.current` prevented session reattach during reconnection, causing repeated password auth attempts that trigger SSHGuard/fail2ban blocking. Remove the guard so reconnection tries to reattach the persisted session first. If the session has expired, the backend sends sessionExpired and the frontend falls back to a new connection. Closes Termix-SSH/Support#644 * fix: prevent browser crash when uploading large files (>100MB) (#725) The file-to-base64 conversion used a byte-by-byte string concatenation loop (String.fromCharCode + btoa), which allocated ~3x the file size in intermediate strings, causing the browser tab to OOM on files over ~100MB. Replace with FileReader.readAsDataURL which delegates base64 encoding to the browser engine natively, avoiding the intermediate allocations. Closes Termix-SSH/Support#577 * fix: support SSH multi-factor auth with publickey + password (#726) When sshd requires AuthenticationMethods publickey,password, the connection failed because the key auth branch only set privateKey without also setting password. After publickey partial auth succeeded, ssh2 sent keyboard-interactive (due to tryKeyboard:true) instead of password, which the server rejected. Pass the credential password alongside the private key so ssh2 can complete the password step after publickey succeeds. Closes Termix-SSH/Support#629 * feat(oidc): add OIDC_ALLOW_REGISTRATION env to bypass allow_registration for OIDC (#727) The `allow_registration` setting blocks both password-based and OIDC user creation. Admins who want to close password registration but still onboard new users via a trusted IdP (with the existing `OIDC_ALLOWED_USERS` whitelist) have no way to do that today. Introduce an `OIDC_ALLOW_REGISTRATION` env var. When set to `true`, the OIDC callback skips the `allow_registration` settings check while still honoring the `OIDC_ALLOWED_USERS` whitelist. Password registration via `POST /users/create` continues to respect `allow_registration`. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf: lazy load locales, file previews, and decouple startup imports (#729) * perf: lazy load locale bundles * perf: lazy load file preview modules * perf: avoid eager api client load on startup * chore: remove dead code, tighten types, fix lint warnings (#730) * chore: clean up low-risk lint warnings * chore: tighten utility types * chore: preserve backend error causes * chore: simplify command palette host state * chore: remove unused frontend code * chore: prune stale frontend state * chore: trim unused navigation code * chore: prune unused user settings props * chore: trim unused sidebar state * chore: remove stale host editor imports * chore: tighten shared frontend types * chore: narrow desktop helper types * chore: type network topology data * chore: type connection log errors * chore: use typed tab context * chore: type api client error metadata * chore: tighten terminal config types * chore: type host proxy chains * chore: type host editor form data * chore: use typed host viewer fields * chore: format app builder patch script * Fix client auth cache after upgrades * chore: fix pr checks after dev merge * fix: remove duplicate session-expired useEffect in FullScreenAppWrapper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Xenthys <x@dis.gg> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: npm package warnings * feat: reconnect after file manager disconnects * feat: add docs button in api keys * feat: change colors for server tunnels * fix: fetch password from API for Copy Password button (#736) * chore: update readme's * feat: improve c2s UI in user profile * feat: improve ssh key detection and move open file manager at path for terminal button * fix: restore missing getHostPassword import in Tab.tsx (#737) * fix: security related fixes * feat: improve alert code * Fix Electron clipboard handling * fix: untranslated alert text --------- Co-authored-by: Xenthys <x@dis.gg> Co-authored-by: PT Kelana Tech Solutions <ptkelanatechsolutions@gmail.com> Co-authored-by: suryacagur <suryacagur.dev@gmail.com> Co-authored-by: zimmra <28514085+zimmra@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Fuad <funtik1229@yandex.ru>
976 lines
28 KiB
TypeScript
976 lines
28 KiB
TypeScript
import { spawn, ChildProcess } from "child_process";
|
|
import { randomUUID } from "crypto";
|
|
import { WebSocket } from "ws";
|
|
import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js";
|
|
import { sshLogger } from "../utils/logger.js";
|
|
import { getDb } from "../database/db/index.js";
|
|
import { opksshTokens } from "../database/db/schema.js";
|
|
import { eq, and } from "drizzle-orm";
|
|
import { UserCrypto } from "../utils/user-crypto.js";
|
|
import { FieldCrypto } from "../utils/field-crypto.js";
|
|
import { promises as fs } from "fs";
|
|
import path from "path";
|
|
import axios from "axios";
|
|
import yaml from "js-yaml";
|
|
|
|
const AUTH_TIMEOUT = 60 * 1000;
|
|
|
|
export const OPKSSH_CALLBACK_PATH = "/host/opkssh-callback";
|
|
|
|
interface OPKSSHAuthSession {
|
|
requestId: string;
|
|
userId: string;
|
|
hostId: number;
|
|
hostname: string;
|
|
process: ChildProcess;
|
|
localPort: number;
|
|
callbackPort: number;
|
|
remoteRedirectUri: string;
|
|
providers: Array<{ alias: string; issuer: string }>;
|
|
status:
|
|
| "starting"
|
|
| "waiting_for_auth"
|
|
| "authenticating"
|
|
| "completed"
|
|
| "error";
|
|
ws: WebSocket;
|
|
stdoutBuffer: string;
|
|
privateKeyBuffer: string;
|
|
sshCertBuffer: string;
|
|
identity: {
|
|
email?: string;
|
|
sub?: string;
|
|
issuer?: string;
|
|
audience?: string;
|
|
};
|
|
createdAt: Date;
|
|
approvalTimeout: NodeJS.Timeout;
|
|
cleanup: () => Promise<void>;
|
|
}
|
|
|
|
const activeAuthSessions = new Map<string, OPKSSHAuthSession>();
|
|
const oauthStateToRequestId = new Map<string, string>();
|
|
const cleanupInProgress = new Set<string>();
|
|
|
|
function getOPKConfigPath(): string {
|
|
const dataDir =
|
|
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
|
|
return path.join(dataDir, ".opk", "config.yml");
|
|
}
|
|
|
|
async function ensureOPKConfigDir(): Promise<void> {
|
|
const configPath = getOPKConfigPath();
|
|
const configDir = path.dirname(configPath);
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
}
|
|
|
|
async function createTemplateConfig(): Promise<void> {
|
|
const configPath = getOPKConfigPath();
|
|
const template = `
|
|
# OPKSSH Configuration
|
|
# OPKSSH Documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md
|
|
# Termix Documentation: https://docs.termix.site/opkssh
|
|
`;
|
|
|
|
try {
|
|
await ensureOPKConfigDir();
|
|
await fs.writeFile(configPath, template, "utf8");
|
|
sshLogger.info(`Created template OPKSSH config at ${configPath}`);
|
|
} catch (error) {
|
|
sshLogger.warn("Failed to create template OPKSSH config", error);
|
|
}
|
|
}
|
|
|
|
interface ProviderRedirectInfo {
|
|
alias: string;
|
|
issuer: string;
|
|
redirectUris: string[];
|
|
}
|
|
|
|
async function checkOPKConfigExists(): Promise<{
|
|
exists: boolean;
|
|
error?: string;
|
|
configPath?: string;
|
|
providers?: ProviderRedirectInfo[];
|
|
}> {
|
|
const configPath = getOPKConfigPath();
|
|
const isDocker =
|
|
!!process.env.DATA_DIR && process.env.DATA_DIR.startsWith("/app");
|
|
const dockerHint = isDocker
|
|
? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user."
|
|
: "";
|
|
|
|
try {
|
|
const content = await fs.readFile(configPath, "utf8");
|
|
|
|
if (!content.includes("providers:")) {
|
|
return {
|
|
exists: false,
|
|
configPath,
|
|
error: `OPKSSH configuration is missing 'providers' section. Please edit the config file at:\n${configPath}\n\n.`,
|
|
};
|
|
}
|
|
|
|
const lines = content.split("\n");
|
|
|
|
const hasUncommentedProvider = lines.some((line) => {
|
|
const trimmed = line.trim();
|
|
return (
|
|
trimmed.startsWith("- alias:") ||
|
|
(trimmed.startsWith("issuer:") && !line.trimStart().startsWith("#"))
|
|
);
|
|
});
|
|
|
|
if (!hasUncommentedProvider) {
|
|
return {
|
|
exists: false,
|
|
configPath,
|
|
error: `OPKSSH configuration has no active providers. Please edit the config file at:\n${configPath}\n\nUncomment and configure at least one OIDC provider.\nSee documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md${dockerHint}`,
|
|
};
|
|
}
|
|
|
|
let providers: ProviderRedirectInfo[] = [];
|
|
try {
|
|
const parsed = yaml.load(content) as {
|
|
providers?: Array<{
|
|
alias?: string;
|
|
issuer?: string;
|
|
redirect_uris?: string[];
|
|
}>;
|
|
};
|
|
if (parsed?.providers && Array.isArray(parsed.providers)) {
|
|
providers = parsed.providers
|
|
.filter(
|
|
(
|
|
p,
|
|
): p is {
|
|
alias: string;
|
|
issuer: string;
|
|
redirect_uris?: string[];
|
|
} => typeof p.alias === "string" && typeof p.issuer === "string",
|
|
)
|
|
.map((p) => ({
|
|
alias: p.alias,
|
|
issuer: p.issuer.replace(/^https?:\/\//, ""),
|
|
redirectUris: Array.isArray(p.redirect_uris)
|
|
? p.redirect_uris.filter(
|
|
(u): u is string => typeof u === "string",
|
|
)
|
|
: [],
|
|
}));
|
|
}
|
|
} catch (e) {
|
|
sshLogger.warn("Failed to parse OPKSSH config for providers", {
|
|
operation: "opkssh_config_parse_providers_error",
|
|
error: e,
|
|
});
|
|
}
|
|
|
|
return { exists: true, configPath, providers };
|
|
} catch {
|
|
await createTemplateConfig();
|
|
return {
|
|
exists: false,
|
|
configPath,
|
|
error: `OPKSSH configuration not found. A template config file has been created at:\n${configPath}\n\nPlease edit this file and configure your OIDC provider (Google, GitHub, Microsoft, etc.).\nSee documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md${dockerHint}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// OPKSSH's `redirect_uris` field lists candidate LOCAL ports for the callback listener
|
|
// that OPKSSH binds on the host running the binary. The openpubkey library enforces these
|
|
// must be localhost, a non-localhost entry causes ECONNRESET on /select/ at runtime.
|
|
// The publicly registered OAuth redirect URI is what Termix passes via --remote-redirect-uri
|
|
// (derived from request origin); users do NOT put that URL in this config field.
|
|
function validateRedirectUrisAreLocalhost(
|
|
providers: ProviderRedirectInfo[],
|
|
): { ok: true } | { ok: false; message: string } {
|
|
const isLocalHost = (host: string): boolean => {
|
|
const bare = host.replace(/^\[|\]$/g, "");
|
|
return (
|
|
bare === "localhost" ||
|
|
bare === "127.0.0.1" ||
|
|
bare === "::1" ||
|
|
bare === "0:0:0:0:0:0:0:1" ||
|
|
bare.startsWith("localhost:") ||
|
|
bare.startsWith("127.0.0.1:")
|
|
);
|
|
};
|
|
|
|
const issues: string[] = [];
|
|
for (const p of providers) {
|
|
const uris = p.redirectUris || [];
|
|
if (uris.length === 0) continue;
|
|
const nonLocal = uris.filter((u) => {
|
|
try {
|
|
return !isLocalHost(new URL(u).hostname);
|
|
} catch {
|
|
return true;
|
|
}
|
|
});
|
|
if (nonLocal.length > 0) {
|
|
issues.push(
|
|
`Provider '${p.alias}': non-localhost entries in redirect_uris: ${nonLocal.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (issues.length > 0) {
|
|
return {
|
|
ok: false,
|
|
message:
|
|
`OPKSSH configuration error: 'redirect_uris' must only contain localhost URLs.\n\n` +
|
|
`${issues.join("\n")}\n\n` +
|
|
`This field is OPKSSH's local callback listener, it must be localhost (or omitted to use ` +
|
|
`the defaults http://localhost:3000/login-callback, :10001, :11110). ` +
|
|
`The public Termix callback URL is supplied automatically by Termix via --remote-redirect-uri; ` +
|
|
`you do not put it here. Register the PUBLIC Termix URL with your OAuth provider instead ` +
|
|
`(e.g. https://your-domain${OPKSSH_CALLBACK_PATH}).\n\n` +
|
|
`Fix: remove the non-localhost entries above, or delete the whole 'redirect_uris' block to use defaults.\n\n` +
|
|
`Docs: https://docs.termix.site/opkssh`,
|
|
};
|
|
}
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function startOPKSSHAuth(
|
|
userId: string,
|
|
hostId: number,
|
|
hostname: string,
|
|
ws: WebSocket,
|
|
requestOrigin: string,
|
|
): Promise<string> {
|
|
try {
|
|
await ensureOPKConfigDir();
|
|
const configDir = path.dirname(getOPKConfigPath());
|
|
await fs.access(configDir, fs.constants.R_OK | fs.constants.W_OK);
|
|
} catch (error) {
|
|
sshLogger.error("OPKSSH directory not accessible", error);
|
|
const isDocker =
|
|
!!process.env.DATA_DIR && process.env.DATA_DIR.startsWith("/app");
|
|
const dockerHint = isDocker
|
|
? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user."
|
|
: "";
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_error",
|
|
error: `OPKSSH directory initialization failed: ${error.message}${dockerHint}`,
|
|
}),
|
|
);
|
|
return "";
|
|
}
|
|
|
|
const configCheck = await checkOPKConfigExists();
|
|
if (!configCheck.exists) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_config_error",
|
|
requestId: "",
|
|
error: configCheck.error,
|
|
instructions: configCheck.error,
|
|
}),
|
|
);
|
|
return "";
|
|
}
|
|
|
|
const redirectValidation = validateRedirectUrisAreLocalhost(
|
|
configCheck.providers || [],
|
|
);
|
|
if (redirectValidation.ok === false) {
|
|
sshLogger.warn("OPKSSH config redirect_uris validation failed", {
|
|
operation: "opkssh_config_redirect_uris_not_localhost",
|
|
configPath: configCheck.configPath,
|
|
});
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_config_error",
|
|
requestId: "",
|
|
error: redirectValidation.message,
|
|
instructions: redirectValidation.message,
|
|
}),
|
|
);
|
|
return "";
|
|
}
|
|
|
|
const requestId = randomUUID();
|
|
const remoteRedirectUri = `${requestOrigin}${OPKSSH_CALLBACK_PATH}`;
|
|
|
|
sshLogger.info("Starting OPKSSH auth session", {
|
|
operation: "opkssh_start_auth_remote_redirect_uri",
|
|
requestId,
|
|
userId,
|
|
hostId,
|
|
requestOrigin,
|
|
remoteRedirectUri,
|
|
providerAliases: (configCheck.providers || []).map((p) => p.alias),
|
|
});
|
|
|
|
const session: Partial<OPKSSHAuthSession> = {
|
|
requestId,
|
|
userId,
|
|
hostId,
|
|
hostname,
|
|
localPort: 0,
|
|
callbackPort: 0,
|
|
remoteRedirectUri,
|
|
providers: configCheck.providers || [],
|
|
status: "starting",
|
|
ws,
|
|
stdoutBuffer: "",
|
|
privateKeyBuffer: "",
|
|
sshCertBuffer: "",
|
|
identity: {},
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
try {
|
|
const binaryPath = OPKSSHBinaryManager.getBinaryPath();
|
|
const configPath = getOPKConfigPath();
|
|
|
|
const args = [
|
|
"login",
|
|
"--print-key",
|
|
"--disable-browser-open",
|
|
`--config-path=${configPath}`,
|
|
`--remote-redirect-uri=${remoteRedirectUri}`,
|
|
];
|
|
|
|
const opksshProcess = spawn(binaryPath, args, {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: {
|
|
...process.env,
|
|
},
|
|
});
|
|
session.process = opksshProcess;
|
|
|
|
const cleanup = async () => {
|
|
await cleanupAuthSession(requestId);
|
|
};
|
|
session.cleanup = cleanup;
|
|
|
|
const timeout = setTimeout(async () => {
|
|
sshLogger.warn(`OPKSSH auth timeout for session ${requestId}`);
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_timeout",
|
|
requestId,
|
|
}),
|
|
);
|
|
await cleanup();
|
|
}, AUTH_TIMEOUT);
|
|
|
|
session.approvalTimeout = timeout;
|
|
|
|
ws.on("close", () => {
|
|
cleanup();
|
|
});
|
|
|
|
activeAuthSessions.set(requestId, session as OPKSSHAuthSession);
|
|
|
|
opksshProcess.stdout?.on("data", (data) => {
|
|
const output = data.toString();
|
|
handleOPKSSHOutput(requestId, output);
|
|
});
|
|
|
|
opksshProcess.stderr?.on("data", async (data) => {
|
|
const stderr = data.toString();
|
|
|
|
if (
|
|
stderr.includes("Opening browser to") ||
|
|
stderr.includes("Open your browser to:")
|
|
) {
|
|
handleOPKSSHOutput(requestId, stderr);
|
|
}
|
|
|
|
if (stderr.includes("listening on")) {
|
|
handleOPKSSHOutput(requestId, stderr);
|
|
}
|
|
|
|
const lowerStderr = stderr.toLowerCase();
|
|
|
|
// OPKSSH's openpubkey library rejects non-localhost `redirect_uris` at runtime
|
|
// with the distinctive message "redirectURI must be localhost". Surface that
|
|
// directly with actionable guidance.
|
|
if (lowerStderr.includes("redirecturi must be localhost")) {
|
|
sshLogger.warn("OPKSSH rejected non-localhost entry in redirect_uris", {
|
|
operation: "opkssh_stderr_redirect_uris_not_localhost",
|
|
requestId,
|
|
remoteRedirectUri,
|
|
stderrSnippet: stderr.slice(0, 500),
|
|
});
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_config_error",
|
|
requestId,
|
|
error:
|
|
`OPKSSH rejected the local callback URI: every entry in 'redirect_uris' must be localhost.\n\n` +
|
|
`OPKSSH output:\n${stderr.trim()}\n\n` +
|
|
`The 'redirect_uris' config field is OPKSSH's LOCAL listener — it is not the public Termix callback. ` +
|
|
`Remove any non-localhost entries from redirect_uris (or delete the whole block to use OPKSSH's ` +
|
|
`defaults of :3000, :10001, :11110). Register the public Termix callback URL with your OAuth ` +
|
|
`provider instead, Termix passes it to OPKSSH automatically via --remote-redirect-uri.`,
|
|
instructions: "See documentation: https://docs.termix.site/opkssh",
|
|
}),
|
|
);
|
|
await cleanup();
|
|
return;
|
|
}
|
|
|
|
// Generic redirect-uri/mismatch errors (OAuth provider side, OPKSSH config side, etc.)
|
|
const genericRedirectIndicators = [
|
|
"redirect_uri",
|
|
"redirect uri",
|
|
"invalid redirect",
|
|
"no matching redirect",
|
|
"allowed redirect",
|
|
"mismatching redirection",
|
|
];
|
|
const hasGenericRedirectError = genericRedirectIndicators.some((s) =>
|
|
lowerStderr.includes(s),
|
|
);
|
|
|
|
if (hasGenericRedirectError) {
|
|
sshLogger.warn("OPKSSH stderr reported redirect_uri error", {
|
|
operation: "opkssh_stderr_redirect_uri_error",
|
|
requestId,
|
|
remoteRedirectUri,
|
|
stderrSnippet: stderr.slice(0, 500),
|
|
});
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_config_error",
|
|
requestId,
|
|
error:
|
|
`OPKSSH or the OAuth provider rejected the redirect URI.\n\n` +
|
|
`Computed Termix callback URI (sent to provider): ${remoteRedirectUri}\n\n` +
|
|
`OPKSSH output:\n${stderr.trim()}\n\n` +
|
|
`Register '${remoteRedirectUri}' as an authorized redirect URI with your OAuth provider ` +
|
|
`(e.g. in Google Cloud Console → OAuth client). ` +
|
|
`Also confirm any 'redirect_uris' in your OPKSSH config contain ONLY localhost URLs.`,
|
|
instructions: "See documentation: https://docs.termix.site/opkssh",
|
|
}),
|
|
);
|
|
await cleanup();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
stderr.includes("provider not found") ||
|
|
stderr.includes("config error") ||
|
|
stderr.includes("invalid config") ||
|
|
stderr.includes("config not found")
|
|
) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_config_error",
|
|
requestId,
|
|
error:
|
|
"OPKSSH configuration error. Please verify your config file contains valid OIDC provider settings.",
|
|
instructions:
|
|
"See documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md",
|
|
}),
|
|
);
|
|
cleanup();
|
|
}
|
|
|
|
if (
|
|
stderr.includes("level=error") ||
|
|
stderr.includes("Error:") ||
|
|
stderr.includes("failed")
|
|
) {
|
|
const isXdgOpenError = stderr.includes('exec: "xdg-open"');
|
|
if (!isXdgOpenError) {
|
|
if (
|
|
stderr.includes("bind: address already in use") ||
|
|
stderr.includes("error logging in") ||
|
|
stderr.includes("failed to start")
|
|
) {
|
|
await cleanup();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
opksshProcess.on("error", (error) => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_error",
|
|
requestId,
|
|
error: `OPKSSH process error: ${error.message}`,
|
|
}),
|
|
);
|
|
cleanup();
|
|
});
|
|
|
|
opksshProcess.on("exit", (code) => {
|
|
if (code !== 0 && session.status !== "completed") {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_error",
|
|
requestId,
|
|
error: `OPKSSH process exited with code ${code}`,
|
|
}),
|
|
);
|
|
}
|
|
cleanup();
|
|
});
|
|
|
|
return requestId;
|
|
} catch (error) {
|
|
sshLogger.error(`Failed to start OPKSSH auth session`, error);
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_error",
|
|
requestId,
|
|
error: `Failed to start OPKSSH authentication: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
}),
|
|
);
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function handleOPKSSHOutput(requestId: string, output: string): void {
|
|
const session = activeAuthSessions.get(requestId);
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
session.stdoutBuffer += output;
|
|
|
|
const chooserUrlMatch = session.stdoutBuffer.match(
|
|
/(?:Opening browser to|Open your browser to:)\s*http:\/\/(?:localhost|127\.0\.0\.1):(\d+)\/chooser/,
|
|
);
|
|
if (chooserUrlMatch && session.status === "starting") {
|
|
const actualPort = parseInt(chooserUrlMatch[1], 10);
|
|
const localChooserUrl = `http://127.0.0.1:${actualPort}/chooser`;
|
|
|
|
session.localPort = actualPort;
|
|
|
|
const baseUrl = session.remoteRedirectUri
|
|
.replace(/\/host\/opkssh-callback$/, "")
|
|
// In direct dev mode the WS server (30002) is separate from the HTTP API (30001)
|
|
.replace(/:30002\b/, ":30001");
|
|
const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`;
|
|
|
|
session.status = "waiting_for_auth";
|
|
session.ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_status",
|
|
requestId,
|
|
stage: "chooser",
|
|
url: proxiedChooserUrl,
|
|
providers: session.providers,
|
|
localUrl: localChooserUrl,
|
|
message: "Please authenticate in your browser",
|
|
}),
|
|
);
|
|
}
|
|
|
|
const callbackPortMatch = session.stdoutBuffer.match(
|
|
/listening on http:\/\/(?:127\.0\.0\.1|localhost):(\d+)\//,
|
|
);
|
|
if (callbackPortMatch && !session.callbackPort) {
|
|
session.callbackPort = parseInt(callbackPortMatch[1], 10);
|
|
}
|
|
|
|
if (output.includes("BEGIN OPENSSH PRIVATE KEY")) {
|
|
session.status = "authenticating";
|
|
session.ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_status",
|
|
requestId,
|
|
stage: "authenticating",
|
|
message: "Processing authentication...",
|
|
}),
|
|
);
|
|
}
|
|
|
|
const privateKeyMatch = session.stdoutBuffer.match(
|
|
/(-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*?-----END OPENSSH PRIVATE KEY-----)/,
|
|
);
|
|
if (privateKeyMatch) {
|
|
session.privateKeyBuffer = privateKeyMatch[1].trim();
|
|
}
|
|
|
|
const certMatch = session.stdoutBuffer.match(
|
|
/(ecdsa-sha2-nistp256-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+|ssh-rsa-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+|ssh-ed25519-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+)/,
|
|
);
|
|
if (certMatch) {
|
|
session.sshCertBuffer = certMatch[1].trim();
|
|
}
|
|
|
|
const identityMatch = session.stdoutBuffer.match(
|
|
/Email, sub, issuer, audience:\s*\n?\s*([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/,
|
|
);
|
|
if (identityMatch) {
|
|
session.identity = {
|
|
email: identityMatch[1],
|
|
sub: identityMatch[2],
|
|
issuer: identityMatch[3],
|
|
audience: identityMatch[4],
|
|
};
|
|
}
|
|
|
|
if (session.privateKeyBuffer && session.sshCertBuffer) {
|
|
if (!session.privateKeyBuffer.includes("BEGIN OPENSSH PRIVATE KEY")) {
|
|
sshLogger.error(`Invalid private key extracted [${requestId}]`, {
|
|
bufferPrefix: session.privateKeyBuffer.substring(0, 50),
|
|
});
|
|
session.ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_error",
|
|
requestId,
|
|
error: "Failed to extract valid private key from OPKSSH output",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!session.sshCertBuffer.match(/-cert-v01@openssh\.com/)) {
|
|
sshLogger.error(`Invalid SSH certificate extracted [${requestId}]`, {
|
|
bufferPrefix: session.sshCertBuffer.substring(0, 50),
|
|
});
|
|
session.ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_error",
|
|
requestId,
|
|
error: "Failed to extract valid SSH certificate from OPKSSH output",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
storeOPKSSHToken(session);
|
|
}
|
|
}
|
|
|
|
async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise<void> {
|
|
try {
|
|
const db = getDb();
|
|
const userCrypto = UserCrypto.getInstance();
|
|
|
|
const expiresAt = new Date();
|
|
expiresAt.setHours(expiresAt.getHours() + 24);
|
|
|
|
const userDataKey = userCrypto.getUserDataKey(session.userId);
|
|
if (!userDataKey) {
|
|
throw new Error("User data key not found");
|
|
}
|
|
|
|
const tokenId = `opkssh-${session.userId}-${session.hostId}`;
|
|
|
|
const encryptedCert = FieldCrypto.encryptField(
|
|
session.sshCertBuffer,
|
|
userDataKey,
|
|
tokenId,
|
|
"ssh_cert",
|
|
);
|
|
const encryptedKey = FieldCrypto.encryptField(
|
|
session.privateKeyBuffer,
|
|
userDataKey,
|
|
tokenId,
|
|
"private_key",
|
|
);
|
|
|
|
await db
|
|
.insert(opksshTokens)
|
|
.values({
|
|
userId: session.userId,
|
|
hostId: session.hostId,
|
|
sshCert: encryptedCert,
|
|
privateKey: encryptedKey,
|
|
email: session.identity.email,
|
|
sub: session.identity.sub,
|
|
issuer: session.identity.issuer,
|
|
audience: session.identity.audience,
|
|
expiresAt: expiresAt.toISOString(),
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [opksshTokens.userId, opksshTokens.hostId],
|
|
set: {
|
|
sshCert: encryptedCert,
|
|
privateKey: encryptedKey,
|
|
email: session.identity.email,
|
|
sub: session.identity.sub,
|
|
issuer: session.identity.issuer,
|
|
audience: session.identity.audience,
|
|
expiresAt: expiresAt.toISOString(),
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
});
|
|
|
|
session.status = "completed";
|
|
session.ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_completed",
|
|
requestId: session.requestId,
|
|
expiresAt: expiresAt.toISOString(),
|
|
}),
|
|
);
|
|
|
|
await session.cleanup();
|
|
} catch (error) {
|
|
sshLogger.error(
|
|
`Failed to store OPKSSH token for session ${session.requestId}`,
|
|
error,
|
|
);
|
|
session.ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_error",
|
|
requestId: session.requestId,
|
|
error: "Failed to store authentication token",
|
|
}),
|
|
);
|
|
await session.cleanup();
|
|
}
|
|
}
|
|
|
|
export async function getOPKSSHToken(
|
|
userId: string,
|
|
hostId: number,
|
|
): Promise<{ sshCert: string; privateKey: string } | null> {
|
|
try {
|
|
const db = getDb();
|
|
const token = await db
|
|
.select()
|
|
.from(opksshTokens)
|
|
.where(
|
|
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
|
)
|
|
.limit(1);
|
|
|
|
if (!token || token.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const tokenData = token[0];
|
|
const expiresAt = new Date(tokenData.expiresAt);
|
|
|
|
if (expiresAt < new Date()) {
|
|
await db
|
|
.delete(opksshTokens)
|
|
.where(
|
|
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const userCrypto = UserCrypto.getInstance();
|
|
const userDataKey = userCrypto.getUserDataKey(userId);
|
|
if (!userDataKey) {
|
|
throw new Error("User data key not found");
|
|
}
|
|
|
|
const tokenId = `opkssh-${userId}-${hostId}`;
|
|
const decryptedCert = FieldCrypto.decryptField(
|
|
tokenData.sshCert,
|
|
userDataKey,
|
|
tokenId,
|
|
"ssh_cert",
|
|
);
|
|
const decryptedKey = FieldCrypto.decryptField(
|
|
tokenData.privateKey,
|
|
userDataKey,
|
|
tokenId,
|
|
"private_key",
|
|
);
|
|
|
|
await db
|
|
.update(opksshTokens)
|
|
.set({ lastUsed: new Date().toISOString() })
|
|
.where(
|
|
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
|
);
|
|
|
|
return {
|
|
sshCert: decryptedCert,
|
|
privateKey: decryptedKey,
|
|
};
|
|
} catch (error) {
|
|
sshLogger.error(`Failed to retrieve OPKSSH token`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function deleteOPKSSHToken(
|
|
userId: string,
|
|
hostId: number,
|
|
): Promise<void> {
|
|
const db = getDb();
|
|
await db
|
|
.delete(opksshTokens)
|
|
.where(
|
|
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
|
);
|
|
}
|
|
|
|
export async function invalidateOPKSSHToken(
|
|
userId: string,
|
|
hostId: number,
|
|
reason: string,
|
|
): Promise<void> {
|
|
try {
|
|
const db = getDb();
|
|
await db
|
|
.delete(opksshTokens)
|
|
.where(
|
|
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
|
);
|
|
} catch (error) {
|
|
sshLogger.error(`Failed to invalidate OPKSSH token`, {
|
|
userId,
|
|
hostId,
|
|
reason,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function handleOAuthCallback(
|
|
requestId: string,
|
|
queryString: string,
|
|
): Promise<{ success: boolean; message?: string }> {
|
|
const session = activeAuthSessions.get(requestId);
|
|
|
|
if (!session) {
|
|
return { success: false, message: "Invalid authentication session" };
|
|
}
|
|
|
|
try {
|
|
const callbackUrl = `http://127.0.0.1:${session.localPort}/login-callback?${queryString}`;
|
|
await axios.get(callbackUrl, {
|
|
timeout: 10000,
|
|
validateStatus: () => true,
|
|
});
|
|
return { success: true };
|
|
} catch {
|
|
session.ws.send(
|
|
JSON.stringify({
|
|
type: "opkssh_error",
|
|
requestId,
|
|
error: "Failed to complete authentication",
|
|
}),
|
|
);
|
|
await session.cleanup();
|
|
return { success: false, message: "Authentication failed" };
|
|
}
|
|
}
|
|
|
|
async function cleanupAuthSession(requestId: string): Promise<void> {
|
|
if (cleanupInProgress.has(requestId)) {
|
|
return;
|
|
}
|
|
|
|
cleanupInProgress.add(requestId);
|
|
|
|
try {
|
|
const session = activeAuthSessions.get(requestId);
|
|
if (!session) {
|
|
cleanupInProgress.delete(requestId);
|
|
return;
|
|
}
|
|
|
|
if (session.approvalTimeout) {
|
|
clearTimeout(session.approvalTimeout);
|
|
}
|
|
|
|
if (session.process) {
|
|
try {
|
|
session.process.kill("SIGTERM");
|
|
await new Promise<void>((resolve) => {
|
|
const killTimeout = setTimeout(() => {
|
|
if (session.process && !session.process.killed) {
|
|
session.process.kill("SIGKILL");
|
|
}
|
|
resolve();
|
|
}, 3000);
|
|
|
|
session.process.once("exit", () => {
|
|
clearTimeout(killTimeout);
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
} catch (killError) {
|
|
sshLogger.warn(
|
|
`Failed to kill OPKSSH process for session ${requestId}`,
|
|
killError,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Clean up any OAuth state mappings for this session
|
|
for (const [state, reqId] of oauthStateToRequestId.entries()) {
|
|
if (reqId === requestId) {
|
|
oauthStateToRequestId.delete(state);
|
|
}
|
|
}
|
|
|
|
activeAuthSessions.delete(requestId);
|
|
} finally {
|
|
cleanupInProgress.delete(requestId);
|
|
}
|
|
}
|
|
|
|
export function cancelAuthSession(requestId: string): void {
|
|
const session = activeAuthSessions.get(requestId);
|
|
if (session) {
|
|
session.cleanup();
|
|
}
|
|
}
|
|
|
|
export function getActiveAuthSession(
|
|
requestId: string,
|
|
): OPKSSHAuthSession | undefined {
|
|
return activeAuthSessions.get(requestId);
|
|
}
|
|
|
|
export function getActiveSessionsForUser(userId: string): OPKSSHAuthSession[] {
|
|
const sessions: OPKSSHAuthSession[] = [];
|
|
for (const session of activeAuthSessions.values()) {
|
|
if (session.userId === userId) {
|
|
sessions.push(session);
|
|
}
|
|
}
|
|
return sessions;
|
|
}
|
|
|
|
export function getActiveSessionsAll(): OPKSSHAuthSession[] {
|
|
return Array.from(activeAuthSessions.values());
|
|
}
|
|
|
|
export function registerOAuthState(state: string, requestId: string): void {
|
|
oauthStateToRequestId.set(state, requestId);
|
|
}
|
|
|
|
export function getRequestIdByOAuthState(state: string): string | undefined {
|
|
return oauthStateToRequestId.get(state);
|
|
}
|
|
|
|
export function clearOAuthState(state: string): void {
|
|
oauthStateToRequestId.delete(state);
|
|
}
|
|
|
|
export async function getUserIdFromRequest(req: {
|
|
cookies?: Record<string, string>;
|
|
headers: Record<string, string | undefined>;
|
|
}): Promise<string | null> {
|
|
try {
|
|
const { AuthManager } = await import("../utils/auth-manager.js");
|
|
const authManager = AuthManager.getInstance();
|
|
|
|
const token =
|
|
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
|
|
const decoded = await authManager.verifyJWTToken(token);
|
|
return decoded?.userId || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|