FIX: Implement comprehensive autostart tunnel system with credential automation

This commit completely resolves the autostart tunnel functionality issues by:

**Core Autostart System**:
- Fixed internal API to return explicit autostart fields to tunnel service
- Implemented automatic endpoint credential resolution during autostart enable
- Enhanced database synchronization with force save and verification
- Added comprehensive debugging and logging throughout the process

**Tunnel Connection Improvements**:
- Enhanced credential resolution with priority: TunnelConnection → autostart → encrypted
- Fixed SSH command format with proper tunnel markers and exec process naming
- Added connection state protection to prevent premature cleanup during establishment
- Implemented sequential kill strategies for reliable remote process cleanup

**Type System Extensions**:
- Extended TunnelConnection interface with endpoint credential fields
- Added autostart credential fields to SSHHost interface for plaintext storage
- Maintained backward compatibility with existing encrypted credential system

**Key Technical Fixes**:
- Database API now includes /db/host/internal/all endpoint with SystemCrypto auth
- Autostart enable automatically populates endpoint credentials from target hosts
- Tunnel cleanup uses multiple kill strategies with verification and delay timing
- Connection protection prevents cleanup interference during tunnel establishment

Users can now enable fully automated tunneling by simply checking the autostart
checkbox - no manual credential configuration required. The system automatically
resolves and stores plaintext credentials for unattended tunnel operation.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZacharyZcR
2025-09-24 07:30:21 +08:00
parent ece6ec0892
commit fc6acbb81f
3 changed files with 584 additions and 41 deletions

View File

