mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
refactor(backend): reorganize top-level layout
- ssh/ renamed to hosts/ (it covers SSH, RDP, VNC, Telnet, Docker, metrics) - serial/serial.ts and guacamole/ moved inside hosts/ - dashboard.ts and homepage.ts moved to services/ - swagger.ts moved to utils/ with adjusted scan globs Import paths and the generate:openapi script updated; ports and endpoints unchanged.
This commit is contained in:
@@ -0,0 +1,596 @@
|
||||
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 "../tunnel-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" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user