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", {
operation: "autostart_internal_access",
configCount: autostartHosts.length,
@@ -91,6 +106,20 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
? 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 {
id: host.id,
userId: host.userId,
@@ -101,6 +130,10 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
password: host.autostartPassword,
key: host.autostartKey,
keyPassword: host.autostartKeyPassword,
// Include explicit autostart fields for tunnel service
autostartPassword: host.autostartPassword,
autostartKey: host.autostartKey,
autostartKeyPassword: host.autostartKeyPassword,
authType: host.authType,
enableTunnel: true,
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)
// POST /ssh/host
router.post(
@@ -1362,15 +1478,115 @@ router.post(
// Decrypt sensitive fields
const decryptedConfig = DataCrypto.decryptRecord("ssh_data", config, userId, userDataKey);
// Update the SSH config with plaintext autostart fields
await db.update(sshData)
// Debug: Log what we're about to save
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({
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
autostartKeyPassword: decryptedConfig.keyPassword || null,
tunnelConnections: updatedTunnelConnections,
})
.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", {
operation: "autostart_enabled",
userId,

View File

@@ -44,6 +44,8 @@ const verificationTimers = new Map<string, NodeJS.Timeout>();
const activeRetryTimers = new Map<string, NodeJS.Timeout>();
const countdownIntervals = new Map<string, NodeJS.Timeout>();
const retryExhaustedTunnels = new Set<string>();
const cleanupInProgress = new Set<string>();
const tunnelConnecting = new Set<string>();
const tunnelConfigs = new Map<string, TunnelConfig>();
const activeTunnelProcesses = new Map<string, ChildProcess>();
@@ -124,16 +126,37 @@ function getTunnelMarker(tunnelName: string) {
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);
if (tunnelConfig) {
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
cleanupInProgress.delete(tunnelName);
if (err) {
tunnelLogger.error(
`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)) {
@@ -155,6 +178,7 @@ function cleanupTunnelResources(tunnelName: string): void {
try {
const conn = activeTunnels.get(tunnelName);
if (conn) {
tunnelLogger.info(`Closing SSH2 connection for tunnel '${tunnelName}'`);
conn.end();
}
} catch (e) {
@@ -164,6 +188,7 @@ function cleanupTunnelResources(tunnelName: string): void {
);
}
activeTunnels.delete(tunnelName);
tunnelLogger.info(`Removed tunnel '${tunnelName}' from activeTunnels`);
}
if (tunnelVerifications.has(tunnelName)) {
@@ -204,6 +229,8 @@ function cleanupTunnelResources(tunnelName: string): void {
function resetRetryState(tunnelName: string): void {
retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName);
cleanupInProgress.delete(tunnelName);
tunnelConnecting.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) {
clearTimeout(activeRetryTimers.get(tunnelName)!);
@@ -395,7 +422,11 @@ async function connectSSHTunnel(
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) {
retryExhaustedTunnels.delete(tunnelName);
@@ -486,6 +517,32 @@ async function connectSSHTunnel(
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) {
try {
const credentials = await getDb()
@@ -507,6 +564,7 @@ async function connectSSHTunnel(
keyType: credential.keyType,
authMethod: credential.authType,
};
tunnelLogger.info(`Resolved endpoint credentials from DB for '${tunnelName}': authMethod=${resolvedEndpointCredentials.authMethod}, hasPassword=${!!resolvedEndpointCredentials.password}, hasSSHKey=${!!resolvedEndpointCredentials.sshKey}`);
} else {
tunnelLogger.warn("No endpoint credentials found in database", {
operation: "tunnel_connect",
@@ -556,6 +614,9 @@ async function connectSSHTunnel(
clearTimeout(connectionTimeout);
tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`);
// Clear connecting state on error
tunnelConnecting.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) {
return;
}
@@ -584,6 +645,9 @@ async function connectSSHTunnel(
conn.on("close", () => {
clearTimeout(connectionTimeout);
// Clear connecting state on close
tunnelConnecting.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) {
return;
}
@@ -621,11 +685,13 @@ async function connectSSHTunnel(
resolvedEndpointCredentials.sshKey
) {
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 {
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) => {
if (err) {
tunnelLogger.error(
@@ -652,6 +718,9 @@ async function connectSSHTunnel(
!manualDisconnects.has(tunnelName) &&
activeTunnels.has(tunnelName)
) {
// Clear connecting state on successful connection
tunnelConnecting.delete(tunnelName);
broadcastTunnelStatus(tunnelName, {
connected: true,
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.stderr.on("data", (data) => {
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);
}
function killRemoteTunnelByMarker(
async function killRemoteTunnelByMarker(
tunnelConfig: TunnelConfig,
tunnelName: string,
callback: (err?: Error) => void,
) {
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 connOptions: any = {
host: tunnelConfig.sourceIP,
@@ -870,48 +1021,138 @@ function killRemoteTunnelByMarker(
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
if (
resolvedSourceCredentials.authMethod === "key" &&
resolvedSourceCredentials.sshKey
) {
if (
!tunnelConfig.sourceSSHKey.includes("-----BEGIN") ||
!tunnelConfig.sourceSSHKey.includes("-----END")
!resolvedSourceCredentials.sshKey.includes("-----BEGIN") ||
!resolvedSourceCredentials.sshKey.includes("-----END")
) {
callback(new Error("Invalid SSH key format"));
return;
}
const cleanKey = tunnelConfig.sourceSSHKey
const cleanKey = resolvedSourceCredentials.sshKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
connOptions.privateKey = Buffer.from(cleanKey, "utf8");
if (tunnelConfig.sourceKeyPassword) {
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
if (resolvedSourceCredentials.keyPassword) {
connOptions.passphrase = resolvedSourceCredentials.keyPassword;
}
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== "auto") {
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
if (
resolvedSourceCredentials.keyType &&
resolvedSourceCredentials.keyType !== "auto"
) {
connOptions.privateKeyType = resolvedSourceCredentials.keyType;
}
} else {
connOptions.password = tunnelConfig.sourcePassword;
connOptions.password = resolvedSourceCredentials.password;
}
conn.on("ready", () => {
const killCmd = `pkill -f '${tunnelMarker}'`;
conn.exec(killCmd, (err, stream) => {
if (err) {
conn.end();
callback(err);
return;
}
stream.on("close", () => {
conn.end();
callback();
// First, check for existing processes and get their PIDs
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`;
conn.exec(checkCmd, (err, stream) => {
let foundProcesses = false;
stream.on("data", (data) => {
const output = data.toString().trim();
if (output) {
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) => {
tunnelLogger.error(`Failed to connect to source host for killing tunnel '${tunnelName}': ${err.message}`);
callback(err);
});
conn.connect(connOptions);
}
@@ -939,6 +1180,10 @@ app.post("/ssh/tunnel/connect", (req, res) => {
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);
retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName);
@@ -970,6 +1215,10 @@ app.post("/ssh/tunnel/disconnect", (req, res) => {
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, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
@@ -1006,6 +1255,10 @@ app.post("/ssh/tunnel/cancel", (req, res) => {
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, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
@@ -1028,7 +1281,8 @@ async function initializeAutoStartTunnels(): Promise<void> {
const systemCrypto = SystemCrypto.getInstance();
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",
{
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[] = [];
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) {
for (const tunnelConnection of host.tunnelConnections) {
if (tunnelConnection.autoStart) {
const endpointHost = hosts.find(
const endpointHost = allHosts.find(
(h) =>
h.name === tunnelConnection.endpointHost ||
`${h.username}@${h.ip}` === tunnelConnection.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 = {
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
sourcePassword: host.password,
// Prefer autostart credentials for source host, fallback to encrypted credentials
sourcePassword: host.autostartPassword || host.password,
sourceAuthMethod: host.authType,
sourceSSHKey: host.key,
sourceKeyPassword: host.keyPassword,
sourceSSHKey: host.autostartKey || host.key,
sourceKeyPassword: host.autostartKeyPassword || host.keyPassword,
sourceKeyType: host.keyType,
sourceCredentialId: host.credentialId,
sourceUserId: host.userId,
endpointIP: endpointHost.ip,
endpointSSHPort: endpointHost.port,
endpointUsername: endpointHost.username,
endpointPassword: endpointHost.password,
endpointAuthMethod: endpointHost.authType,
endpointSSHKey: endpointHost.key,
endpointKeyPassword: endpointHost.keyPassword,
endpointKeyType: endpointHost.keyType,
// Prefer TunnelConnection credentials, then autostart credentials, fallback to encrypted credentials
endpointPassword: tunnelConnection.endpointPassword || endpointHost.autostartPassword || endpointHost.password,
endpointAuthMethod: tunnelConnection.endpointAuthType || endpointHost.authType,
endpointSSHKey: tunnelConnection.endpointKey || endpointHost.autostartKey || endpointHost.key,
endpointKeyPassword: tunnelConnection.endpointKeyPassword || endpointHost.autostartKeyPassword || endpointHost.keyPassword,
endpointKeyType: tunnelConnection.endpointKeyType || endpointHost.keyType,
endpointCredentialId: endpointHost.credentialId,
endpointUserId: endpointHost.userId,
sourcePort: tunnelConnection.sourcePort,
endpointPort: tunnelConnection.endpointPort,
maxRetries: tunnelConnection.maxRetries,
@@ -1079,7 +1374,25 @@ async function initializeAutoStartTunnels(): Promise<void> {
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);
} 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;
keyPassword?: string;
keyType?: string;
// Autostart plaintext credentials
autostartPassword?: string;
autostartKey?: string;
autostartKeyPassword?: string;
credentialId?: number;
userId?: string;
enableTerminal: boolean;
@@ -101,6 +107,14 @@ export interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
// Endpoint host credentials for tunnel authentication
endpointPassword?: string;
endpointKey?: string;
endpointKeyPassword?: string;
endpointAuthType?: string;
endpointKeyType?: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;