feat: integrate all 10 skills into homelab-topology
- Added api-security-hardening (helmet, rate limits) - Added nodejs-backend-patterns (error handling) - Added observability-monitoring (pino logging) - Added websocket-engineer (socket.io real-time updates) - Added docker (Multi-stage build, compose) - Added vitest (testing configuration and store tests) - Added data-visualizer (MetricsBar and HostChart) - Added infrastructure-monitoring/proxmox-admin/network-engineer types - Fixed UI accessibility and styling - Cleaned up node_modules tracking
This commit is contained in:
215
server/routes/config.ts
Normal file
215
server/routes/config.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* SSH Config API Endpoint
|
||||
*
|
||||
* GET /api/config/:host/:container - Get docker-compose config for a specific container
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { Client } from 'ssh2';
|
||||
import { readFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { getHostConfigs } from '../config';
|
||||
import { ConfigResponse } from '../types';
|
||||
|
||||
const router = Router();
|
||||
|
||||
interface SSHConnectionConfig {
|
||||
host: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
privateKey?: Buffer;
|
||||
}
|
||||
|
||||
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(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Find docker-compose.yml files on the remote host
|
||||
async function findDockerComposeFiles(conn: Client): Promise<string[]> {
|
||||
const command = `find /home -name "docker-compose.yml" 2>/dev/null | head -10`;
|
||||
const output = await execSSH(conn, command);
|
||||
|
||||
const files = output
|
||||
.split('\n')
|
||||
.map(f => f.trim())
|
||||
.filter(f => f.length > 0);
|
||||
|
||||
// Also check common locations
|
||||
const commonPaths = [
|
||||
`${homedir()}/docker-compose.yml`,
|
||||
'/opt/docker-compose.yml',
|
||||
'/root/docker-compose.yml'
|
||||
];
|
||||
|
||||
return [...new Set([...files, ...commonPaths])];
|
||||
}
|
||||
|
||||
// Extract a specific service from docker-compose.yml
|
||||
function extractServiceFromYaml(fullYaml: string, serviceName: string): string {
|
||||
const lines = fullYaml.split('\n');
|
||||
let inService = false;
|
||||
let serviceLines: string[] = [];
|
||||
let indentLevel = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
// Check if we're starting the target service
|
||||
if (line.match(new RegExp(`^\\s+${serviceName}:`)) || line.match(new RegExp(`^${serviceName}:`))) {
|
||||
inService = true;
|
||||
indentLevel = line.match(/^(\s*)/)![1].length;
|
||||
serviceLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inService) {
|
||||
const currentIndent = line.match(/^(\s*)/)![1].length;
|
||||
|
||||
// If we've dedented back to or past the service level, we're done
|
||||
if (line.trim() && currentIndent <= indentLevel) {
|
||||
// Check if this is another top-level key (services, volumes, networks)
|
||||
if (line.match(/^(services|volumes|networks):/)) {
|
||||
break;
|
||||
}
|
||||
// If this is another service, we're done
|
||||
if (!line.startsWith(' ')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
serviceLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (serviceLines.length === 0) {
|
||||
return `# Service "${serviceName}" not found in docker-compose.yml`;
|
||||
}
|
||||
|
||||
return serviceLines.join('\n');
|
||||
}
|
||||
|
||||
// Read docker-compose.yml from remote host and extract service
|
||||
async function getContainerConfig(conn: Client, containerName: string): Promise<ConfigResponse> {
|
||||
const files = await findDockerComposeFiles(conn);
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const command = `cat "${filePath}"`;
|
||||
const content = await execSSH(conn, command);
|
||||
|
||||
if (content && content.includes(containerName)) {
|
||||
const serviceConfig = extractServiceFromYaml(content, containerName);
|
||||
return {
|
||||
yaml: serviceConfig,
|
||||
path: filePath
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error reading ${filePath}:`, err);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
yaml: '',
|
||||
path: '',
|
||||
error: `No docker-compose.yml found with service "${containerName}"`
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/config/:host/:container
|
||||
router.get('/config/:host/:container', async (req, res) => {
|
||||
const { host, container } = req.params;
|
||||
|
||||
console.log(`Fetching config for ${container} on ${host}`);
|
||||
|
||||
try {
|
||||
// Find host config
|
||||
const hostConfigs = getHostConfigs();
|
||||
const hostConfig = hostConfigs.find(h => h.name === host);
|
||||
|
||||
if (!hostConfig) {
|
||||
return res.status(404).json({
|
||||
yaml: '',
|
||||
path: '',
|
||||
error: `Host "${host}" not found in configuration`
|
||||
});
|
||||
}
|
||||
|
||||
// Read SSH key
|
||||
let privateKey: Buffer | undefined;
|
||||
if (hostConfig.sshKeyPath) {
|
||||
const keyPath = hostConfig.sshKeyPath.replace(/^~/, homedir());
|
||||
try {
|
||||
privateKey = readFileSync(keyPath);
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
yaml: '',
|
||||
path: '',
|
||||
error: `Failed to read SSH key: ${err}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to host via SSH
|
||||
const conn = await connectSSH({
|
||||
host: hostConfig.ip,
|
||||
port: hostConfig.sshPort || 22,
|
||||
username: hostConfig.sshUser,
|
||||
privateKey
|
||||
}, 30000);
|
||||
|
||||
// Get container config
|
||||
const config = await getContainerConfig(conn, container);
|
||||
conn.end();
|
||||
|
||||
res.json(config);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching config:', err);
|
||||
res.status(500).json({
|
||||
yaml: '',
|
||||
path: '',
|
||||
error: err.message || 'Failed to fetch config'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user