feat: implement remote tls/ssh security, isolate system stats, and polish ux

- NodeRegistry: wire TLS ca/cert/key into Dockerode when present on node config
- index.ts: /api/system/stats now branches on node type — remote nodes use docker.info() for CPU/RAM, local keeps systeminformation; disk gracefully returns null for remote
- index.ts: POST /api/nodes now persists tls_ca, tls_cert, tls_key fields
- FileSystemService: throw clean error on missing/empty compose_dir instead of crashing path.join
- FileSystemService: guard getStacks() against falsy item.name entries
- SSHFileAdapter: filter undefined/non-string names from SFTP readdir before returning
- NodeManager: add SSH Authentication Type toggle (Password vs Private Key)
- NodeManager: add Enable TLS toggle with conditional CA/cert/key textarea fields
- NodeManager: auto-test connection immediately after node creation
- NodeManager: replace "Strategy B" copy with Docker TCP setup instructions
- NodeManager: add pr-8 to header and DialogHeader to prevent overlap with parent dialog X button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SaelixCode
2026-03-19 09:40:03 -04:00
parent 1fb0494e2e
commit 2a37e114df
6 changed files with 254 additions and 58 deletions
+6 -2
View File
@@ -11,14 +11,17 @@ export class FileSystemService {
constructor(nodeId?: number) {
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.baseDir = node.compose_dir;
if (!this.baseDir || typeof this.baseDir !== 'string' || this.baseDir.trim() === '') {
throw new Error(`Remote node "${node.name}" has no compose_dir configured. Please set a compose directory in the Node Manager.`);
}
this.adapter = new SSHFileAdapter(node);
}
}
@@ -77,6 +80,7 @@ export class FileSystemService {
for (const item of items) {
if (!item.isDirectory()) continue;
if (!item.name || typeof item.name !== 'string') continue;
const stackDir = path.join(this.baseDir, item.name);
const hasCompose = await this.hasComposeFile(stackDir);
+11 -3
View File
@@ -90,11 +90,19 @@ export class NodeRegistry {
throw new Error(`Remote node "${node.name}" is missing a host address`);
}
return new Docker({
const dockerOptions: Docker.DockerOptions = {
host: node.host,
port: node.port || 2375,
// TODO: Phase 55.2 — Add TLS certificate support for secure remote connections
});
};
// Phase 55.4 — TLS: if all three certs are present, enable secure connection
if (node.tls_ca && node.tls_cert && node.tls_key) {
dockerOptions.ca = node.tls_ca;
dockerOptions.cert = node.tls_cert;
dockerOptions.key = node.tls_key;
}
return new Docker(dockerOptions);
}
/**
+5 -2
View File
@@ -36,14 +36,17 @@ export class SSHFileAdapter implements IFileAdapter {
const sftp = await this.getClient();
try {
const list = await sftp.list(dirPath);
// Guard: filter out any entries with missing or non-string names to prevent
// downstream path.join crashes (TypeError on undefined.split)
const valid = list.filter((item: any) => item.name && typeof item.name === 'string');
if (options?.withFileTypes) {
return list.map((item: any) => ({
return valid.map((item: any) => ({
name: item.name,
isDirectory: () => item.type === 'd',
isFile: () => item.type === '-',
}));
}
return list.map((item: any) => item.name);
return valid.map((item: any) => item.name);
} catch(err: any) {
if(err.code === 2 || err.message.includes('No such file')) throw Object.assign(new Error(), { code: 'ENOENT' });
throw err;