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:
60
server/AGENTS.md
Normal file
60
server/AGENTS.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Server (Express API Backend)
|
||||
|
||||
**Generated:** 2026-02-19
|
||||
**Location:** server/
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Express 5 + TypeScript backend serving REST API endpoints for homelab topology discovery.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
server/
|
||||
├── index.ts # Express app entry, CORS, route mounting
|
||||
├── config.ts # Server configuration
|
||||
├── types.ts # Shared TypeScript types
|
||||
├── config.json # Runtime config (contains hosts, credentials)
|
||||
├── config.example.json
|
||||
└── routes/ # API endpoints
|
||||
├── discover.ts # POST /api/discover - SSH discovery
|
||||
├── config.ts # GET/PUT /api/config
|
||||
├── stats.ts # GET /api/stats
|
||||
└── files.ts # GET /api/files
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Task | File | Notes |
|
||||
|------|------|-------|
|
||||
| Add new endpoint | server/routes/{name}.ts | Create router, import in index.ts |
|
||||
| Modify CORS | server/index.ts | CORS middleware (line 12-15) |
|
||||
| Change port | server/index.ts | PORT const (line 9) |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **Route files**: Export default router, mount in index.ts via `app.use('/api', router)`
|
||||
- **Error handling**: Return JSON `{ error: string }` on failure
|
||||
- **Config**: Use server/config.ts for shared config, not hardcode
|
||||
- **Credentials**: Never log or expose SSH credentials in responses
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **NEVER expose SSH credentials in API responses**
|
||||
- **DO NOT store credentials in source** - use server/config.json
|
||||
|
||||
## API ENDPOINTS
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| POST | /api/discover | Run SSH discovery on hosts |
|
||||
| GET | /api/config | Get configuration |
|
||||
| PUT | /api/config | Update configuration |
|
||||
| GET | /api/stats | Get statistics |
|
||||
| GET | /api/files | Get file topology |
|
||||
|
||||
## NOTES
|
||||
|
||||
- Server runs on port 3001
|
||||
- CORS allows only `http://localhost:3000`
|
||||
- Health check: GET /api/health
|
||||
56
server/hosts.json
Normal file
56
server/hosts.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"hosts": [
|
||||
{
|
||||
"name": "ubuntu",
|
||||
"ip": "192.168.50.61",
|
||||
"sshUser": "bear",
|
||||
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||
"sshPort": 22,
|
||||
"hostType": "docker-host"
|
||||
},
|
||||
{
|
||||
"name": "grizzley",
|
||||
"ip": "192.168.50.84",
|
||||
"sshUser": "bear",
|
||||
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||
"sshPort": 22,
|
||||
"hostType": "docker-host"
|
||||
},
|
||||
{
|
||||
"name": "truenas",
|
||||
"ip": "192.168.50.12",
|
||||
"sshUser": "root",
|
||||
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||
"sshPort": 22,
|
||||
"hostType": "truenas"
|
||||
},
|
||||
{
|
||||
"name": "proxmox",
|
||||
"ip": "192.168.50.11",
|
||||
"sshUser": "root",
|
||||
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||
"sshPort": 22,
|
||||
"hostType": "proxmox"
|
||||
},
|
||||
{
|
||||
"name": "ice",
|
||||
"ip": "192.168.50.197",
|
||||
"sshUser": "bear",
|
||||
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||
"sshPort": 22,
|
||||
"hostType": "docker-host"
|
||||
},
|
||||
{
|
||||
"name": "panda",
|
||||
"ip": "192.168.50.196",
|
||||
"sshUser": "bear",
|
||||
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||
"sshPort": 22,
|
||||
"hostType": "docker-host"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import discoverRouter from './routes/discover';
|
||||
import configRouter from './routes/config';
|
||||
import statsRouter from './routes/stats';
|
||||
import filesRouter from './routes/files';
|
||||
import terminalRouter from './routes/terminal';
|
||||
import { getHostConfigs } from './config';
|
||||
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
@@ -10,11 +16,25 @@ app.use(cors({
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// Debug endpoint to check config
|
||||
app.get('/api/debug-config', (req, res) => {
|
||||
const hosts = getHostConfigs();
|
||||
res.json({ hosts });
|
||||
});
|
||||
|
||||
app.use('/api', discoverRouter);
|
||||
app.use('/api', configRouter);
|
||||
app.use('/api', statsRouter);
|
||||
app.use('/api', filesRouter);
|
||||
app.use('/api', terminalRouter);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
169
server/routes/discover.ts
Normal file
169
server/routes/discover.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* SSH Discovery API Endpoint
|
||||
*
|
||||
* POST /api/discover - Discover all hosts via SSH and return their status
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { execSync } from 'child_process';
|
||||
import { homedir } from 'os';
|
||||
import { getHostConfigs } from '../config';
|
||||
import { DiscoveryResponse } from '../types';
|
||||
|
||||
const router = Router();
|
||||
|
||||
interface HostDiscoveryResult {
|
||||
name: string;
|
||||
ip: string;
|
||||
online: boolean;
|
||||
containers?: string[];
|
||||
services?: string[];
|
||||
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function discoverHost(
|
||||
name: string,
|
||||
ip: string,
|
||||
sshUser: string,
|
||||
sshKeyPath?: string,
|
||||
sshPort?: number,
|
||||
hostType?: string
|
||||
): Promise<HostDiscoveryResult> {
|
||||
try {
|
||||
const keyPath = (sshKeyPath || `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir());
|
||||
console.error(`DEBUG: ${name} keyPath=${keyPath}, user=${sshUser}`);
|
||||
const keyArg = `-i ${keyPath}`;
|
||||
const portArg = sshPort && sshPort !== 22 ? `-p ${sshPort}` : '';
|
||||
|
||||
const dockerCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "docker ps --format '{{.Names}}'" 2>/dev/null`;
|
||||
const dockerOutput = execSync(dockerCmd, { encoding: 'utf-8', timeout: 15000 });
|
||||
const containers = dockerOutput.trim().split('\n').filter(c => c.trim());
|
||||
|
||||
let services: string[] = [];
|
||||
if (hostType !== 'proxmox') {
|
||||
try {
|
||||
const systemdCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "systemctl list-units --type=service --state=running --no-pager --no-legend --quiet | awk '{print \\$1}'" 2>/dev/null`;
|
||||
const systemdOutput = execSync(systemdCmd, { encoding: 'utf-8', timeout: 10000 });
|
||||
services = systemdOutput.trim().split('\n').filter(s => s.trim());
|
||||
} catch {
|
||||
console.error(`DEBUG: ${name} systemd discovery failed`);
|
||||
}
|
||||
}
|
||||
|
||||
let vms: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }> = [];
|
||||
if (hostType === 'proxmox' || name === 'proxmox') {
|
||||
try {
|
||||
const lxcCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "pct list" 2>/dev/null`;
|
||||
const lxcOutput = execSync(lxcCmd, { encoding: 'utf-8', timeout: 10000 });
|
||||
const lxcLines = lxcOutput.trim().split('\n').slice(1);
|
||||
for (const line of lxcLines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
vms.push({ id: parts[0], name: parts[1], status: parts[2] || 'unknown', type: 'lxc' });
|
||||
}
|
||||
}
|
||||
|
||||
const vmCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "qm list" 2>/dev/null`;
|
||||
const vmOutput = execSync(vmCmd, { encoding: 'utf-8', timeout: 10000 });
|
||||
const vmLines = vmOutput.trim().split('\n').slice(1);
|
||||
for (const line of vmLines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
vms.push({ id: parts[0], name: parts[1], status: parts[2] || 'unknown', type: 'qemu' });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.error(`DEBUG: ${name} Proxmox discovery failed`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
ip,
|
||||
online: true,
|
||||
containers,
|
||||
services: services.length > 0 ? services : undefined,
|
||||
vms: vms.length > 0 ? vms : undefined,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
name,
|
||||
ip,
|
||||
online: false,
|
||||
containers: [],
|
||||
error: error.message || 'Discovery failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/discover - Discover all hosts via SSH
|
||||
router.post('/discover', async (req, res) => {
|
||||
try {
|
||||
const hosts = getHostConfigs();
|
||||
|
||||
if (hosts.length === 0) {
|
||||
const response: DiscoveryResponse = {
|
||||
hosts: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
errors: ['No hosts configured'],
|
||||
};
|
||||
return res.json(response);
|
||||
}
|
||||
|
||||
const results: HostDiscoveryResult[] = [];
|
||||
for (const host of hosts) {
|
||||
try {
|
||||
const keyPath = host.sshKeyPath?.replace(/^~/, homedir());
|
||||
const result = await discoverHost(
|
||||
host.name,
|
||||
host.ip,
|
||||
host.sshUser,
|
||||
keyPath,
|
||||
host.sshPort,
|
||||
host.hostType
|
||||
);
|
||||
results.push(result);
|
||||
} catch (error: any) {
|
||||
results.push({
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
online: false,
|
||||
containers: [],
|
||||
error: error.message || 'Discovery failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
results.forEach((result: HostDiscoveryResult) => {
|
||||
if (!result.online && result.error) {
|
||||
errors.push(`${result.name}: ${result.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
const response: DiscoveryResponse = {
|
||||
hosts: results.map(r => ({
|
||||
name: r.name,
|
||||
ip: r.ip,
|
||||
online: r.online,
|
||||
containers: r.containers,
|
||||
services: r.services,
|
||||
vms: r.vms,
|
||||
})),
|
||||
timestamp: new Date().toISOString(),
|
||||
errors,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error: any) {
|
||||
const response: DiscoveryResponse = {
|
||||
hosts: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
errors: [error.message || 'Discovery failed'],
|
||||
};
|
||||
res.status(500).json(response);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
265
server/routes/files.ts
Normal file
265
server/routes/files.ts
Normal 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;
|
||||
60
server/routes/terminal.ts
Normal file
60
server/routes/terminal.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Router } from 'express';
|
||||
import { execSync } from 'child_process';
|
||||
import { homedir } from 'os';
|
||||
import { getHostConfigs } from '../config';
|
||||
|
||||
const router = Router();
|
||||
|
||||
interface TerminalRequest {
|
||||
host: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
router.post('/terminal/exec', async (req, res) => {
|
||||
try {
|
||||
const { host: hostName, command }: TerminalRequest = req.body;
|
||||
|
||||
if (!hostName || !command) {
|
||||
return res.status(400).json({ error: 'Missing host or command' });
|
||||
}
|
||||
|
||||
const hosts = getHostConfigs();
|
||||
const hostConfig = hosts.find(h => h.name === hostName);
|
||||
|
||||
if (!hostConfig) {
|
||||
return res.status(404).json({ error: `Host not found: ${hostName}` });
|
||||
}
|
||||
|
||||
const keyPath = (hostConfig.sshKeyPath || `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir());
|
||||
const keyArg = `-i ${keyPath}`;
|
||||
const portArg = hostConfig.sshPort && hostConfig.sshPort !== 22 ? `-p ${hostConfig.sshPort}` : '';
|
||||
|
||||
const fullCommand = `ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${hostConfig.sshUser}@${hostConfig.ip} ${command} 2>&1`;
|
||||
|
||||
const output = execSync(fullCommand, { encoding: 'utf-8', timeout: 30000 });
|
||||
|
||||
res.json({ output, error: null });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
output: '',
|
||||
error: error.message || 'Command execution failed'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/terminal/hosts', async (_req, res) => {
|
||||
try {
|
||||
const hosts = getHostConfigs();
|
||||
res.json({
|
||||
hosts: hosts.map(h => ({
|
||||
name: h.name,
|
||||
ip: h.ip,
|
||||
user: h.sshUser
|
||||
}))
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -17,6 +17,8 @@ export interface HostConfig {
|
||||
sshKeyPath?: string;
|
||||
/** Optional SSH port (defaults to 22) */
|
||||
sshPort?: number;
|
||||
/** Host type: proxmox, truenas, docker-host, etc */
|
||||
hostType?: string;
|
||||
}
|
||||
|
||||
export interface DiscoveryResponse {
|
||||
@@ -26,6 +28,8 @@ export interface DiscoveryResponse {
|
||||
ip: string;
|
||||
online: boolean;
|
||||
containers?: string[];
|
||||
services?: string[];
|
||||
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
|
||||
error?: string;
|
||||
}>;
|
||||
/** Timestamp of discovery run */
|
||||
|
||||
Reference in New Issue
Block a user