v1.10.0 #471
@@ -1,4 +1,4 @@
|
|||||||
import express from "express";
|
import express, { type Response } from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import { Client } from "ssh2";
|
import { Client } from "ssh2";
|
||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
TunnelStatus,
|
TunnelStatus,
|
||||||
VerificationData,
|
VerificationData,
|
||||||
ErrorType,
|
ErrorType,
|
||||||
|
AuthenticatedRequest,
|
||||||
} from "../../types/index.js";
|
} from "../../types/index.js";
|
||||||
import { CONNECTION_STATES } from "../../types/index.js";
|
import { CONNECTION_STATES } from "../../types/index.js";
|
||||||
import { tunnelLogger, sshLogger } from "../utils/logger.js";
|
import { tunnelLogger, sshLogger } from "../utils/logger.js";
|
||||||
@@ -20,6 +21,8 @@ import { SystemCrypto } from "../utils/system-crypto.js";
|
|||||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||||
import { DataCrypto } from "../utils/data-crypto.js";
|
import { DataCrypto } from "../utils/data-crypto.js";
|
||||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||||
|
import { AuthManager } from "../utils/auth-manager.js";
|
||||||
|
import { PermissionManager } from "../utils/permission-manager.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(
|
app.use(
|
||||||
@@ -64,6 +67,10 @@ app.use(
|
|||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
const authManager = AuthManager.getInstance();
|
||||||
|
const permissionManager = PermissionManager.getInstance();
|
||||||
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
|
|
||||||
const activeTunnels = new Map<string, Client>();
|
const activeTunnels = new Map<string, Client>();
|
||||||
const retryCounters = new Map<string, number>();
|
const retryCounters = new Map<string, number>();
|
||||||
const connectionStatus = new Map<string, TunnelStatus>();
|
const connectionStatus = new Map<string, TunnelStatus>();
|
||||||
@@ -78,6 +85,7 @@ const tunnelConnecting = new Set<string>();
|
|||||||
|
|
||||||
const tunnelConfigs = new Map<string, TunnelConfig>();
|
const tunnelConfigs = new Map<string, TunnelConfig>();
|
||||||
const activeTunnelProcesses = new Map<string, ChildProcess>();
|
const activeTunnelProcesses = new Map<string, ChildProcess>();
|
||||||
|
const pendingTunnelOperations = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
|
function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
|
||||||
if (
|
if (
|
||||||
@@ -155,10 +163,75 @@ function getTunnelMarker(tunnelName: string) {
|
|||||||
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupTunnelResources(
|
function normalizeTunnelName(
|
||||||
|
hostId: number,
|
||||||
|
tunnelIndex: number,
|
||||||
|
displayName: string,
|
||||||
|
sourcePort: number,
|
||||||
|
endpointHost: string,
|
||||||
|
endpointPort: number,
|
||||||
|
): string {
|
||||||
|
return `${hostId}::${tunnelIndex}::${displayName}::${sourcePort}::${endpointHost}::${endpointPort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTunnelName(tunnelName: string): {
|
||||||
|
hostId?: number;
|
||||||
|
tunnelIndex?: number;
|
||||||
|
displayName: string;
|
||||||
|
sourcePort: string;
|
||||||
|
endpointHost: string;
|
||||||
|
endpointPort: string;
|
||||||
|
isLegacyFormat: boolean;
|
||||||
|
} {
|
||||||
|
const parts = tunnelName.split("::");
|
||||||
|
|
||||||
|
if (parts.length === 6) {
|
||||||
|
return {
|
||||||
|
hostId: parseInt(parts[0]),
|
||||||
|
tunnelIndex: parseInt(parts[1]),
|
||||||
|
displayName: parts[2],
|
||||||
|
sourcePort: parts[3],
|
||||||
|
endpointHost: parts[4],
|
||||||
|
endpointPort: parts[5],
|
||||||
|
isLegacyFormat: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelLogger.warn(`Legacy tunnel name format: ${tunnelName}`);
|
||||||
|
|
||||||
|
const legacyParts = tunnelName.split("_");
|
||||||
|
return {
|
||||||
|
displayName: legacyParts[0] || "unknown",
|
||||||
|
sourcePort: legacyParts[legacyParts.length - 3] || "0",
|
||||||
|
endpointHost: legacyParts[legacyParts.length - 2] || "unknown",
|
||||||
|
endpointPort: legacyParts[legacyParts.length - 1] || "0",
|
||||||
|
isLegacyFormat: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTunnelConfig(
|
||||||
|
tunnelName: string,
|
||||||
|
tunnelConfig: TunnelConfig,
|
||||||
|
): boolean {
|
||||||
|
const parsed = parseTunnelName(tunnelName);
|
||||||
|
|
||||||
|
if (parsed.isLegacyFormat) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
parsed.hostId === tunnelConfig.sourceHostId &&
|
||||||
|
parsed.tunnelIndex === tunnelConfig.tunnelIndex &&
|
||||||
|
String(parsed.sourcePort) === String(tunnelConfig.sourcePort) &&
|
||||||
|
parsed.endpointHost === tunnelConfig.endpointHost &&
|
||||||
|
String(parsed.endpointPort) === String(tunnelConfig.endpointPort)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTunnelResources(
|
||||||
tunnelName: string,
|
tunnelName: string,
|
||||||
forceCleanup = false,
|
forceCleanup = false,
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (cleanupInProgress.has(tunnelName)) {
|
if (cleanupInProgress.has(tunnelName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,13 +244,16 @@ function cleanupTunnelResources(
|
|||||||
|
|
||||||
const tunnelConfig = tunnelConfigs.get(tunnelName);
|
const tunnelConfig = tunnelConfigs.get(tunnelName);
|
||||||
if (tunnelConfig) {
|
if (tunnelConfig) {
|
||||||
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
|
await new Promise<void>((resolve) => {
|
||||||
cleanupInProgress.delete(tunnelName);
|
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
|
||||||
if (err) {
|
cleanupInProgress.delete(tunnelName);
|
||||||
tunnelLogger.error(
|
if (err) {
|
||||||
`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`,
|
tunnelLogger.error(
|
||||||
);
|
`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`,
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
cleanupInProgress.delete(tunnelName);
|
cleanupInProgress.delete(tunnelName);
|
||||||
@@ -491,38 +567,81 @@ async function connectSSHTunnel(
|
|||||||
authMethod: tunnelConfig.sourceAuthMethod,
|
authMethod: tunnelConfig.sourceAuthMethod,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) {
|
const effectiveUserId =
|
||||||
try {
|
tunnelConfig.requestingUserId || tunnelConfig.sourceUserId;
|
||||||
const userDataKey = DataCrypto.getUserDataKey(tunnelConfig.sourceUserId);
|
|
||||||
if (userDataKey) {
|
|
||||||
const credentials = await SimpleDBOps.select(
|
|
||||||
getDb()
|
|
||||||
.select()
|
|
||||||
.from(sshCredentials)
|
|
||||||
.where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)),
|
|
||||||
"ssh_credentials",
|
|
||||||
tunnelConfig.sourceUserId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (credentials.length > 0) {
|
if (tunnelConfig.sourceCredentialId && effectiveUserId) {
|
||||||
const credential = credentials[0];
|
try {
|
||||||
resolvedSourceCredentials = {
|
if (
|
||||||
password: credential.password as string | undefined,
|
tunnelConfig.requestingUserId &&
|
||||||
sshKey: (credential.private_key ||
|
tunnelConfig.requestingUserId !== tunnelConfig.sourceUserId
|
||||||
credential.privateKey ||
|
) {
|
||||||
credential.key) as string | undefined,
|
const { SharedCredentialManager } =
|
||||||
keyPassword: (credential.key_password || credential.keyPassword) as
|
await import("../utils/shared-credential-manager.js");
|
||||||
| string
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||||
| undefined,
|
|
||||||
keyType: (credential.key_type || credential.keyType) as
|
if (tunnelConfig.sourceHostId) {
|
||||||
| string
|
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||||
| undefined,
|
tunnelConfig.sourceHostId,
|
||||||
authMethod: (credential.auth_type || credential.authType) as string,
|
tunnelConfig.requestingUserId,
|
||||||
};
|
);
|
||||||
|
|
||||||
|
if (sharedCred) {
|
||||||
|
resolvedSourceCredentials = {
|
||||||
|
password: sharedCred.password,
|
||||||
|
sshKey: sharedCred.key,
|
||||||
|
keyPassword: sharedCred.keyPassword,
|
||||||
|
keyType: sharedCred.keyType,
|
||||||
|
authMethod: sharedCred.authType,
|
||||||
|
};
|
||||||
|
tunnelLogger.info("Resolved shared credentials for tunnel source", {
|
||||||
|
operation: "tunnel_connect_shared_cred",
|
||||||
|
tunnelName,
|
||||||
|
userId: effectiveUserId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const errorMessage = `Cannot connect tunnel '${tunnelName}': shared credentials not available`;
|
||||||
|
tunnelLogger.error(errorMessage);
|
||||||
|
broadcastTunnelStatus(tunnelName, {
|
||||||
|
connected: false,
|
||||||
|
status: CONNECTION_STATES.FAILED,
|
||||||
|
reason: errorMessage,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const userDataKey = DataCrypto.getUserDataKey(effectiveUserId);
|
||||||
|
if (userDataKey) {
|
||||||
|
const credentials = await SimpleDBOps.select(
|
||||||
|
getDb()
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)),
|
||||||
|
"ssh_credentials",
|
||||||
|
effectiveUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (credentials.length > 0) {
|
||||||
|
const credential = credentials[0];
|
||||||
|
resolvedSourceCredentials = {
|
||||||
|
password: credential.password as string | undefined,
|
||||||
|
sshKey: (credential.private_key ||
|
||||||
|
credential.privateKey ||
|
||||||
|
credential.key) as string | undefined,
|
||||||
|
keyPassword: (credential.key_password ||
|
||||||
|
credential.keyPassword) as string | undefined,
|
||||||
|
keyType: (credential.key_type || credential.keyType) as
|
||||||
|
| string
|
||||||
|
| undefined,
|
||||||
|
authMethod: (credential.auth_type ||
|
||||||
|
credential.authType) as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
tunnelLogger.warn("Failed to resolve source credentials from database", {
|
tunnelLogger.warn("Failed to resolve source credentials", {
|
||||||
operation: "tunnel_connect",
|
operation: "tunnel_connect",
|
||||||
tunnelName,
|
tunnelName,
|
||||||
credentialId: tunnelConfig.sourceCredentialId,
|
credentialId: tunnelConfig.sourceCredentialId,
|
||||||
@@ -1349,103 +1468,312 @@ app.get("/ssh/tunnel/status/:tunnelName", (req, res) => {
|
|||||||
res.json({ name: tunnelName, status });
|
res.json({ name: tunnelName, status });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/ssh/tunnel/connect", (req, res) => {
|
app.post(
|
||||||
const tunnelConfig: TunnelConfig = req.body;
|
"/ssh/tunnel/connect",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const tunnelConfig: TunnelConfig = req.body;
|
||||||
|
const userId = req.userId;
|
||||||
|
|
||||||
if (!tunnelConfig || !tunnelConfig.name) {
|
if (!userId) {
|
||||||
return res.status(400).json({ error: "Invalid tunnel configuration" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tunnelName = tunnelConfig.name;
|
if (!tunnelConfig || !tunnelConfig.name) {
|
||||||
|
return res.status(400).json({ error: "Invalid tunnel configuration" });
|
||||||
|
}
|
||||||
|
|
||||||
cleanupTunnelResources(tunnelName);
|
const tunnelName = tunnelConfig.name;
|
||||||
|
|
||||||
manualDisconnects.delete(tunnelName);
|
try {
|
||||||
retryCounters.delete(tunnelName);
|
if (!validateTunnelConfig(tunnelName, tunnelConfig)) {
|
||||||
retryExhaustedTunnels.delete(tunnelName);
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
tunnelConfigs.set(tunnelName, tunnelConfig);
|
if (tunnelConfig.sourceHostId) {
|
||||||
|
const accessInfo = await permissionManager.canAccessHost(
|
||||||
|
userId,
|
||||||
|
tunnelConfig.sourceHostId,
|
||||||
|
"read",
|
||||||
|
);
|
||||||
|
|
||||||
connectSSHTunnel(tunnelConfig, 0).catch((error) => {
|
if (!accessInfo.hasAccess) {
|
||||||
tunnelLogger.error(
|
tunnelLogger.warn("User attempted tunnel connect without access", {
|
||||||
`Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
operation: "tunnel_connect_unauthorized",
|
||||||
);
|
userId,
|
||||||
});
|
hostId: tunnelConfig.sourceHostId,
|
||||||
|
tunnelName,
|
||||||
|
});
|
||||||
|
return res.status(403).json({ error: "Access denied to this host" });
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ message: "Connection request received", tunnelName });
|
if (accessInfo.isShared && !accessInfo.isOwner) {
|
||||||
});
|
tunnelConfig.requestingUserId = userId;
|
||||||
|
tunnelLogger.info("Shared host tunnel connect", {
|
||||||
|
operation: "tunnel_connect_shared",
|
||||||
|
userId,
|
||||||
|
hostId: tunnelConfig.sourceHostId,
|
||||||
|
tunnelName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.post("/ssh/tunnel/disconnect", (req, res) => {
|
if (pendingTunnelOperations.has(tunnelName)) {
|
||||||
const { tunnelName } = req.body;
|
try {
|
||||||
|
await pendingTunnelOperations.get(tunnelName);
|
||||||
|
} catch (error) {
|
||||||
|
tunnelLogger.warn(`Previous tunnel operation failed`, { tunnelName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!tunnelName) {
|
const operation = (async () => {
|
||||||
return res.status(400).json({ error: "Tunnel name required" });
|
manualDisconnects.delete(tunnelName);
|
||||||
}
|
retryCounters.delete(tunnelName);
|
||||||
|
retryExhaustedTunnels.delete(tunnelName);
|
||||||
|
|
||||||
manualDisconnects.add(tunnelName);
|
await cleanupTunnelResources(tunnelName);
|
||||||
retryCounters.delete(tunnelName);
|
|
||||||
retryExhaustedTunnels.delete(tunnelName);
|
|
||||||
|
|
||||||
if (activeRetryTimers.has(tunnelName)) {
|
if (tunnelConfigs.has(tunnelName)) {
|
||||||
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
const existingConfig = tunnelConfigs.get(tunnelName);
|
||||||
activeRetryTimers.delete(tunnelName);
|
if (
|
||||||
}
|
existingConfig &&
|
||||||
|
(existingConfig.sourceHostId !== tunnelConfig.sourceHostId ||
|
||||||
|
existingConfig.tunnelIndex !== tunnelConfig.tunnelIndex)
|
||||||
|
) {
|
||||||
|
throw new Error(`Tunnel name collision detected: ${tunnelName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cleanupTunnelResources(tunnelName, true);
|
// If endpoint details are missing, resolve them from database
|
||||||
|
if (!tunnelConfig.endpointIP || !tunnelConfig.endpointUsername) {
|
||||||
|
tunnelLogger.info("Resolving endpoint host details from database", {
|
||||||
|
operation: "tunnel_connect_resolve_endpoint",
|
||||||
|
tunnelName,
|
||||||
|
endpointHost: tunnelConfig.endpointHost,
|
||||||
|
});
|
||||||
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
try {
|
||||||
connected: false,
|
const systemCrypto = SystemCrypto.getInstance();
|
||||||
status: CONNECTION_STATES.DISCONNECTED,
|
const internalAuthToken = await systemCrypto.getInternalAuthToken();
|
||||||
manualDisconnect: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
const allHostsResponse = await axios.get(
|
||||||
handleDisconnect(tunnelName, tunnelConfig, false);
|
"http://localhost:30001/ssh/db/host/internal/all",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Internal-Auth-Token": internalAuthToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
setTimeout(() => {
|
const allHosts: SSHHost[] = allHostsResponse.data || [];
|
||||||
manualDisconnects.delete(tunnelName);
|
const endpointHost = allHosts.find(
|
||||||
}, 5000);
|
(h) =>
|
||||||
|
h.name === tunnelConfig.endpointHost ||
|
||||||
|
`${h.username}@${h.ip}` === tunnelConfig.endpointHost,
|
||||||
|
);
|
||||||
|
|
||||||
res.json({ message: "Disconnect request received", tunnelName });
|
if (!endpointHost) {
|
||||||
});
|
throw new Error(
|
||||||
|
`Endpoint host '${tunnelConfig.endpointHost}' not found in database`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
app.post("/ssh/tunnel/cancel", (req, res) => {
|
// Populate endpoint fields
|
||||||
const { tunnelName } = req.body;
|
tunnelConfig.endpointIP = endpointHost.ip;
|
||||||
|
tunnelConfig.endpointSSHPort = endpointHost.port;
|
||||||
|
tunnelConfig.endpointUsername = endpointHost.username;
|
||||||
|
tunnelConfig.endpointPassword = endpointHost.password;
|
||||||
|
tunnelConfig.endpointAuthMethod = endpointHost.authType;
|
||||||
|
tunnelConfig.endpointSSHKey = endpointHost.key;
|
||||||
|
tunnelConfig.endpointKeyPassword = endpointHost.keyPassword;
|
||||||
|
tunnelConfig.endpointKeyType = endpointHost.keyType;
|
||||||
|
tunnelConfig.endpointCredentialId = endpointHost.credentialId;
|
||||||
|
tunnelConfig.endpointUserId = endpointHost.userId;
|
||||||
|
|
||||||
if (!tunnelName) {
|
tunnelLogger.info("Endpoint host details resolved", {
|
||||||
return res.status(400).json({ error: "Tunnel name required" });
|
operation: "tunnel_connect_endpoint_resolved",
|
||||||
}
|
tunnelName,
|
||||||
|
endpointIP: tunnelConfig.endpointIP,
|
||||||
|
endpointUsername: tunnelConfig.endpointUsername,
|
||||||
|
});
|
||||||
|
} 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"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
retryCounters.delete(tunnelName);
|
tunnelConfigs.set(tunnelName, tunnelConfig);
|
||||||
retryExhaustedTunnels.delete(tunnelName);
|
await connectSSHTunnel(tunnelConfig, 0);
|
||||||
|
})();
|
||||||
|
|
||||||
if (activeRetryTimers.has(tunnelName)) {
|
pendingTunnelOperations.set(tunnelName, operation);
|
||||||
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
|
||||||
activeRetryTimers.delete(tunnelName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (countdownIntervals.has(tunnelName)) {
|
res.json({ message: "Connection request received", tunnelName });
|
||||||
clearInterval(countdownIntervals.get(tunnelName)!);
|
|
||||||
countdownIntervals.delete(tunnelName);
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupTunnelResources(tunnelName, true);
|
operation.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" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
app.post(
|
||||||
connected: false,
|
"/ssh/tunnel/disconnect",
|
||||||
status: CONNECTION_STATES.DISCONNECTED,
|
authenticateJWT,
|
||||||
manualDisconnect: true,
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
});
|
const { tunnelName } = req.body;
|
||||||
|
const userId = req.userId;
|
||||||
|
|
||||||
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
if (!userId) {
|
||||||
handleDisconnect(tunnelName, tunnelConfig, false);
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
if (!tunnelName) {
|
||||||
manualDisconnects.delete(tunnelName);
|
return res.status(400).json({ error: "Tunnel name required" });
|
||||||
}, 5000);
|
}
|
||||||
|
|
||||||
res.json({ message: "Cancel request received", tunnelName });
|
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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manualDisconnects.add(tunnelName);
|
||||||
|
retryCounters.delete(tunnelName);
|
||||||
|
retryExhaustedTunnels.delete(tunnelName);
|
||||||
|
|
||||||
|
if (activeRetryTimers.has(tunnelName)) {
|
||||||
|
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
||||||
|
activeRetryTimers.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: "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" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
async function initializeAutoStartTunnels(): Promise<void> {
|
async function initializeAutoStartTunnels(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -1491,12 +1819,19 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (endpointHost) {
|
if (endpointHost) {
|
||||||
|
const tunnelIndex =
|
||||||
|
host.tunnelConnections.indexOf(tunnelConnection);
|
||||||
const tunnelConfig: TunnelConfig = {
|
const tunnelConfig: TunnelConfig = {
|
||||||
name: `${host.name || `${host.username}@${host.ip}`}_${
|
name: normalizeTunnelName(
|
||||||
tunnelConnection.sourcePort
|
host.id,
|
||||||
}_${tunnelConnection.endpointHost}_${
|
tunnelIndex,
|
||||||
tunnelConnection.endpointPort
|
host.name || `${host.username}@${host.ip}`,
|
||||||
}`,
|
tunnelConnection.sourcePort,
|
||||||
|
tunnelConnection.endpointHost,
|
||||||
|
tunnelConnection.endpointPort,
|
||||||
|
),
|
||||||
|
sourceHostId: host.id,
|
||||||
|
tunnelIndex: tunnelIndex,
|
||||||
hostName: host.name || `${host.username}@${host.ip}`,
|
hostName: host.name || `${host.username}@${host.ip}`,
|
||||||
sourceIP: host.ip,
|
sourceIP: host.ip,
|
||||||
sourceSSHPort: host.port,
|
sourceSSHPort: host.port,
|
||||||
@@ -1512,6 +1847,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
endpointIP: endpointHost.ip,
|
endpointIP: endpointHost.ip,
|
||||||
endpointSSHPort: endpointHost.port,
|
endpointSSHPort: endpointHost.port,
|
||||||
endpointUsername: endpointHost.username,
|
endpointUsername: endpointHost.username,
|
||||||
|
endpointHost: tunnelConnection.endpointHost,
|
||||||
endpointPassword:
|
endpointPassword:
|
||||||
tunnelConnection.endpointPassword ||
|
tunnelConnection.endpointPassword ||
|
||||||
endpointHost.autostartPassword ||
|
endpointHost.autostartPassword ||
|
||||||
|
|||||||
@@ -212,6 +212,14 @@ export interface TunnelConnection {
|
|||||||
|
|
||||||
export interface TunnelConfig {
|
export interface TunnelConfig {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
// Unique identifiers for collision prevention
|
||||||
|
sourceHostId: number;
|
||||||
|
tunnelIndex: number;
|
||||||
|
|
||||||
|
// User context for RBAC
|
||||||
|
requestingUserId?: string;
|
||||||
|
|
||||||
hostName: string;
|
hostName: string;
|
||||||
sourceIP: string;
|
sourceIP: string;
|
||||||
sourceSSHPort: number;
|
sourceSSHPort: number;
|
||||||
@@ -226,6 +234,7 @@ export interface TunnelConfig {
|
|||||||
endpointIP: string;
|
endpointIP: string;
|
||||||
endpointSSHPort: number;
|
endpointSSHPort: number;
|
||||||
endpointUsername: string;
|
endpointUsername: string;
|
||||||
|
endpointHost: string;
|
||||||
endpointPassword?: string;
|
endpointPassword?: string;
|
||||||
endpointAuthMethod: string;
|
endpointAuthMethod: string;
|
||||||
endpointSSHKey?: string;
|
endpointSSHKey?: string;
|
||||||
|
|||||||
@@ -126,26 +126,25 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
|||||||
tunnelIndex: number,
|
tunnelIndex: number,
|
||||||
) => {
|
) => {
|
||||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${
|
const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||||
tunnel.sourcePort
|
|
||||||
}_${tunnel.endpointHost}_${tunnel.endpointPort}`;
|
|
||||||
|
|
||||||
setTunnelActions((prev) => ({ ...prev, [tunnelName]: true }));
|
setTunnelActions((prev) => ({ ...prev, [tunnelName]: true }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (action === "connect") {
|
if (action === "connect") {
|
||||||
|
// Try to find endpoint host in user's accessible hosts
|
||||||
const endpointHost = allHosts.find(
|
const endpointHost = allHosts.find(
|
||||||
(h) =>
|
(h) =>
|
||||||
h.name === tunnel.endpointHost ||
|
h.name === tunnel.endpointHost ||
|
||||||
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!endpointHost) {
|
// For shared users who don't have access to endpoint host,
|
||||||
throw new Error(t("tunnels.endpointHostNotFound"));
|
// send a minimal config and let backend resolve endpoint details
|
||||||
}
|
|
||||||
|
|
||||||
const tunnelConfig = {
|
const tunnelConfig = {
|
||||||
name: tunnelName,
|
name: tunnelName,
|
||||||
|
sourceHostId: host.id,
|
||||||
|
tunnelIndex: tunnelIndex,
|
||||||
hostName: host.name || `${host.username}@${host.ip}`,
|
hostName: host.name || `${host.username}@${host.ip}`,
|
||||||
sourceIP: host.ip,
|
sourceIP: host.ip,
|
||||||
sourceSSHPort: host.port,
|
sourceSSHPort: host.port,
|
||||||
@@ -159,24 +158,25 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
|||||||
sourceKeyType: host.authType === "key" ? host.keyType : undefined,
|
sourceKeyType: host.authType === "key" ? host.keyType : undefined,
|
||||||
sourceCredentialId: host.credentialId,
|
sourceCredentialId: host.credentialId,
|
||||||
sourceUserId: host.userId,
|
sourceUserId: host.userId,
|
||||||
endpointIP: endpointHost.ip,
|
endpointHost: tunnel.endpointHost,
|
||||||
endpointSSHPort: endpointHost.port,
|
endpointIP: endpointHost?.ip,
|
||||||
endpointUsername: endpointHost.username,
|
endpointSSHPort: endpointHost?.port,
|
||||||
|
endpointUsername: endpointHost?.username,
|
||||||
endpointPassword:
|
endpointPassword:
|
||||||
endpointHost.authType === "password"
|
endpointHost?.authType === "password"
|
||||||
? endpointHost.password
|
? endpointHost.password
|
||||||
: undefined,
|
: undefined,
|
||||||
endpointAuthMethod: endpointHost.authType,
|
endpointAuthMethod: endpointHost?.authType,
|
||||||
endpointSSHKey:
|
endpointSSHKey:
|
||||||
endpointHost.authType === "key" ? endpointHost.key : undefined,
|
endpointHost?.authType === "key" ? endpointHost.key : undefined,
|
||||||
endpointKeyPassword:
|
endpointKeyPassword:
|
||||||
endpointHost.authType === "key"
|
endpointHost?.authType === "key"
|
||||||
? endpointHost.keyPassword
|
? endpointHost.keyPassword
|
||||||
: undefined,
|
: undefined,
|
||||||
endpointKeyType:
|
endpointKeyType:
|
||||||
endpointHost.authType === "key" ? endpointHost.keyType : undefined,
|
endpointHost?.authType === "key" ? endpointHost.keyType : undefined,
|
||||||
endpointCredentialId: endpointHost.credentialId,
|
endpointCredentialId: endpointHost?.credentialId,
|
||||||
endpointUserId: endpointHost.userId,
|
endpointUserId: endpointHost?.userId,
|
||||||
sourcePort: tunnel.sourcePort,
|
sourcePort: tunnel.sourcePort,
|
||||||
endpointPort: tunnel.endpointPort,
|
endpointPort: tunnel.endpointPort,
|
||||||
maxRetries: tunnel.maxRetries,
|
maxRetries: tunnel.maxRetries,
|
||||||
@@ -191,6 +191,19 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
|||||||
socks5ProxyChain: host.socks5ProxyChain,
|
socks5ProxyChain: host.socks5ProxyChain,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("Tunnel connect config:", {
|
||||||
|
tunnelName,
|
||||||
|
sourceHostId: tunnelConfig.sourceHostId,
|
||||||
|
sourceCredentialId: tunnelConfig.sourceCredentialId,
|
||||||
|
sourceUserId: tunnelConfig.sourceUserId,
|
||||||
|
hasSourcePassword: !!tunnelConfig.sourcePassword,
|
||||||
|
hasSourceKey: !!tunnelConfig.sourceSSHKey,
|
||||||
|
hasEndpointHost: !!endpointHost,
|
||||||
|
endpointHost: tunnel.endpointHost,
|
||||||
|
isShared: (host as any).isShared,
|
||||||
|
ownerId: (host as any).ownerId,
|
||||||
|
});
|
||||||
|
|
||||||
await connectTunnel(tunnelConfig);
|
await connectTunnel(tunnelConfig);
|
||||||
} else if (action === "disconnect") {
|
} else if (action === "disconnect") {
|
||||||
await disconnectTunnel(tunnelName);
|
await disconnectTunnel(tunnelName);
|
||||||
@@ -199,7 +212,15 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await fetchTunnelStatuses();
|
await fetchTunnelStatuses();
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error("Tunnel action failed:", {
|
||||||
|
action,
|
||||||
|
tunnelName,
|
||||||
|
hostId: host.id,
|
||||||
|
tunnelIndex,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
fullError: error,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
|
setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ export function TunnelObject({
|
|||||||
|
|
||||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
||||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${
|
const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||||
tunnel.sourcePort
|
|
||||||
}_${tunnel.endpointHost}_${tunnel.endpointPort}`;
|
|
||||||
return tunnelStatuses[tunnelName];
|
return tunnelStatuses[tunnelName];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,9 +119,7 @@ export function TunnelObject({
|
|||||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||||
const status = getTunnelStatus(tunnelIndex);
|
const status = getTunnelStatus(tunnelIndex);
|
||||||
const statusDisplay = getTunnelStatusDisplay(status);
|
const statusDisplay = getTunnelStatusDisplay(status);
|
||||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${
|
const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||||
tunnel.sourcePort
|
|
||||||
}_${tunnel.endpointHost}_${tunnel.endpointPort}`;
|
|
||||||
const isActionLoading = tunnelActions[tunnelName];
|
const isActionLoading = tunnelActions[tunnelName];
|
||||||
const statusValue =
|
const statusValue =
|
||||||
status?.status?.toUpperCase() || "DISCONNECTED";
|
status?.status?.toUpperCase() || "DISCONNECTED";
|
||||||
@@ -356,9 +352,7 @@ export function TunnelObject({
|
|||||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||||
const status = getTunnelStatus(tunnelIndex);
|
const status = getTunnelStatus(tunnelIndex);
|
||||||
const statusDisplay = getTunnelStatusDisplay(status);
|
const statusDisplay = getTunnelStatusDisplay(status);
|
||||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${
|
const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||||
tunnel.sourcePort
|
|
||||||
}_${tunnel.endpointHost}_${tunnel.endpointPort}`;
|
|
||||||
const isActionLoading = tunnelActions[tunnelName];
|
const isActionLoading = tunnelActions[tunnelName];
|
||||||
const statusValue =
|
const statusValue =
|
||||||
status?.status?.toUpperCase() || "DISCONNECTED";
|
status?.status?.toUpperCase() || "DISCONNECTED";
|
||||||
|
|||||||
@@ -7,10 +7,20 @@ import {
|
|||||||
} from "@/components/ui/form.tsx";
|
} from "@/components/ui/form.tsx";
|
||||||
import { Switch } from "@/components/ui/switch.tsx";
|
import { Switch } from "@/components/ui/switch.tsx";
|
||||||
import type { HostDockerTabProps } from "./shared/tab-types";
|
import type { HostDockerTabProps } from "./shared/tab-types";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
export function HostDockerTab({ control, t }: HostDockerTabProps) {
|
export function HostDockerTab({ control, t }: HostDockerTabProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-3 text-xs"
|
||||||
|
onClick={() => window.open("https://docs.termix.site/docker", "_blank")}
|
||||||
|
>
|
||||||
|
{t("common.documentation")}
|
||||||
|
</Button>
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={control}
|
||||||
name="enableDocker"
|
name="enableDocker"
|
||||||
|
|||||||
Reference in New Issue
Block a user