refactor: pivot from ssh proxy to distributed api model

- Delete SSHFileAdapter, IFileAdapter, LocalFileAdapter (SSH/SFTP stack)
- Remove ssh2, ssh2-sftp-client dependencies; add http-proxy-middleware, http-proxy
- NodeRegistry: remote nodes no longer use Dockerode TCP; new getProxyTarget() returns {apiUrl, apiToken}
- NodeRegistry.testConnection: remote nodes use HTTP GET /api/auth/check instead of docker.info()
- DatabaseService: Node interface swaps SSH/TLS fields for api_url + api_token; legacy columns preserved for DB compat
- FileSystemService: reverted to clean local-only fs.promises; adapter pattern fully removed
- ComposeService: executeRemote() and SSH log streaming deleted; local-only execution remains
- index.ts: add /api/auth/generate-node-token endpoint (long-lived JWT, scope:node_proxy)
- index.ts: authMiddleware now accepts Bearer token in addition to cookie (Sencho-to-Sencho auth)
- index.ts: remote HTTP proxy middleware intercepts all /api/ requests for remote nodes, strips x-node-id, injects Authorization header, proxies to api_url
- index.ts: WS upgrade handler proxies WebSocket connections for remote nodes via http-proxy wsProxyServer
- NodeManager.tsx: form reduced to Name, API URL, API Token; Generate Node Token button added inline
- NodeContext.tsx: Node interface updated to api_url/api_token

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SaelixCode
2026-03-19 12:20:32 -04:00
parent 23730430d7
commit c91c9ed7fd
14 changed files with 522 additions and 899 deletions
+36 -150
View File
@@ -1,14 +1,16 @@
import { spawn, exec } from 'child_process';
import { promisify } from 'util';
import { spawn } from 'child_process';
import path from 'path';
import WebSocket from 'ws';
import DockerController from './DockerController';
import { LogFormatter } from './LogFormatter';
import { NodeRegistry } from './NodeRegistry';
import { Client as SSHClient } from 'ssh2';
const execAsync = promisify(exec);
/**
* ComposeService — local docker compose CLI execution.
*
* In the Distributed API model, remote node compose operations are handled
* by the remote Sencho instance. This service only executes commands locally.
*/
export class ComposeService {
private baseDir: string;
private nodeId: number;
@@ -22,30 +24,10 @@ export class ComposeService {
return new ComposeService(nodeId);
}
/**
* Universal command execution (Local or Remote SSH)
*/
private async executeCommand(
command: string,
args: string[],
cwd: string,
ws?: WebSocket,
throwOnError = true
): Promise<void> {
const node = NodeRegistry.getInstance().getNode(this.nodeId);
if (!node) throw new Error(`Node ${this.nodeId} not found`);
if (node.type === 'local' || !node.host) {
return this.executeLocal(command, args, cwd, ws, throwOnError);
} else {
return this.executeRemote(node, command, args, cwd, ws, throwOnError);
}
}
private async executeLocal(
command: string,
args: string[],
cwd: string,
private execute(
command: string,
args: string[],
cwd: string,
ws?: WebSocket,
throwOnError = true
): Promise<void> {
@@ -90,65 +72,9 @@ export class ComposeService {
});
}
private async executeRemote(
node: any,
command: string,
args: string[],
cwd: string,
ws?: WebSocket,
throwOnError = true
): Promise<void> {
return new Promise((resolve, reject) => {
const conn = new SSHClient();
let errorLog = '';
conn.on('ready', () => {
const cmdString = `cd "${cwd}" && ${command} ${args.map(a => `"${a}"`).join(' ')}`;
conn.exec(cmdString, (err, stream) => {
if (err) {
conn.end();
if (throwOnError) reject(err); else resolve();
return;
}
const onData = (data: any) => {
const text = data.toString();
errorLog += text;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(text);
}
};
stream.on('data', onData).stderr.on('data', onData);
stream.on('close', (code: any, signal: any) => {
conn.end();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(`Command exited with code ${code}\n`);
}
if (code === 0) resolve();
else if (throwOnError) reject(new Error(errorLog.trim() || `Command failed with code ${code}`));
else resolve();
});
});
}).on('error', (err) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(`SSH Error: ${err.message}\n`);
if (throwOnError) reject(err); else resolve();
}).connect({
host: node.host,
port: node.ssh_port || 22,
username: node.ssh_user!,
password: node.ssh_password,
privateKey: node.ssh_key,
readyTimeout: 10000,
});
});
}
async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
await this.executeCommand('docker', ['compose', action], stackDir, ws);
await this.execute('docker', ['compose', action], stackDir, ws);
}
async deployStack(stackName: string, ws?: WebSocket): Promise<void> {
@@ -165,7 +91,7 @@ export class ComposeService {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
}
await this.executeCommand('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
// Post-Deploy Health Probe
await new Promise(resolve => setTimeout(resolve, 3000));
@@ -231,13 +157,10 @@ export class ComposeService {
let activeProcesses = 0;
let streamEndedHandled = false;
let clientConnections: any[] = [];
let localProcesses: ReturnType<typeof spawn>[] = [];
const onWsClose = () => {
localProcesses.forEach(cp => { try { cp.kill(); } catch {} });
clientConnections.forEach(conn => { try { conn.end(); } catch {} });
};
ws.on('close', onWsClose);
@@ -253,80 +176,43 @@ export class ComposeService {
}
};
const node = NodeRegistry.getInstance().getNode(this.nodeId);
const isRemote = (node && node.type === 'remote' && node.host);
for (const container of containersToLog) {
const containerName = container.Names?.[0]?.replace(/^\//, '') || container.Id;
activeProcesses++;
let lineBuffer = '';
const sendOutput = (data: Buffer | any) => {
const sendOutput = (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
lineBuffer += data.toString();
const lines = lineBuffer.split(/\r?\n/);
lineBuffer = lines.pop() || '';
for (const line of lines) {
const formattedLine = LogFormatter.process(line);
ws.send(formattedLine + '\r\n');
ws.send(LogFormatter.process(line) + '\r\n');
}
}
};
const flushBuffer = () => {
if (lineBuffer && ws.readyState === WebSocket.OPEN) {
const formattedLine = LogFormatter.process(lineBuffer);
ws.send(formattedLine + '\r\n');
lineBuffer = '';
if (lineBuffer && ws.readyState === WebSocket.OPEN) {
ws.send(LogFormatter.process(lineBuffer) + '\r\n');
lineBuffer = '';
}
}
};
if (isRemote) {
const conn = new SSHClient();
clientConnections.push(conn);
conn.on('ready', () => {
conn.exec(`docker logs -f --tail 100 "${containerName}"`, {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
}, (err, stream) => {
if (err) {
conn.end();
handleProcessEnd();
return;
}
stream.on('data', sendOutput).stderr.on('data', sendOutput);
stream.on('close', () => {
flushBuffer();
conn.end();
handleProcessEnd();
});
});
}).on('error', handleProcessEnd).connect({
host: node!.host,
port: node!.ssh_port || 22,
username: node!.ssh_user!,
password: node!.ssh_password,
privateKey: node!.ssh_key,
readyTimeout: 10000,
});
} else {
const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
localProcesses.push(child);
child.stdout.on('data', sendOutput);
child.stderr.on('data', sendOutput);
child.on('error', handleProcessEnd);
child.on('close', () => {
flushBuffer();
handleProcessEnd();
});
}
const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
localProcesses.push(child);
child.stdout.on('data', sendOutput);
child.stderr.on('data', sendOutput);
child.on('error', handleProcessEnd);
child.on('close', () => {
flushBuffer();
handleProcessEnd();
});
}
} catch (err) {
if (!isClosed && ws.readyState === WebSocket.OPEN) {
@@ -360,17 +246,17 @@ export class ComposeService {
}
sendOutput('=== Pulling latest images ===\n');
await this.executeCommand('docker', ['compose', 'pull'], stackDir, ws);
await this.execute('docker', ['compose', 'pull'], stackDir, ws);
sendOutput('=== Recreating containers ===\n');
await this.executeCommand('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
sendOutput('=== Stack updated successfully ===\n');
}
public async downStack(stackName: string): Promise<void> {
const stackPath = path.join(this.baseDir, stackName);
try {
await this.executeCommand('docker', ['compose', 'down'], stackPath, undefined, false);
await this.execute('docker', ['compose', 'down'], stackPath, undefined, false);
} catch (error) {
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
}