feat: expand discovery with systemd services, LXC/VMs, SSH terminal, and filebrowser

- Add systemd service discovery to backend
- Add Proxmox LXC/VM detection
- Add hostType field to config for better host categorization
- Fix SSH trust between hosts (ubuntu/grizzley -> truenas/proxmox)
- Add SSH terminal support via xterm.js
- Add filebrowser for browsing host filesystems
- Update frontend types and components for new node types
This commit is contained in:
2026-02-20 17:18:33 -08:00
parent a4cff9894c
commit 3dc5d236a2
23 changed files with 2680 additions and 70 deletions

265
server/routes/files.ts Normal file
View File

@@ -0,0 +1,265 @@
/**
* Files API Endpoint
*
* GET /api/files/:host/:container - Get volume mounts for a container
*/
import { Router } from 'express';
import { Client } from 'ssh2';
import { readFileSync } from 'fs';
import { homedir } from 'os';
import { getHostConfigs } from '../config';
const router = Router();
interface SSHConnectionConfig {
host: string;
port?: number;
username: string;
privateKey?: Buffer;
}
interface VolumeMount {
source: string;
destination: string;
mode: string;
}
interface FilesResponse {
volumes: VolumeMount[];
error?: string;
}
async function connectSSH(config: SSHConnectionConfig, timeout: number = 30000): Promise<Client> {
return new Promise((resolve, reject) => {
const conn = new Client();
const timeoutId = setTimeout(() => {
conn.end();
reject(new Error('Connection timeout'));
}, timeout);
conn.on('ready', () => {
clearTimeout(timeoutId);
resolve(conn);
});
conn.on('error', (err) => {
clearTimeout(timeoutId);
reject(err);
});
conn.connect({
host: config.host,
port: config.port || 22,
username: config.username,
privateKey: config.privateKey,
readyTimeout: timeout,
});
});
}
async function execSSH(conn: Client, command: string): Promise<string> {
return new Promise((resolve, reject) => {
conn.exec(command, (err, stream) => {
if (err) {
reject(err);
return;
}
let output = '';
stream.on('close', () => resolve(output));
stream.on('data', (data: Buffer) => { output += data.toString(); });
stream.stderr.on('data', (data: Buffer) => { output += data.toString(); });
});
});
}
async function getContainerVolumes(
ip: string,
containerName: string,
sshUser: string,
sshKeyPath?: string,
sshPort?: number
): Promise<FilesResponse> {
try {
// Load SSH key
const keyPath = sshKeyPath || `${homedir()}/.ssh/id_ed25519`;
let privateKey: Buffer | undefined;
try {
privateKey = readFileSync(keyPath);
} catch {
// If key file doesn't exist, try without it
}
const sshConfig: SSHConnectionConfig = {
host: ip,
port: sshPort || 22,
username: sshUser,
privateKey,
};
const conn = await connectSSH(sshConfig, 30000);
// Run docker inspect to get mounts
const command = `docker inspect ${containerName} --format '{{json .Mounts}}'`;
const output = await execSSH(conn, command);
conn.end();
// Parse JSON output
let mounts: any[] = [];
try {
mounts = JSON.parse(output.trim()) || [];
} catch {
// If parsing fails, return empty array
return { volumes: [] };
}
// Transform to VolumeMount format
const volumes: VolumeMount[] = mounts.map((mount: any) => ({
source: mount.Source || mount.Source || '',
destination: mount.Destination || mount.Destination || '',
mode: mount.Mode || mount.Mode || 'rw',
})).filter((v: VolumeMount) => v.source && v.destination);
return { volumes };
} catch (error: any) {
return {
volumes: [],
error: error.message || 'Failed to get container volumes',
};
}
}
// GET /api/files/:host/:container - Get volume mounts for a container
router.get('/files/:host/:container', async (req, res) => {
try {
const { host, container } = req.params;
const hosts = getHostConfigs();
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
if (!hostConfig) {
const response: FilesResponse = {
volumes: [],
error: `Host '${host}' not found in configuration`,
};
return res.status(404).json(response);
}
const result = await getContainerVolumes(
hostConfig.ip,
container,
hostConfig.sshUser,
hostConfig.sshKeyPath,
hostConfig.sshPort
);
res.json(result);
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : 'Failed to get container volumes';
const response: FilesResponse = {
volumes: [],
error: errMsg,
};
res.status(500).json(response);
}
});
interface FileEntry {
name: string;
path: string;
type: 'file' | 'directory' | 'symlink';
size: number;
modified: string;
}
interface BrowseResponse {
path: string;
files: FileEntry[];
error?: string;
}
async function browseDirectory(
ip: string,
path: string,
sshUser: string,
sshKeyPath?: string,
sshPort?: number
): Promise<BrowseResponse> {
try {
const keyPath = sshKeyPath || `${homedir()}/.ssh/id_ed25519`;
let privateKey: Buffer | undefined;
try {
privateKey = require('fs').readFileSync(keyPath);
} catch {
// ignore
}
const sshConfig: SSHConnectionConfig = {
host: ip,
port: sshPort || 22,
username: sshUser,
privateKey,
};
const conn = await connectSSH(sshConfig, 30000);
const command = `ls -la --time-style=long-iso "${path}" 2>/dev/null | tail -n +2`;
const output = await execSSH(conn, command);
conn.end();
const files: FileEntry[] = output.trim().split('\n').filter(Boolean).map(line => {
const parts = line.split(/\s+/);
const perms = parts[0];
const size = parseInt(parts[4], 10) || 0;
const modified = parts[5] + ' ' + parts[6];
const name = parts.slice(8).join(' ');
const type = perms.startsWith('d') ? 'directory' :
perms.startsWith('l') ? 'symlink' : 'file';
return {
name,
path: path === '/' ? `/${name}` : `${path}/${name}`,
type,
size,
modified,
};
});
return { path, files };
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : 'Failed to browse directory';
return { path, files: [], error: errMsg };
}
}
router.get('/files/browse/:host', async (req, res) => {
try {
const { host } = req.params;
const path = (req.query.path as string) || '/';
const hosts = getHostConfigs();
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
if (!hostConfig) {
return res.status(404).json({ path, files: [], error: `Host '${host}' not found` });
}
const result = await browseDirectory(
hostConfig.ip,
path,
hostConfig.sshUser,
hostConfig.sshKeyPath,
hostConfig.sshPort
);
res.json(result);
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : 'Failed to browse directory';
res.status(500).json({ path: req.query.path as string || '/', files: [], error: errMsg });
}
});
export default router;