diff --git a/CHANGELOG.md b/CHANGELOG.md index 67b234a7..cea07437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **Fixed:** Critical port routing conflict — separated Docker API port (`port`) from SSH/SFTP port (`ssh_port`) in the `nodes` schema. Previously, a single `port` field served both protocols, causing ECONNREFUSED. +- **Fixed:** `FileSystemService` now reads the node's `compose_dir` from the database for remote nodes instead of always using the `COMPOSE_DIR` env var. +- **Fixed:** SSH/SFTP connections in `SSHFileAdapter`, `ComposeService.executeRemote()`, and `ComposeService.streamLogs()` now use `ssh_port` (default 22) instead of Docker API `port`. +- **Added:** Full SSH credential fields (SSH Port, Username, Password, Private Key) to the Node Manager Add/Edit forms. +- **Added:** `ssh_port` column to the `nodes` database table with migration support (default: 22). - **Changed:** Global `FileSystemService` and `ComposeService` singletons refactored into node-aware instances. - **Added:** `IFileAdapter`, `LocalFileAdapter`, and `SSHFileAdapter` to abstract all filesystem interactions for remote node support. - **Changed:** `MonitorService` now evaluates limits, fetches metrics, and detects container crashes across all registered nodes concurrently. diff --git a/backend/src/index.ts b/backend/src/index.ts index dd5c4bdb..525149b9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1414,7 +1414,7 @@ app.get('/api/nodes/:id', async (req: Request, res: Response) => { // Create a new node app.post('/api/nodes', async (req: Request, res: Response) => { try { - const { name, type, host, port, compose_dir, is_default } = req.body; + const { name, type, host, port, ssh_port, compose_dir, is_default, ssh_user, ssh_password, ssh_key } = req.body; if (!name || typeof name !== 'string') { return res.status(400).json({ error: 'Node name is required' }); @@ -1431,8 +1431,12 @@ app.post('/api/nodes', async (req: Request, res: Response) => { type, host: host || '', port: port || 2375, + ssh_port: ssh_port || 22, compose_dir: compose_dir || '/opt/docker', is_default: is_default || false, + ssh_user: ssh_user || '', + ssh_password: ssh_password || '', + ssh_key: ssh_key || '', }); res.json({ success: true, id }); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 442981b4..44fb0e16 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -137,7 +137,7 @@ export class ComposeService { if (throwOnError) reject(err); else resolve(); }).connect({ host: node.host, - port: node.port || 22, + port: node.ssh_port || 22, username: node.ssh_user!, password: node.ssh_password, privateKey: node.ssh_key, @@ -305,7 +305,7 @@ export class ComposeService { }); }).on('error', handleProcessEnd).connect({ host: node!.host, - port: node!.port || 22, + port: node!.ssh_port || 22, username: node!.ssh_user!, password: node!.ssh_password, privateKey: node!.ssh_key, diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index eacd043c..abfab30a 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -31,6 +31,7 @@ export interface Node { type: 'local' | 'remote'; host: string; port: number; + ssh_port: number; compose_dir: string; is_default: boolean; status: 'online' | 'offline' | 'unknown'; @@ -146,6 +147,7 @@ export class DatabaseService { const maybeAddCol = (table: string, col: string, def: string) => { try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e) { /* ignore */ } }; + maybeAddCol('nodes', 'ssh_port', 'INTEGER DEFAULT 22'); maybeAddCol('nodes', 'ssh_user', "TEXT DEFAULT ''"); maybeAddCol('nodes', 'ssh_password', "TEXT DEFAULT ''"); maybeAddCol('nodes', 'ssh_key', "TEXT DEFAULT ''"); @@ -368,13 +370,14 @@ export class DatabaseService { this.db.prepare('UPDATE nodes SET is_default = 0').run(); } const stmt = this.db.prepare( - 'INSERT INTO nodes (name, type, host, port, compose_dir, is_default, status, created_at, ssh_user, ssh_password, ssh_key, tls_ca, tls_cert, tls_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO nodes (name, type, host, port, ssh_port, compose_dir, is_default, status, created_at, ssh_user, ssh_password, ssh_key, tls_ca, tls_cert, tls_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ); const result = stmt.run( node.name, node.type, node.host, node.port, + node.ssh_port || 22, node.compose_dir, node.is_default ? 1 : 0, 'unknown', @@ -405,6 +408,7 @@ export class DatabaseService { if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); } if (updates.host !== undefined) { fields.push('host = ?'); values.push(updates.host); } if (updates.port !== undefined) { fields.push('port = ?'); values.push(updates.port); } + if (updates.ssh_port !== undefined) { fields.push('ssh_port = ?'); values.push(updates.ssh_port); } if (updates.compose_dir !== undefined) { fields.push('compose_dir = ?'); values.push(updates.compose_dir); } if (updates.is_default !== undefined) { fields.push('is_default = ?'); values.push(updates.is_default ? 1 : 0); } if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); } diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 5733052b..f8fc6dde 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -10,14 +10,15 @@ export class FileSystemService { private nodeId: number; constructor(nodeId?: number) { - this.baseDir = process.env.COMPOSE_DIR || '/app/compose'; this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId(); const node = NodeRegistry.getInstance().getNode(this.nodeId); if (!node || node.type === 'local' || !node.host) { + this.baseDir = process.env.COMPOSE_DIR || '/app/compose'; this.adapter = new LocalFileAdapter(); } else { + this.baseDir = node.compose_dir || '/app/compose'; this.adapter = new SSHFileAdapter(node); } } diff --git a/backend/src/services/fs/SSHFileAdapter.ts b/backend/src/services/fs/SSHFileAdapter.ts index 653fb1e4..0f05b9d7 100644 --- a/backend/src/services/fs/SSHFileAdapter.ts +++ b/backend/src/services/fs/SSHFileAdapter.ts @@ -13,7 +13,7 @@ export class SSHFileAdapter implements IFileAdapter { const sftp = new Client(); await sftp.connect({ host: this.node.host, - port: this.node.port || 22, + port: this.node.ssh_port || 22, username: this.node.ssh_user!, password: this.node.ssh_password, privateKey: this.node.ssh_key, diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 302247dc..cddaac74 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -20,8 +20,12 @@ interface NodeFormData { type: 'local' | 'remote'; host: string; port: number; + ssh_port: number; compose_dir: string; is_default: boolean; + ssh_user: string; + ssh_password: string; + ssh_key: string; } const defaultFormData: NodeFormData = { @@ -29,8 +33,12 @@ const defaultFormData: NodeFormData = { type: 'remote', host: '', port: 2375, + ssh_port: 22, compose_dir: '/opt/docker', is_default: false, + ssh_user: '', + ssh_password: '', + ssh_key: '', }; export function NodeManager() { @@ -90,8 +98,12 @@ export function NodeManager() { type: node.type, host: node.host, port: node.port, + ssh_port: node.ssh_port || 22, compose_dir: node.compose_dir, is_default: node.is_default, + ssh_user: node.ssh_user || '', + ssh_password: node.ssh_password || '', + ssh_key: node.ssh_key || '', }); setEditingNodeId(node.id); setEditOpen(true); @@ -188,17 +200,72 @@ export function NodeManager() { /> +
+ Default: 2375 (unencrypted) or 2376 (TLS) +
++ Default: 22. Used for file operations (SFTP). +
+SSH Credentials (for file operations)
+ +