mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 03:59:41 +00:00
Merge pull request #26 from AnsoCode/fix/remote-nodes-remediation
fix: Remote Nodes Remediation — Port Routing, SSH Credentials & compose_dir
This commit is contained in:
@@ -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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [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.
|
- **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.
|
- **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.
|
- **Changed:** `MonitorService` now evaluates limits, fetches metrics, and detects container crashes across all registered nodes concurrently.
|
||||||
|
|||||||
@@ -1414,7 +1414,7 @@ app.get('/api/nodes/:id', async (req: Request, res: Response) => {
|
|||||||
// Create a new node
|
// Create a new node
|
||||||
app.post('/api/nodes', async (req: Request, res: Response) => {
|
app.post('/api/nodes', async (req: Request, res: Response) => {
|
||||||
try {
|
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') {
|
if (!name || typeof name !== 'string') {
|
||||||
return res.status(400).json({ error: 'Node name is required' });
|
return res.status(400).json({ error: 'Node name is required' });
|
||||||
@@ -1431,8 +1431,12 @@ app.post('/api/nodes', async (req: Request, res: Response) => {
|
|||||||
type,
|
type,
|
||||||
host: host || '',
|
host: host || '',
|
||||||
port: port || 2375,
|
port: port || 2375,
|
||||||
|
ssh_port: ssh_port || 22,
|
||||||
compose_dir: compose_dir || '/opt/docker',
|
compose_dir: compose_dir || '/opt/docker',
|
||||||
is_default: is_default || false,
|
is_default: is_default || false,
|
||||||
|
ssh_user: ssh_user || '',
|
||||||
|
ssh_password: ssh_password || '',
|
||||||
|
ssh_key: ssh_key || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, id });
|
res.json({ success: true, id });
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export class ComposeService {
|
|||||||
if (throwOnError) reject(err); else resolve();
|
if (throwOnError) reject(err); else resolve();
|
||||||
}).connect({
|
}).connect({
|
||||||
host: node.host,
|
host: node.host,
|
||||||
port: node.port || 22,
|
port: node.ssh_port || 22,
|
||||||
username: node.ssh_user!,
|
username: node.ssh_user!,
|
||||||
password: node.ssh_password,
|
password: node.ssh_password,
|
||||||
privateKey: node.ssh_key,
|
privateKey: node.ssh_key,
|
||||||
@@ -305,7 +305,7 @@ export class ComposeService {
|
|||||||
});
|
});
|
||||||
}).on('error', handleProcessEnd).connect({
|
}).on('error', handleProcessEnd).connect({
|
||||||
host: node!.host,
|
host: node!.host,
|
||||||
port: node!.port || 22,
|
port: node!.ssh_port || 22,
|
||||||
username: node!.ssh_user!,
|
username: node!.ssh_user!,
|
||||||
password: node!.ssh_password,
|
password: node!.ssh_password,
|
||||||
privateKey: node!.ssh_key,
|
privateKey: node!.ssh_key,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface Node {
|
|||||||
type: 'local' | 'remote';
|
type: 'local' | 'remote';
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
ssh_port: number;
|
||||||
compose_dir: string;
|
compose_dir: string;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
status: 'online' | 'offline' | 'unknown';
|
status: 'online' | 'offline' | 'unknown';
|
||||||
@@ -146,6 +147,7 @@ export class DatabaseService {
|
|||||||
const maybeAddCol = (table: string, col: string, def: string) => {
|
const maybeAddCol = (table: string, col: string, def: string) => {
|
||||||
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e) { /* ignore */ }
|
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_user', "TEXT DEFAULT ''");
|
||||||
maybeAddCol('nodes', 'ssh_password', "TEXT DEFAULT ''");
|
maybeAddCol('nodes', 'ssh_password', "TEXT DEFAULT ''");
|
||||||
maybeAddCol('nodes', 'ssh_key', "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();
|
this.db.prepare('UPDATE nodes SET is_default = 0').run();
|
||||||
}
|
}
|
||||||
const stmt = this.db.prepare(
|
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(
|
const result = stmt.run(
|
||||||
node.name,
|
node.name,
|
||||||
node.type,
|
node.type,
|
||||||
node.host,
|
node.host,
|
||||||
node.port,
|
node.port,
|
||||||
|
node.ssh_port || 22,
|
||||||
node.compose_dir,
|
node.compose_dir,
|
||||||
node.is_default ? 1 : 0,
|
node.is_default ? 1 : 0,
|
||||||
'unknown',
|
'unknown',
|
||||||
@@ -405,6 +408,7 @@ export class DatabaseService {
|
|||||||
if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); }
|
if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); }
|
||||||
if (updates.host !== undefined) { fields.push('host = ?'); values.push(updates.host); }
|
if (updates.host !== undefined) { fields.push('host = ?'); values.push(updates.host); }
|
||||||
if (updates.port !== undefined) { fields.push('port = ?'); values.push(updates.port); }
|
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.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.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); }
|
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ export class FileSystemService {
|
|||||||
private nodeId: number;
|
private nodeId: number;
|
||||||
|
|
||||||
constructor(nodeId?: number) {
|
constructor(nodeId?: number) {
|
||||||
this.baseDir = process.env.COMPOSE_DIR || '/app/compose';
|
|
||||||
this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
|
this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
|
||||||
|
|
||||||
const node = NodeRegistry.getInstance().getNode(this.nodeId);
|
const node = NodeRegistry.getInstance().getNode(this.nodeId);
|
||||||
|
|
||||||
if (!node || node.type === 'local' || !node.host) {
|
if (!node || node.type === 'local' || !node.host) {
|
||||||
|
this.baseDir = process.env.COMPOSE_DIR || '/app/compose';
|
||||||
this.adapter = new LocalFileAdapter();
|
this.adapter = new LocalFileAdapter();
|
||||||
} else {
|
} else {
|
||||||
|
this.baseDir = node.compose_dir || '/app/compose';
|
||||||
this.adapter = new SSHFileAdapter(node);
|
this.adapter = new SSHFileAdapter(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export class SSHFileAdapter implements IFileAdapter {
|
|||||||
const sftp = new Client();
|
const sftp = new Client();
|
||||||
await sftp.connect({
|
await sftp.connect({
|
||||||
host: this.node.host,
|
host: this.node.host,
|
||||||
port: this.node.port || 22,
|
port: this.node.ssh_port || 22,
|
||||||
username: this.node.ssh_user!,
|
username: this.node.ssh_user!,
|
||||||
password: this.node.ssh_password,
|
password: this.node.ssh_password,
|
||||||
privateKey: this.node.ssh_key,
|
privateKey: this.node.ssh_key,
|
||||||
|
|||||||
@@ -20,8 +20,12 @@ interface NodeFormData {
|
|||||||
type: 'local' | 'remote';
|
type: 'local' | 'remote';
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
ssh_port: number;
|
||||||
compose_dir: string;
|
compose_dir: string;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
|
ssh_user: string;
|
||||||
|
ssh_password: string;
|
||||||
|
ssh_key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultFormData: NodeFormData = {
|
const defaultFormData: NodeFormData = {
|
||||||
@@ -29,8 +33,12 @@ const defaultFormData: NodeFormData = {
|
|||||||
type: 'remote',
|
type: 'remote',
|
||||||
host: '',
|
host: '',
|
||||||
port: 2375,
|
port: 2375,
|
||||||
|
ssh_port: 22,
|
||||||
compose_dir: '/opt/docker',
|
compose_dir: '/opt/docker',
|
||||||
is_default: false,
|
is_default: false,
|
||||||
|
ssh_user: '',
|
||||||
|
ssh_password: '',
|
||||||
|
ssh_key: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NodeManager() {
|
export function NodeManager() {
|
||||||
@@ -90,8 +98,12 @@ export function NodeManager() {
|
|||||||
type: node.type,
|
type: node.type,
|
||||||
host: node.host,
|
host: node.host,
|
||||||
port: node.port,
|
port: node.port,
|
||||||
|
ssh_port: node.ssh_port || 22,
|
||||||
compose_dir: node.compose_dir,
|
compose_dir: node.compose_dir,
|
||||||
is_default: node.is_default,
|
is_default: node.is_default,
|
||||||
|
ssh_user: node.ssh_user || '',
|
||||||
|
ssh_password: node.ssh_password || '',
|
||||||
|
ssh_key: node.ssh_key || '',
|
||||||
});
|
});
|
||||||
setEditingNodeId(node.id);
|
setEditingNodeId(node.id);
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
@@ -188,17 +200,72 @@ export function NodeManager() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="node-port">Docker API Port</Label>
|
||||||
|
<Input
|
||||||
|
id="node-port"
|
||||||
|
type="number"
|
||||||
|
placeholder="2375"
|
||||||
|
value={formData.port}
|
||||||
|
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) || 2375 })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Default: 2375 (unencrypted) or 2376 (TLS)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="node-ssh-port">SSH Port</Label>
|
||||||
|
<Input
|
||||||
|
id="node-ssh-port"
|
||||||
|
type="number"
|
||||||
|
placeholder="22"
|
||||||
|
value={formData.ssh_port}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ssh_port: parseInt(e.target.value) || 22 })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Default: 22. Used for file operations (SFTP).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">SSH Credentials (for file operations)</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="node-ssh-user">SSH Username</Label>
|
||||||
|
<Input
|
||||||
|
id="node-ssh-user"
|
||||||
|
placeholder="e.g., root"
|
||||||
|
value={formData.ssh_user}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ssh_user: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="node-ssh-password">SSH Password</Label>
|
||||||
|
<Input
|
||||||
|
id="node-ssh-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Optional if using SSH key"
|
||||||
|
value={formData.ssh_password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ssh_password: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="node-port">Docker API Port</Label>
|
<Label htmlFor="node-ssh-key">SSH Private Key</Label>
|
||||||
<Input
|
<textarea
|
||||||
id="node-port"
|
id="node-ssh-key"
|
||||||
type="number"
|
className="flex min-h-[100px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
|
||||||
placeholder="2375"
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||||
value={formData.port}
|
value={formData.ssh_key}
|
||||||
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) || 2375 })}
|
onChange={(e) => setFormData({ ...formData, ssh_key: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Default: 2375 (unencrypted) or 2376 (TLS)
|
Paste the full private key contents. Optional if using password auth.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ export interface Node {
|
|||||||
type: 'local' | 'remote';
|
type: 'local' | 'remote';
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
ssh_port: number;
|
||||||
compose_dir: string;
|
compose_dir: string;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
status: 'online' | 'offline' | 'unknown';
|
status: 'online' | 'offline' | 'unknown';
|
||||||
created_at: number;
|
created_at: number;
|
||||||
|
ssh_user?: string;
|
||||||
|
ssh_password?: string;
|
||||||
|
ssh_key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeContextType {
|
interface NodeContextType {
|
||||||
|
|||||||
Reference in New Issue
Block a user