@@ -78,6 +78,21 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
) )
); );
console.log("=== AUTOSTART QUERY DEBUG ===");
console.log("Found autostart hosts count:", autostartHosts.length);
autostartHosts.forEach((host, index) => {
console.log(`Host ${index + 1}:`, {
id: host.id,
ip: host.ip,
username: host.username,
hasAutostartPassword: !!host.autostartPassword,
hasAutostartKey: !!host.autostartKey,
autostartPasswordLength: host.autostartPassword?.length || 0,
autostartKeyLength: host.autostartKey?.length || 0
});
});
console.log("=== END AUTOSTART QUERY DEBUG ===");
sshLogger.info("Internal autostart endpoint accessed", { sshLogger.info("Internal autostart endpoint accessed", {
operation: "autostart_internal_access", operation: "autostart_internal_access",
configCount: autostartHosts.length, configCount: autostartHosts.length,
@@ -91,6 +106,20 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
? JSON.parse(host.tunnelConnections) ? JSON.parse(host.tunnelConnections)
: []; : [];
// Debug: Log what we're reading from database
sshLogger.info(`Autostart host from DB:`, {
hostId: host.id,
ip: host.ip,
username: host.username,
hasAutostartPassword: !!host.autostartPassword,
hasAutostartKey: !!host.autostartKey,
hasEncryptedPassword: !!host.password,
hasEncryptedKey: !!host.key,
authType: host.authType,
autostartPasswordLength: host.autostartPassword?.length || 0,
autostartKeyLength: host.autostartKey?.length || 0,
});
return { return {
id: host.id, id: host.id,
userId: host.userId, userId: host.userId,
@@ -101,6 +130,10 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
password: host.autostartPassword, password: host.autostartPassword,
key: host.autostartKey, key: host.autostartKey,
keyPassword: host.autostartKeyPassword, keyPassword: host.autostartKeyPassword,
// Include explicit autostart fields for tunnel service
autostartPassword: host.autostartPassword,
autostartKey: host.autostartKey,
autostartKeyPassword: host.autostartKeyPassword,
authType: host.authType, authType: host.authType,
enableTunnel: true, enableTunnel: true,
tunnelConnections: tunnelConnections.filter((tunnel: any) => tunnel.autoStart), tunnelConnections: tunnelConnections.filter((tunnel: any) => tunnel.autoStart),
@@ -118,6 +151,89 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
} }
}); });
// Internal-only endpoint for all hosts - requires internal auth token (for tunnel endpointHost resolution)
router.get("/db/host/internal/all", async (req: Request, res: Response) => {
try {
// Check for internal authentication token using SystemCrypto
const internalToken = req.headers["x-internal-auth-token"];
if (!internalToken) {
return res.status(401).json({ error: "Internal authentication token required" });
}
const systemCrypto = SystemCrypto.getInstance();
const expectedToken = await systemCrypto.getInternalAuthToken();
if (internalToken !== expectedToken) {
return res.status(401).json({ error: "Invalid internal authentication token" });
}
// Query all hosts for endpointHost resolution
const allHosts = await db.select().from(sshData);
sshLogger.info("Internal all hosts endpoint accessed", {
operation: "all_hosts_internal_access",
hostCount: allHosts.length,
source: req.ip,
userAgent: req.headers["user-agent"]
});
// Transform to expected format for tunnel service
const result = allHosts.map((host) => {
const tunnelConnections = host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [];
// Debug: Log what we're reading from database for all hosts
sshLogger.info(`All hosts endpoint - host from DB:`, {
hostId: host.id,
ip: host.ip,
username: host.username,
hasAutostartPassword: !!host.autostartPassword,
hasAutostartKey: !!host.autostartKey,
hasEncryptedPassword: !!host.password,
hasEncryptedKey: !!host.key,
authType: host.authType,
autostartPasswordLength: host.autostartPassword?.length || 0,
autostartKeyLength: host.autostartKey?.length || 0,
encryptedPasswordLength: host.password?.length || 0,
encryptedKeyLength: host.key?.length || 0,
});
return {
id: host.id,
userId: host.userId,
name: host.name || `${host.username}@${host.ip}`,
ip: host.ip,
port: host.port,
username: host.username,
password: host.autostartPassword || host.password,
key: host.autostartKey || host.key,
keyPassword: host.autostartKeyPassword || host.keyPassword,
// Include autostart fields for fallback
autostartPassword: host.autostartPassword,
autostartKey: host.autostartKey,
autostartKeyPassword: host.autostartKeyPassword,
authType: host.authType,
keyType: host.keyType,
credentialId: host.credentialId,
enableTunnel: !!host.enableTunnel,
tunnelConnections: tunnelConnections,
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableFileManager: !!host.enableFileManager,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
};
});
res.json(result);
} catch (err) {
sshLogger.error("Failed to fetch all hosts for internal use", err);
res.status(500).json({ error: "Failed to fetch all hosts" });
}
});
// Route: Create SSH data (requires JWT) // Route: Create SSH data (requires JWT)
// POST /ssh/host // POST /ssh/host
router.post( router.post(
@@ -1362,15 +1478,115 @@ router.post(
// Decrypt sensitive fields // Decrypt sensitive fields
const decryptedConfig = DataCrypto.decryptRecord("ssh_data", config, userId, userDataKey); const decryptedConfig = DataCrypto.decryptRecord("ssh_data", config, userId, userDataKey);
// Update the SSH config with plaintext autostart fields // Debug: Log what we're about to save
await db.update(sshData) console.log("=== AUTOSTART DEBUG: Decrypted credentials ===");
console.log("sshConfigId:", sshConfigId);
console.log("authType:", config.authType);
console.log("hasPassword:", !!decryptedConfig.password);
console.log("hasKey:", !!decryptedConfig.key);
console.log("hasKeyPassword:", !!decryptedConfig.keyPassword);
console.log("passwordLength:", decryptedConfig.password?.length || 0);
console.log("keyLength:", decryptedConfig.key?.length || 0);
console.log("=== END AUTOSTART DEBUG ===");
// Also handle tunnel connections - populate endpoint credentials
let updatedTunnelConnections = config.tunnelConnections;
if (config.tunnelConnections) {
try {
const tunnelConnections = JSON.parse(config.tunnelConnections);
// For each tunnel connection, try to resolve endpoint credentials
const resolvedConnections = await Promise.all(
tunnelConnections.map(async (tunnel: any) => {
if (tunnel.autoStart && tunnel.endpointHost && !tunnel.endpointPassword && !tunnel.endpointKey) {
console.log("=== RESOLVING ENDPOINT CREDENTIALS ===");
console.log("endpointHost:", tunnel.endpointHost);
// Find endpoint host by name or username@ip
const endpointHosts = await db.select()
.from(sshData)
.where(eq(sshData.userId, userId));
const endpointHost = endpointHosts.find(h =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost
);
if (endpointHost) {
console.log("Found endpoint host:", endpointHost.id, endpointHost.ip);
// Decrypt endpoint host credentials
const decryptedEndpoint = DataCrypto.decryptRecord("ssh_data", endpointHost, userId, userDataKey);
console.log("Endpoint credentials:", {
hasPassword: !!decryptedEndpoint.password,
hasKey: !!decryptedEndpoint.key,
passwordLength: decryptedEndpoint.password?.length || 0
});
// Add endpoint credentials to tunnel connection
return {
...tunnel,
endpointPassword: decryptedEndpoint.password || null,
endpointKey: decryptedEndpoint.key || null,
endpointKeyPassword: decryptedEndpoint.keyPassword || null,
endpointAuthType: endpointHost.authType
};
}
}
return tunnel;
})
);
updatedTunnelConnections = JSON.stringify(resolvedConnections);
console.log("=== UPDATED TUNNEL CONNECTIONS ===");
} catch (error) {
console.log("=== TUNNEL CONNECTION UPDATE FAILED ===", error);
}
}
// Update the SSH config with plaintext autostart fields and resolved tunnel connections
const updateResult = await db.update(sshData)
.set({ .set({
autostartPassword: decryptedConfig.password || null, autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null, autostartKey: decryptedConfig.key || null,
autostartKeyPassword: decryptedConfig.keyPassword || null, autostartKeyPassword: decryptedConfig.keyPassword || null,
tunnelConnections: updatedTunnelConnections,
}) })
.where(eq(sshData.id, sshConfigId)); .where(eq(sshData.id, sshConfigId));
// Debug: Log update result
console.log("=== AUTOSTART DEBUG: Update result ===");
console.log("updateResult:", updateResult);
console.log("update completed for sshConfigId:", sshConfigId);
console.log("=== END UPDATE DEBUG ===");
// Force database save after autostart update
try {
await DatabaseSaveTrigger.triggerSave();
console.log("=== DATABASE SAVE TRIGGERED AFTER AUTOSTART ===");
} catch (saveError) {
console.log("=== DATABASE SAVE FAILED ===", saveError);
}
// Verify the data was actually saved
try {
const verifyQuery = await db.select()
.from(sshData)
.where(eq(sshData.id, sshConfigId));
if (verifyQuery.length > 0) {
const saved = verifyQuery[0];
console.log("=== VERIFICATION: Data actually saved ===");
console.log("autostartPassword exists:", !!saved.autostartPassword);
console.log("autostartKey exists:", !!saved.autostartKey);
console.log("autostartPassword length:", saved.autostartPassword?.length || 0);
console.log("=== END VERIFICATION ===");
}
} catch (verifyError) {
console.log("=== VERIFICATION FAILED ===", verifyError);
}
sshLogger.success("AutoStart enabled successfully", { sshLogger.success("AutoStart enabled successfully", {
operation: "autostart_enabled", operation: "autostart_enabled",
userId, userId,

View File

@@ -44,6 +44,8 @@ const verificationTimers = new Map<string, NodeJS.Timeout>();
const activeRetryTimers = new Map<string, NodeJS.Timeout>(); const activeRetryTimers = new Map<string, NodeJS.Timeout>();
const countdownIntervals = new Map<string, NodeJS.Timeout>(); const countdownIntervals = new Map<string, NodeJS.Timeout>();
const retryExhaustedTunnels = new Set<string>(); const retryExhaustedTunnels = new Set<string>();
const cleanupInProgress = new Set<string>();
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>();
@@ -124,16 +126,37 @@ 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(tunnelName: string): void { function cleanupTunnelResources(tunnelName: string, forceCleanup = false): void {
tunnelLogger.info(`Cleaning up resources for tunnel '${tunnelName}' (force=${forceCleanup})`);
// Prevent concurrent cleanup operations
if (cleanupInProgress.has(tunnelName)) {
tunnelLogger.info(`Cleanup already in progress for '${tunnelName}', skipping`);
return;
}
// Protect connecting tunnels unless forced
if (!forceCleanup && tunnelConnecting.has(tunnelName)) {
tunnelLogger.info(`Tunnel '${tunnelName}' is connecting, skipping cleanup (use force=true to override)`);
return;
}
cleanupInProgress.add(tunnelName);
const tunnelConfig = tunnelConfigs.get(tunnelName); const tunnelConfig = tunnelConfigs.get(tunnelName);
if (tunnelConfig) { if (tunnelConfig) {
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => { killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
cleanupInProgress.delete(tunnelName);
if (err) { if (err) {
tunnelLogger.error( tunnelLogger.error(
`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`, `Failed to kill remote tunnel for '${tunnelName}': ${err.message}`,
); );
} else {
tunnelLogger.info(`Successfully cleaned up remote tunnel processes for '${tunnelName}'`);
} }
}); });
} else {
cleanupInProgress.delete(tunnelName);
} }
if (activeTunnelProcesses.has(tunnelName)) { if (activeTunnelProcesses.has(tunnelName)) {
@@ -155,6 +178,7 @@ function cleanupTunnelResources(tunnelName: string): void {
try { try {
const conn = activeTunnels.get(tunnelName); const conn = activeTunnels.get(tunnelName);
if (conn) { if (conn) {
tunnelLogger.info(`Closing SSH2 connection for tunnel '${tunnelName}'`);
conn.end(); conn.end();
} }
} catch (e) { } catch (e) {
@@ -164,6 +188,7 @@ function cleanupTunnelResources(tunnelName: string): void {
); );
} }
activeTunnels.delete(tunnelName); activeTunnels.delete(tunnelName);
tunnelLogger.info(`Removed tunnel '${tunnelName}' from activeTunnels`);
} }
if (tunnelVerifications.has(tunnelName)) { if (tunnelVerifications.has(tunnelName)) {
@@ -204,6 +229,8 @@ function cleanupTunnelResources(tunnelName: string): void {
function resetRetryState(tunnelName: string): void { function resetRetryState(tunnelName: string): void {
retryCounters.delete(tunnelName); retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName);
cleanupInProgress.delete(tunnelName);
tunnelConnecting.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) { if (activeRetryTimers.has(tunnelName)) {
clearTimeout(activeRetryTimers.get(tunnelName)!); clearTimeout(activeRetryTimers.get(tunnelName)!);
@@ -395,7 +422,11 @@ async function connectSSHTunnel(
return; return;
} }
cleanupTunnelResources(tunnelName); // Mark tunnel as connecting to protect from cleanup
tunnelConnecting.add(tunnelName);
// Force cleanup any existing resources before new connection
cleanupTunnelResources(tunnelName, true);
if (retryAttempt === 0) { if (retryAttempt === 0) {
retryExhaustedTunnels.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName);
@@ -486,6 +517,32 @@ async function connectSSHTunnel(
authMethod: tunnelConfig.endpointAuthMethod, authMethod: tunnelConfig.endpointAuthMethod,
}; };
tunnelLogger.info(`Source credentials for '${tunnelName}': authMethod=${resolvedSourceCredentials.authMethod}, hasPassword=${!!resolvedSourceCredentials.password}, hasSSHKey=${!!resolvedSourceCredentials.sshKey}`);
tunnelLogger.info(`Final endpoint credentials for '${tunnelName}': authMethod=${resolvedEndpointCredentials.authMethod}, hasPassword=${!!resolvedEndpointCredentials.password}, hasSSHKey=${!!resolvedEndpointCredentials.sshKey}, credentialId=${tunnelConfig.endpointCredentialId}`);
// Validate that we have usable endpoint credentials
if (resolvedEndpointCredentials.authMethod === "password" && !resolvedEndpointCredentials.password) {
const errorMessage = `Cannot connect tunnel '${tunnelName}': endpoint host requires password authentication but no plaintext password available. Enable autostart for endpoint host or configure credentials in tunnel connection.`;
tunnelLogger.error(errorMessage);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: errorMessage,
});
return;
}
if (resolvedEndpointCredentials.authMethod === "key" && !resolvedEndpointCredentials.sshKey) {
const errorMessage = `Cannot connect tunnel '${tunnelName}': endpoint host requires key authentication but no plaintext key available. Enable autostart for endpoint host or configure credentials in tunnel connection.`;
tunnelLogger.error(errorMessage);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: errorMessage,
});
return;
}
if (tunnelConfig.endpointCredentialId && tunnelConfig.endpointUserId) { if (tunnelConfig.endpointCredentialId && tunnelConfig.endpointUserId) {
try { try {
const credentials = await getDb() const credentials = await getDb()
@@ -507,6 +564,7 @@ async function connectSSHTunnel(
keyType: credential.keyType, keyType: credential.keyType,
authMethod: credential.authType, authMethod: credential.authType,
}; };
tunnelLogger.info(`Resolved endpoint credentials from DB for '${tunnelName}': authMethod=${resolvedEndpointCredentials.authMethod}, hasPassword=${!!resolvedEndpointCredentials.password}, hasSSHKey=${!!resolvedEndpointCredentials.sshKey}`);
} else { } else {
tunnelLogger.warn("No endpoint credentials found in database", { tunnelLogger.warn("No endpoint credentials found in database", {
operation: "tunnel_connect", operation: "tunnel_connect",
@@ -556,6 +614,9 @@ async function connectSSHTunnel(
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`); tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`);
// Clear connecting state on error
tunnelConnecting.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) { if (activeRetryTimers.has(tunnelName)) {
return; return;
} }
@@ -584,6 +645,9 @@ async function connectSSHTunnel(
conn.on("close", () => { conn.on("close", () => {
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
// Clear connecting state on close
tunnelConnecting.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) { if (activeRetryTimers.has(tunnelName)) {
return; return;
} }
@@ -621,11 +685,13 @@ async function connectSSHTunnel(
resolvedEndpointCredentials.sshKey resolvedEndpointCredentials.sshKey
) { ) {
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`; const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`; tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && exec -a "${tunnelMarker}" ssh -i ${keyFilePath} -v -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} && rm -f ${keyFilePath}`;
} else { } else {
tunnelCmd = `sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`; tunnelCmd = `exec -a "${tunnelMarker}" sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -v -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
} }
tunnelLogger.info(`Executing tunnel command for '${tunnelName}': ${tunnelCmd.replace(/sshpass -p '[^']*'/g, 'sshpass -p [HIDDEN]').replace(/echo '[^']*'/g, 'echo [HIDDEN]')}`);
conn.exec(tunnelCmd, (err, stream) => { conn.exec(tunnelCmd, (err, stream) => {
if (err) { if (err) {
tunnelLogger.error( tunnelLogger.error(
@@ -652,6 +718,9 @@ async function connectSSHTunnel(
!manualDisconnects.has(tunnelName) && !manualDisconnects.has(tunnelName) &&
activeTunnels.has(tunnelName) activeTunnels.has(tunnelName)
) { ) {
// Clear connecting state on successful connection
tunnelConnecting.delete(tunnelName);
broadcastTunnelStatus(tunnelName, { broadcastTunnelStatus(tunnelName, {
connected: true, connected: true,
status: CONNECTION_STATES.CONNECTED, status: CONNECTION_STATES.CONNECTED,
@@ -723,12 +792,52 @@ async function connectSSHTunnel(
} }
}); });
stream.stdout?.on("data", (data: Buffer) => {}); stream.stdout?.on("data", (data: Buffer) => {
const output = data.toString().trim();
if (output) {
tunnelLogger.info(`SSH stdout for '${tunnelName}': ${output}`);
}
});
stream.on("error", (err: Error) => {}); stream.on("error", (err: Error) => {});
stream.stderr.on("data", (data) => { stream.stderr.on("data", (data) => {
const errorMsg = data.toString().trim(); const errorMsg = data.toString().trim();
if (errorMsg) {
tunnelLogger.error(`SSH stderr for '${tunnelName}': ${errorMsg}`);
// Check for specific SSH errors
if (errorMsg.includes("sshpass: command not found") || errorMsg.includes("sshpass not found")) {
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: "sshpass tool not found on source host. Please install sshpass or use SSH key authentication.",
});
}
// Check for port forwarding errors
if (errorMsg.includes("remote port forwarding failed") || errorMsg.includes("Error: remote port forwarding failed")) {
const portMatch = errorMsg.match(/listen port (\d+)/);
const port = portMatch ? portMatch[1] : tunnelConfig.endpointPort;
tunnelLogger.error(`Port forwarding failed for tunnel '${tunnelName}' on port ${port}. This prevents tunnel establishment.`);
// Close the connection immediately to prevent retries
if (activeTunnels.has(tunnelName)) {
const conn = activeTunnels.get(tunnelName);
if (conn) {
conn.end();
}
activeTunnels.delete(tunnelName);
}
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: `Remote port forwarding failed for port ${port}. Port may be in use, requires root privileges, or SSH server doesn't allow port forwarding. Try a different port.`,
});
}
}
}); });
}); });
}); });
@@ -828,12 +937,54 @@ async function connectSSHTunnel(
conn.connect(connOptions); conn.connect(connOptions);
} }
function killRemoteTunnelByMarker( async function killRemoteTunnelByMarker(
tunnelConfig: TunnelConfig, tunnelConfig: TunnelConfig,
tunnelName: string, tunnelName: string,
callback: (err?: Error) => void, callback: (err?: Error) => void,
) { ) {
const tunnelMarker = getTunnelMarker(tunnelName); const tunnelMarker = getTunnelMarker(tunnelName);
tunnelLogger.info(`Attempting to kill remote tunnel processes with marker '${tunnelMarker}' on source host ${tunnelConfig.sourceIP}`);
// Resolve source credentials using same logic as main tunnel connection
let resolvedSourceCredentials = {
password: tunnelConfig.sourcePassword,
sshKey: tunnelConfig.sourceSSHKey,
keyPassword: tunnelConfig.sourceKeyPassword,
keyType: tunnelConfig.sourceKeyType,
authMethod: tunnelConfig.sourceAuthMethod,
};
if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) {
try {
const credentials = await getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, tunnelConfig.sourceCredentialId),
eq(sshCredentials.userId, tunnelConfig.sourceUserId),
),
);
if (credentials.length > 0) {
const credential = credentials[0];
resolvedSourceCredentials = {
password: credential.password,
sshKey: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authMethod: credential.authType,
};
}
} catch (error) {
tunnelLogger.warn("Failed to resolve source credentials for cleanup", {
tunnelName,
credentialId: tunnelConfig.sourceCredentialId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
const conn = new Client(); const conn = new Client();
const connOptions: any = { const connOptions: any = {
host: tunnelConfig.sourceIP, host: tunnelConfig.sourceIP,
@@ -870,48 +1021,138 @@ function killRemoteTunnelByMarker(
compress: ["none", "zlib@openssh.com", "zlib"], compress: ["none", "zlib@openssh.com", "zlib"],
}, },
}; };
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
if (
resolvedSourceCredentials.authMethod === "key" &&
resolvedSourceCredentials.sshKey
) {
if ( if (
!tunnelConfig.sourceSSHKey.includes("-----BEGIN") || !resolvedSourceCredentials.sshKey.includes("-----BEGIN") ||
!tunnelConfig.sourceSSHKey.includes("-----END") !resolvedSourceCredentials.sshKey.includes("-----END")
) { ) {
callback(new Error("Invalid SSH key format")); callback(new Error("Invalid SSH key format"));
return; return;
} }
const cleanKey = tunnelConfig.sourceSSHKey const cleanKey = resolvedSourceCredentials.sshKey
.trim() .trim()
.replace(/\r\n/g, "\n") .replace(/\r\n/g, "\n")
.replace(/\r/g, "\n"); .replace(/\r/g, "\n");
connOptions.privateKey = Buffer.from(cleanKey, "utf8"); connOptions.privateKey = Buffer.from(cleanKey, "utf8");
if (tunnelConfig.sourceKeyPassword) { if (resolvedSourceCredentials.keyPassword) {
connOptions.passphrase = tunnelConfig.sourceKeyPassword; connOptions.passphrase = resolvedSourceCredentials.keyPassword;
} }
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== "auto") { if (
connOptions.privateKeyType = tunnelConfig.sourceKeyType; resolvedSourceCredentials.keyType &&
resolvedSourceCredentials.keyType !== "auto"
) {
connOptions.privateKeyType = resolvedSourceCredentials.keyType;
} }
} else { } else {
connOptions.password = tunnelConfig.sourcePassword; connOptions.password = resolvedSourceCredentials.password;
} }
conn.on("ready", () => { conn.on("ready", () => {
const killCmd = `pkill -f '${tunnelMarker}'`; // First, check for existing processes and get their PIDs
conn.exec(killCmd, (err, stream) => { const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*-R.*${tunnelConfig.endpointPort})' | grep -v grep`;
if (err) {
conn.end(); conn.exec(checkCmd, (err, stream) => {
callback(err); let foundProcesses = false;
return;
} stream.on("data", (data) => {
stream.on("close", () => { const output = data.toString().trim();
conn.end(); if (output) {
callback(); foundProcesses = true;
tunnelLogger.info(`Found running tunnel processes for '${tunnelName}': ${output}`);
}
});
stream.on("close", () => {
if (!foundProcesses) {
tunnelLogger.info(`No running tunnel processes found for '${tunnelName}', cleanup not needed`);
conn.end();
callback();
return;
}
// Execute kill commands sequentially for better control
const killCmds = [
`pkill -TERM -f '${tunnelMarker}'`,
`sleep 1 && pkill -f 'ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
`sleep 1 && pkill -f 'sshpass.*ssh.*-R.*${tunnelConfig.endpointPort}'`,
`sleep 2 && pkill -9 -f '${tunnelMarker}'`, // Force kill after delay
];
let commandIndex = 0;
function executeNextKillCommand() {
if (commandIndex >= killCmds.length) {
// Final verification
conn.exec(checkCmd, (err, verifyStream) => {
let stillRunning = false;
verifyStream.on("data", (data) => {
const output = data.toString().trim();
if (output) {
stillRunning = true;
tunnelLogger.warn(`Processes still running after cleanup for '${tunnelName}': ${output}`);
}
});
verifyStream.on("close", () => {
if (!stillRunning) {
tunnelLogger.info(`All tunnel processes successfully terminated for '${tunnelName}'`);
} else {
tunnelLogger.warn(`Some tunnel processes may still be running for '${tunnelName}'`);
}
conn.end();
callback();
});
});
return;
}
const killCmd = killCmds[commandIndex];
conn.exec(killCmd, (err, stream) => {
if (err) {
tunnelLogger.warn(`Kill command ${commandIndex + 1} failed for '${tunnelName}': ${err.message}`);
} else {
tunnelLogger.info(`Executed kill command ${commandIndex + 1} for '${tunnelName}': ${killCmd.replace(/sleep \d+ && /, '')}`);
}
stream.on("close", (code) => {
tunnelLogger.info(`Kill command ${commandIndex + 1} completed with code ${code} for '${tunnelName}'`);
commandIndex++;
executeNextKillCommand();
});
stream.on("data", (data) => {
const output = data.toString().trim();
if (output) {
tunnelLogger.info(`Kill command ${commandIndex + 1} output for '${tunnelName}': ${output}`);
}
});
stream.stderr.on("data", (data) => {
const output = data.toString().trim();
if (output && !output.includes("debug1")) {
tunnelLogger.warn(`Kill command ${commandIndex + 1} stderr for '${tunnelName}': ${output}`);
}
});
});
}
executeNextKillCommand();
}); });
stream.on("data", () => {});
stream.stderr.on("data", () => {});
}); });
}); });
conn.on("error", (err) => { conn.on("error", (err) => {
tunnelLogger.error(`Failed to connect to source host for killing tunnel '${tunnelName}': ${err.message}`);
callback(err); callback(err);
}); });
conn.connect(connOptions); conn.connect(connOptions);
} }
@@ -939,6 +1180,10 @@ app.post("/ssh/tunnel/connect", (req, res) => {
const tunnelName = tunnelConfig.name; const tunnelName = tunnelConfig.name;
// Clean up any existing resources before starting new connection
tunnelLogger.info(`Starting new connection for '${tunnelName}', cleaning up any existing resources`);
cleanupTunnelResources(tunnelName);
manualDisconnects.delete(tunnelName); manualDisconnects.delete(tunnelName);
retryCounters.delete(tunnelName); retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName);
@@ -970,6 +1215,10 @@ app.post("/ssh/tunnel/disconnect", (req, res) => {
activeRetryTimers.delete(tunnelName); activeRetryTimers.delete(tunnelName);
} }
// Immediately clean up active connections (force cleanup)
tunnelLogger.info(`Manual disconnect requested for '${tunnelName}', cleaning up resources`);
cleanupTunnelResources(tunnelName, true);
broadcastTunnelStatus(tunnelName, { broadcastTunnelStatus(tunnelName, {
connected: false, connected: false,
status: CONNECTION_STATES.DISCONNECTED, status: CONNECTION_STATES.DISCONNECTED,
@@ -1006,6 +1255,10 @@ app.post("/ssh/tunnel/cancel", (req, res) => {
countdownIntervals.delete(tunnelName); countdownIntervals.delete(tunnelName);
} }
// Immediately clean up active connections for cancel operation too (force cleanup)
tunnelLogger.info(`Cancel requested for '${tunnelName}', cleaning up resources`);
cleanupTunnelResources(tunnelName, true);
broadcastTunnelStatus(tunnelName, { broadcastTunnelStatus(tunnelName, {
connected: false, connected: false,
status: CONNECTION_STATES.DISCONNECTED, status: CONNECTION_STATES.DISCONNECTED,
@@ -1028,7 +1281,8 @@ async function initializeAutoStartTunnels(): Promise<void> {
const systemCrypto = SystemCrypto.getInstance(); const systemCrypto = SystemCrypto.getInstance();
const internalAuthToken = await systemCrypto.getInternalAuthToken(); const internalAuthToken = await systemCrypto.getInternalAuthToken();
const response = await axios.get( // Get autostart hosts for tunnel configs
const autostartResponse = await axios.get(
"http://localhost:8081/ssh/db/host/internal", "http://localhost:8081/ssh/db/host/internal",
{ {
headers: { headers: {
@@ -1038,39 +1292,80 @@ async function initializeAutoStartTunnels(): Promise<void> {
}, },
); );
const hosts: SSHHost[] = response.data || []; // Get all hosts for endpointHost resolution
const allHostsResponse = await axios.get(
"http://localhost:8081/ssh/db/host/internal/all",
{
headers: {
"Content-Type": "application/json",
"X-Internal-Auth-Token": internalAuthToken,
},
},
);
const autostartHosts: SSHHost[] = autostartResponse.data || [];
const allHosts: SSHHost[] = allHostsResponse.data || [];
const autoStartTunnels: TunnelConfig[] = []; const autoStartTunnels: TunnelConfig[] = [];
for (const host of hosts) { tunnelLogger.info(`Found ${autostartHosts.length} autostart hosts and ${allHosts.length} total hosts for endpointHost resolution`);
for (const host of autostartHosts) {
if (host.enableTunnel && host.tunnelConnections) { if (host.enableTunnel && host.tunnelConnections) {
for (const tunnelConnection of host.tunnelConnections) { for (const tunnelConnection of host.tunnelConnections) {
if (tunnelConnection.autoStart) { if (tunnelConnection.autoStart) {
const endpointHost = hosts.find( const endpointHost = allHosts.find(
(h) => (h) =>
h.name === tunnelConnection.endpointHost || h.name === tunnelConnection.endpointHost ||
`${h.username}@${h.ip}` === tunnelConnection.endpointHost, `${h.username}@${h.ip}` === tunnelConnection.endpointHost,
); );
if (endpointHost) { if (endpointHost) {
tunnelLogger.info(`Setting up tunnel credentials for '${host.name || `${host.username}@${host.ip}`}' -> '${endpointHost.name || `${endpointHost.username}@${endpointHost.ip}`}': sourceAutostart=${!!host.autostartPassword}, endpointAutostart=${!!endpointHost.autostartPassword}, endpointEncrypted=${!!endpointHost.password}`);
// Debug: Log actual credential availability
tunnelLogger.info(`Source host credentials debug:`, {
hostId: host.id,
hasAutostartPassword: !!host.autostartPassword,
hasAutostartKey: !!host.autostartKey,
hasEncryptedPassword: !!host.password,
hasEncryptedKey: !!host.key,
authType: host.authType
});
tunnelLogger.info(`Endpoint host credentials debug:`, {
hostId: endpointHost.id,
hasAutostartPassword: !!endpointHost.autostartPassword,
hasAutostartKey: !!endpointHost.autostartKey,
hasEncryptedPassword: !!endpointHost.password,
hasEncryptedKey: !!endpointHost.key,
authType: endpointHost.authType
});
const tunnelConfig: TunnelConfig = { const tunnelConfig: TunnelConfig = {
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`, name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
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,
sourceUsername: host.username, sourceUsername: host.username,
sourcePassword: host.password, // Prefer autostart credentials for source host, fallback to encrypted credentials
sourcePassword: host.autostartPassword || host.password,
sourceAuthMethod: host.authType, sourceAuthMethod: host.authType,
sourceSSHKey: host.key, sourceSSHKey: host.autostartKey || host.key,
sourceKeyPassword: host.keyPassword, sourceKeyPassword: host.autostartKeyPassword || host.keyPassword,
sourceKeyType: host.keyType, sourceKeyType: host.keyType,
sourceCredentialId: host.credentialId,
sourceUserId: host.userId,
endpointIP: endpointHost.ip, endpointIP: endpointHost.ip,
endpointSSHPort: endpointHost.port, endpointSSHPort: endpointHost.port,
endpointUsername: endpointHost.username, endpointUsername: endpointHost.username,
endpointPassword: endpointHost.password, // Prefer TunnelConnection credentials, then autostart credentials, fallback to encrypted credentials
endpointAuthMethod: endpointHost.authType, endpointPassword: tunnelConnection.endpointPassword || endpointHost.autostartPassword || endpointHost.password,
endpointSSHKey: endpointHost.key, endpointAuthMethod: tunnelConnection.endpointAuthType || endpointHost.authType,
endpointKeyPassword: endpointHost.keyPassword, endpointSSHKey: tunnelConnection.endpointKey || endpointHost.autostartKey || endpointHost.key,
endpointKeyType: endpointHost.keyType, endpointKeyPassword: tunnelConnection.endpointKeyPassword || endpointHost.autostartKeyPassword || endpointHost.keyPassword,
endpointKeyType: tunnelConnection.endpointKeyType || endpointHost.keyType,
endpointCredentialId: endpointHost.credentialId,
endpointUserId: endpointHost.userId,
sourcePort: tunnelConnection.sourcePort, sourcePort: tunnelConnection.sourcePort,
endpointPort: tunnelConnection.endpointPort, endpointPort: tunnelConnection.endpointPort,
maxRetries: tunnelConnection.maxRetries, maxRetries: tunnelConnection.maxRetries,
@@ -1079,7 +1374,25 @@ async function initializeAutoStartTunnels(): Promise<void> {
isPinned: host.pin, isPinned: host.pin,
}; };
// Validate source and endpoint credentials availability
const hasSourcePassword = host.autostartPassword;
const hasSourceKey = host.autostartKey;
const hasEndpointPassword = tunnelConnection.endpointPassword || endpointHost.autostartPassword;
const hasEndpointKey = tunnelConnection.endpointKey || endpointHost.autostartKey;
if (!hasSourcePassword && !hasSourceKey) {
tunnelLogger.warn(`Tunnel '${tunnelConfig.name}' may fail: source host '${host.name || `${host.username}@${host.ip}`}' has no plaintext credentials. Enable autostart for this host to use unattended tunneling.`);
}
if (!hasEndpointPassword && !hasEndpointKey) {
tunnelLogger.warn(`Tunnel '${tunnelConfig.name}' may fail: endpoint host '${endpointHost.name || `${endpointHost.username}@${endpointHost.ip}`}' has no plaintext credentials. Consider enabling autostart for this host or configuring credentials in tunnel connection.`);
}
autoStartTunnels.push(tunnelConfig); autoStartTunnels.push(tunnelConfig);
} else {
tunnelLogger.error(
`Failed to find endpointHost '${tunnelConnection.endpointHost}' for tunnel from ${host.name || `${host.username}@${host.ip}`}. Available hosts: ${allHosts.map(h => h.name || `${h.username}@${h.ip}`).join(', ')}`,
);
} }
} }
} }

View File

@@ -24,6 +24,12 @@ export interface SSHHost {
key?: string; key?: string;
keyPassword?: string; keyPassword?: string;
keyType?: string; keyType?: string;
// Autostart plaintext credentials
autostartPassword?: string;
autostartKey?: string;
autostartKeyPassword?: string;
credentialId?: number; credentialId?: number;
userId?: string; userId?: string;
enableTerminal: boolean; enableTerminal: boolean;
@@ -101,6 +107,14 @@ export interface TunnelConnection {
sourcePort: number; sourcePort: number;
endpointPort: number; endpointPort: number;
endpointHost: string; endpointHost: string;
// Endpoint host credentials for tunnel authentication
endpointPassword?: string;
endpointKey?: string;
endpointKeyPassword?: string;
endpointAuthType?: string;
endpointKeyType?: string;
maxRetries: number; maxRetries: number;
retryInterval: number; retryInterval: number;
autoStart: boolean; autoStart: boolean;