mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 21:33:41 +00:00
9c8528e4d2
file-manager/, metrics/ (incl. widgets, managers, alert-engine), terminal/, tmux/ and tunnel/ each own their files; docker/ gains container-runtime. Genuinely shared helpers (jump-host chain, host resolver, connection pool, opkssh, vault, serial) stay at hosts/ root. Pure file moves with import path updates; mirrored test paths follow.
597 lines
18 KiB
TypeScript
597 lines
18 KiB
TypeScript
import express, { type Response } from "express";
|
|
|
|
import axios from "axios";
|
|
import type {
|
|
SSHHost,
|
|
TunnelConfig,
|
|
AuthenticatedRequest,
|
|
} from "../../../types/index.js";
|
|
import { CONNECTION_STATES } from "../../../types/index.js";
|
|
import { tunnelLogger } from "../../utils/logger.js";
|
|
import { SystemCrypto } from "../../utils/system-crypto.js";
|
|
import { AuthManager } from "../../utils/auth-manager.js";
|
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
|
|
|
import { getTunnelMode, validateTunnelConfig } from "./utils.js";
|
|
|
|
import {
|
|
tunnelConfigs,
|
|
activeRetryTimers,
|
|
pendingTunnelOperations,
|
|
manualDisconnects,
|
|
retryExhaustedTunnels,
|
|
retryCounters,
|
|
countdownIntervals,
|
|
tunnelConnecting,
|
|
tunnelStatusClients,
|
|
connectionStatus,
|
|
cleanupTunnelResources,
|
|
broadcastTunnelStatus,
|
|
handleDisconnect,
|
|
sendTunnelStatusSnapshot,
|
|
isSingleHostTunnel,
|
|
getAllTunnelStatus,
|
|
findHostByTunnelEndpoint,
|
|
connectSSHTunnel,
|
|
} from "./manager.js";
|
|
|
|
const permissionManager = PermissionManager.getInstance();
|
|
|
|
const authManager = AuthManager.getInstance();
|
|
const authenticateJWT = authManager.createAuthMiddleware();
|
|
|
|
export function registerTunnelRoutes(app: express.Express): void {
|
|
app.get(
|
|
"/ssh/tunnel/status",
|
|
authenticateJWT,
|
|
(req: AuthenticatedRequest, res: Response) => {
|
|
if (!req.userId) {
|
|
return res.status(401).json({ error: "Authentication required" });
|
|
}
|
|
|
|
res.json(getAllTunnelStatus());
|
|
},
|
|
);
|
|
|
|
app.get(
|
|
"/ssh/tunnel/status/stream",
|
|
authenticateJWT,
|
|
(req: AuthenticatedRequest, res: Response) => {
|
|
if (!req.userId) {
|
|
return res.status(401).json({ error: "Authentication required" });
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-store, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
});
|
|
res.flushHeaders?.();
|
|
|
|
tunnelStatusClients.add(res);
|
|
sendTunnelStatusSnapshot(res);
|
|
|
|
const heartbeat = setInterval(() => {
|
|
try {
|
|
res.write(": keepalive\n\n");
|
|
} catch {
|
|
closeStream();
|
|
}
|
|
}, 30000);
|
|
|
|
const closeStream = () => {
|
|
clearInterval(heartbeat);
|
|
tunnelStatusClients.delete(res);
|
|
};
|
|
|
|
req.on("close", closeStream);
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ssh/tunnel/status/{tunnelName}:
|
|
* get:
|
|
* summary: Get tunnel status by name
|
|
* description: Retrieves the status of a specific SSH tunnel by its name.
|
|
* tags:
|
|
* - SSH Tunnels
|
|
* parameters:
|
|
* - in: path
|
|
* name: tunnelName
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Tunnel status.
|
|
* 404:
|
|
* description: Tunnel not found.
|
|
*/
|
|
app.get(
|
|
"/ssh/tunnel/status/:tunnelName",
|
|
authenticateJWT,
|
|
(req: AuthenticatedRequest, res: Response) => {
|
|
if (!req.userId) {
|
|
return res.status(401).json({ error: "Authentication required" });
|
|
}
|
|
|
|
const tunnelNameParam = req.params.tunnelName;
|
|
const tunnelName = Array.isArray(tunnelNameParam)
|
|
? tunnelNameParam[0]
|
|
: tunnelNameParam;
|
|
const status = connectionStatus.get(tunnelName);
|
|
|
|
if (!status) {
|
|
return res.status(404).json({ error: "Tunnel not found" });
|
|
}
|
|
|
|
res.json({ name: tunnelName, status });
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ssh/tunnel/connect:
|
|
* post:
|
|
* summary: Connect SSH tunnel
|
|
* description: Establishes an SSH tunnel connection with the specified configuration.
|
|
* tags:
|
|
* - SSH Tunnels
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* sourceHostId:
|
|
* type: integer
|
|
* tunnelIndex:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Connection request received.
|
|
* 400:
|
|
* description: Invalid tunnel configuration.
|
|
* 401:
|
|
* description: Authentication required.
|
|
* 403:
|
|
* description: Access denied to this host.
|
|
* 500:
|
|
* description: Failed to connect tunnel.
|
|
*/
|
|
app.post(
|
|
"/ssh/tunnel/connect",
|
|
authenticateJWT,
|
|
async (req: AuthenticatedRequest, res: Response) => {
|
|
const tunnelConfig: TunnelConfig = req.body;
|
|
const userId = req.userId;
|
|
|
|
if (!userId) {
|
|
return res.status(401).json({ error: "Authentication required" });
|
|
}
|
|
|
|
if (!tunnelConfig || !tunnelConfig.name) {
|
|
return res.status(400).json({ error: "Invalid tunnel configuration" });
|
|
}
|
|
|
|
const tunnelName = tunnelConfig.name;
|
|
tunnelConfig.requestingUserId = userId;
|
|
|
|
try {
|
|
if (!validateTunnelConfig(tunnelName, tunnelConfig)) {
|
|
tunnelLogger.error(`Tunnel config validation failed`, {
|
|
operation: "tunnel_connect",
|
|
tunnelName,
|
|
configHostId: tunnelConfig.sourceHostId,
|
|
configTunnelIndex: tunnelConfig.tunnelIndex,
|
|
});
|
|
return res.status(400).json({
|
|
error: "Tunnel configuration does not match tunnel name",
|
|
});
|
|
}
|
|
|
|
if (tunnelConfig.sourceHostId) {
|
|
const accessInfo = await permissionManager.canAccessHost(
|
|
userId,
|
|
tunnelConfig.sourceHostId,
|
|
"read",
|
|
);
|
|
|
|
if (!accessInfo.hasAccess) {
|
|
tunnelLogger.warn("User attempted tunnel connect without access", {
|
|
operation: "tunnel_connect_unauthorized",
|
|
userId,
|
|
hostId: tunnelConfig.sourceHostId,
|
|
tunnelName,
|
|
});
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Access denied to this host" });
|
|
}
|
|
}
|
|
|
|
if (pendingTunnelOperations.has(tunnelName)) {
|
|
try {
|
|
await pendingTunnelOperations.get(tunnelName);
|
|
} catch {
|
|
tunnelLogger.warn(`Previous tunnel operation failed`, {
|
|
tunnelName,
|
|
});
|
|
}
|
|
}
|
|
|
|
const operation = (async () => {
|
|
manualDisconnects.delete(tunnelName);
|
|
retryCounters.delete(tunnelName);
|
|
retryExhaustedTunnels.delete(tunnelName);
|
|
|
|
await cleanupTunnelResources(tunnelName);
|
|
|
|
if (tunnelConfigs.has(tunnelName)) {
|
|
const existingConfig = tunnelConfigs.get(tunnelName);
|
|
if (
|
|
existingConfig &&
|
|
(existingConfig.sourceHostId !== tunnelConfig.sourceHostId ||
|
|
existingConfig.tunnelIndex !== tunnelConfig.tunnelIndex)
|
|
) {
|
|
throw new Error(`Tunnel name collision detected: ${tunnelName}`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
!isSingleHostTunnel(tunnelConfig) &&
|
|
(!tunnelConfig.endpointIP || !tunnelConfig.endpointUsername)
|
|
) {
|
|
try {
|
|
const systemCrypto = SystemCrypto.getInstance();
|
|
const internalAuthToken =
|
|
await systemCrypto.getInternalAuthToken();
|
|
|
|
const allHostsResponse = await axios.get(
|
|
"http://localhost:30001/host/db/host/internal/all",
|
|
{
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Internal-Auth-Token": internalAuthToken,
|
|
},
|
|
},
|
|
);
|
|
|
|
const allHosts: SSHHost[] = allHostsResponse.data || [];
|
|
const endpointHost = findHostByTunnelEndpoint(
|
|
allHosts,
|
|
tunnelConfig.endpointHost,
|
|
);
|
|
|
|
if (!endpointHost) {
|
|
if (getTunnelMode(tunnelConfig) !== "remote") {
|
|
tunnelConfig.endpointIP =
|
|
tunnelConfig.endpointIP || tunnelConfig.endpointHost;
|
|
} else {
|
|
throw new Error(
|
|
`Endpoint host '${tunnelConfig.endpointHost}' not found in database`,
|
|
);
|
|
}
|
|
} else {
|
|
if (!endpointHost.id) {
|
|
throw new Error("Endpoint host not found");
|
|
}
|
|
|
|
const endpointAccess = await permissionManager.canAccessHost(
|
|
userId,
|
|
endpointHost.id,
|
|
"read",
|
|
);
|
|
if (!endpointAccess.hasAccess) {
|
|
tunnelLogger.warn(
|
|
"User attempted tunnel connect without endpoint access",
|
|
{
|
|
operation: "tunnel_connect_endpoint_unauthorized",
|
|
userId,
|
|
hostId: endpointHost.id,
|
|
tunnelName,
|
|
},
|
|
);
|
|
throw new Error("Endpoint host not found");
|
|
}
|
|
|
|
tunnelConfig.endpointIP = endpointHost.ip;
|
|
tunnelConfig.endpointSSHPort = endpointHost.port;
|
|
tunnelConfig.endpointUsername = endpointHost.username;
|
|
tunnelConfig.endpointAuthMethod = endpointHost.authType;
|
|
tunnelConfig.endpointKeyType = endpointHost.keyType;
|
|
tunnelConfig.endpointCredentialId =
|
|
endpointHost.userId === userId
|
|
? endpointHost.credentialId
|
|
: undefined;
|
|
tunnelConfig.endpointUserId = userId;
|
|
|
|
// Resolve credentials server-side instead of from HTTP response
|
|
if (endpointHost.id) {
|
|
try {
|
|
const { resolveHostById } =
|
|
await import("../host-resolver.js");
|
|
const resolved = await resolveHostById(
|
|
endpointHost.id,
|
|
userId,
|
|
);
|
|
if (resolved) {
|
|
tunnelConfig.endpointPassword = resolved.password;
|
|
tunnelConfig.endpointSSHKey = resolved.key;
|
|
tunnelConfig.endpointKeyPassword = resolved.keyPassword;
|
|
}
|
|
} catch (credError) {
|
|
tunnelLogger.warn(
|
|
"Failed to resolve endpoint credentials from DB",
|
|
{
|
|
operation: "tunnel_endpoint_credential_resolve",
|
|
endpointHostId: endpointHost.id,
|
|
error:
|
|
credError instanceof Error
|
|
? credError.message
|
|
: "Unknown",
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} catch (resolveError) {
|
|
tunnelLogger.error(
|
|
"Failed to resolve endpoint host",
|
|
resolveError,
|
|
{
|
|
operation: "tunnel_connect_resolve_endpoint_failed",
|
|
tunnelName,
|
|
endpointHost: tunnelConfig.endpointHost,
|
|
},
|
|
);
|
|
throw new Error(
|
|
`Failed to resolve endpoint host: ${resolveError instanceof Error ? resolveError.message : "Unknown error"}`,
|
|
{ cause: resolveError },
|
|
);
|
|
}
|
|
}
|
|
|
|
tunnelConfigs.set(tunnelName, tunnelConfig);
|
|
await connectSSHTunnel(tunnelConfig, 0);
|
|
})();
|
|
|
|
pendingTunnelOperations.set(tunnelName, operation);
|
|
|
|
res.json({ message: "Connection request received", tunnelName });
|
|
|
|
operation
|
|
.catch((err) => {
|
|
tunnelLogger.error("Tunnel operation failed", err, {
|
|
operation: "tunnel_operation_failed",
|
|
tunnelName,
|
|
});
|
|
broadcastTunnelStatus(tunnelName, {
|
|
connected: false,
|
|
status: CONNECTION_STATES.FAILED,
|
|
reason: err instanceof Error ? err.message : "Unknown error",
|
|
});
|
|
tunnelConnecting.delete(tunnelName);
|
|
})
|
|
.finally(() => {
|
|
pendingTunnelOperations.delete(tunnelName);
|
|
});
|
|
} catch (error) {
|
|
tunnelLogger.error("Failed to process tunnel connect", error, {
|
|
operation: "tunnel_connect",
|
|
tunnelName,
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to connect tunnel" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ssh/tunnel/disconnect:
|
|
* post:
|
|
* summary: Disconnect SSH tunnel
|
|
* description: Disconnects an active SSH tunnel.
|
|
* tags:
|
|
* - SSH Tunnels
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* tunnelName:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Disconnect request received.
|
|
* 400:
|
|
* description: Tunnel name required.
|
|
* 401:
|
|
* description: Authentication required.
|
|
* 403:
|
|
* description: Access denied.
|
|
* 500:
|
|
* description: Failed to disconnect tunnel.
|
|
*/
|
|
app.post(
|
|
"/ssh/tunnel/disconnect",
|
|
authenticateJWT,
|
|
async (req: AuthenticatedRequest, res: Response) => {
|
|
const { tunnelName } = req.body;
|
|
const userId = req.userId;
|
|
|
|
if (!userId) {
|
|
return res.status(401).json({ error: "Authentication required" });
|
|
}
|
|
|
|
if (!tunnelName) {
|
|
return res.status(400).json({ error: "Tunnel name required" });
|
|
}
|
|
|
|
try {
|
|
const config = tunnelConfigs.get(tunnelName);
|
|
if (config && config.sourceHostId) {
|
|
const accessInfo = await permissionManager.canAccessHost(
|
|
userId,
|
|
config.sourceHostId,
|
|
"read",
|
|
);
|
|
if (!accessInfo.hasAccess) {
|
|
return res.status(403).json({ error: "Access denied" });
|
|
}
|
|
}
|
|
|
|
tunnelLogger.info("Tunnel stop request received", {
|
|
operation: "tunnel_stop_request",
|
|
userId,
|
|
hostId: config?.sourceHostId,
|
|
tunnelName,
|
|
});
|
|
manualDisconnects.add(tunnelName);
|
|
retryCounters.delete(tunnelName);
|
|
retryExhaustedTunnels.delete(tunnelName);
|
|
|
|
if (activeRetryTimers.has(tunnelName)) {
|
|
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
|
activeRetryTimers.delete(tunnelName);
|
|
}
|
|
|
|
await cleanupTunnelResources(tunnelName, true);
|
|
tunnelLogger.info("Tunnel cleanup completed", {
|
|
operation: "tunnel_cleanup_complete",
|
|
userId,
|
|
hostId: config?.sourceHostId,
|
|
tunnelName,
|
|
});
|
|
|
|
broadcastTunnelStatus(tunnelName, {
|
|
connected: false,
|
|
status: CONNECTION_STATES.DISCONNECTED,
|
|
manualDisconnect: true,
|
|
});
|
|
|
|
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
|
handleDisconnect(tunnelName, tunnelConfig, false);
|
|
|
|
setTimeout(() => {
|
|
manualDisconnects.delete(tunnelName);
|
|
}, 5000);
|
|
|
|
res.json({ message: "Disconnect request received", tunnelName });
|
|
} catch (error) {
|
|
tunnelLogger.error("Failed to disconnect tunnel", error, {
|
|
operation: "tunnel_disconnect",
|
|
tunnelName,
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to disconnect tunnel" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /ssh/tunnel/cancel:
|
|
* post:
|
|
* summary: Cancel tunnel retry
|
|
* description: Cancels the retry mechanism for a failed SSH tunnel connection.
|
|
* tags:
|
|
* - SSH Tunnels
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* tunnelName:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Cancel request received.
|
|
* 400:
|
|
* description: Tunnel name required.
|
|
* 401:
|
|
* description: Authentication required.
|
|
* 403:
|
|
* description: Access denied.
|
|
* 500:
|
|
* description: Failed to cancel tunnel retry.
|
|
*/
|
|
app.post(
|
|
"/ssh/tunnel/cancel",
|
|
authenticateJWT,
|
|
async (req: AuthenticatedRequest, res: Response) => {
|
|
const { tunnelName } = req.body;
|
|
const userId = req.userId;
|
|
|
|
if (!userId) {
|
|
return res.status(401).json({ error: "Authentication required" });
|
|
}
|
|
|
|
if (!tunnelName) {
|
|
return res.status(400).json({ error: "Tunnel name required" });
|
|
}
|
|
|
|
try {
|
|
const config = tunnelConfigs.get(tunnelName);
|
|
if (config && config.sourceHostId) {
|
|
const accessInfo = await permissionManager.canAccessHost(
|
|
userId,
|
|
config.sourceHostId,
|
|
"read",
|
|
);
|
|
if (!accessInfo.hasAccess) {
|
|
return res.status(403).json({ error: "Access denied" });
|
|
}
|
|
}
|
|
|
|
retryCounters.delete(tunnelName);
|
|
retryExhaustedTunnels.delete(tunnelName);
|
|
|
|
if (activeRetryTimers.has(tunnelName)) {
|
|
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
|
activeRetryTimers.delete(tunnelName);
|
|
}
|
|
|
|
if (countdownIntervals.has(tunnelName)) {
|
|
clearInterval(countdownIntervals.get(tunnelName)!);
|
|
countdownIntervals.delete(tunnelName);
|
|
}
|
|
|
|
await cleanupTunnelResources(tunnelName, true);
|
|
|
|
broadcastTunnelStatus(tunnelName, {
|
|
connected: false,
|
|
status: CONNECTION_STATES.DISCONNECTED,
|
|
manualDisconnect: true,
|
|
});
|
|
|
|
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
|
handleDisconnect(tunnelName, tunnelConfig, false);
|
|
|
|
setTimeout(() => {
|
|
manualDisconnects.delete(tunnelName);
|
|
}, 5000);
|
|
|
|
res.json({ message: "Cancel request received", tunnelName });
|
|
} catch (error) {
|
|
tunnelLogger.error("Failed to cancel tunnel retry", error, {
|
|
operation: "tunnel_cancel",
|
|
tunnelName,
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to cancel tunnel retry" });
|
|
}
|
|
},
|
|
);
|
|
}
|