feat: migrate from static config to database and add authentication
- Replaced static hosts.json and staticConfig.ts with SQLite database (Prisma) - Implemented JWT authentication and Login UI - Added dynamic API routes for hosts, topology, and settings - Updated UI components to fetch and manage state dynamically - Added Settings interface for managing hosts and topology nodes
This commit is contained in:
78
server/routes/auth.ts
Normal file
78
server/routes/auth.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-for-development-only';
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // Limit each IP to 5 failed login attempts per window
|
||||
message: { status: 'error', message: 'Too many login attempts, please try again later' },
|
||||
skipSuccessfulRequests: true,
|
||||
});
|
||||
|
||||
// Check if setup is required (no users exist)
|
||||
router.get('/auth/status', async (req, res) => {
|
||||
try {
|
||||
const userCount = await prisma.user.count();
|
||||
res.json({ setupRequired: userCount === 0 });
|
||||
} catch (error) {
|
||||
res.status(500).json({ status: 'error', message: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Initial Setup (Only works if no users exist)
|
||||
router.post('/auth/setup', async (req, res) => {
|
||||
try {
|
||||
const userCount = await prisma.user.count();
|
||||
if (userCount > 0) {
|
||||
return res.status(403).json({ status: 'error', message: 'Setup has already been completed' });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password || password.length < 8) {
|
||||
return res.status(400).json({ status: 'error', message: 'Invalid username or password must be at least 8 characters' });
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const user = await prisma.user.create({
|
||||
data: { username, password: hashedPassword },
|
||||
});
|
||||
|
||||
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' });
|
||||
res.json({ status: 'success', token, user: { id: user.id, username: user.username } });
|
||||
} catch (error) {
|
||||
res.status(500).json({ status: 'error', message: 'Failed to create user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/auth/login', loginLimiter, async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ status: 'error', message: 'Username and password required' });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user) {
|
||||
return res.status(401).json({ status: 'error', message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ status: 'error', message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' });
|
||||
res.json({ status: 'success', token, user: { id: user.id, username: user.username } });
|
||||
} catch (error) {
|
||||
res.status(500).json({ status: 'error', message: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -163,7 +163,7 @@ router.get('/config/:host/:container', async (req, res) => {
|
||||
|
||||
try {
|
||||
// Find host config
|
||||
const hostConfigs = getHostConfigs();
|
||||
const hostConfigs = await getHostConfigs();
|
||||
const hostConfig = hostConfigs.find(h => h.name === host);
|
||||
|
||||
if (!hostConfig) {
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
import { Router } from 'express';
|
||||
import { execSync } from 'child_process';
|
||||
import { homedir } from 'os';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getHostConfigs } from '../config';
|
||||
import { DiscoveryResponse } from '../types';
|
||||
import { io } from '../index';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface HostDiscoveryResult {
|
||||
name: string;
|
||||
@@ -35,11 +38,11 @@ async function discoverHost(
|
||||
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 {
|
||||
@@ -50,7 +53,7 @@ async function discoverHost(
|
||||
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 {
|
||||
@@ -63,7 +66,7 @@ async function discoverHost(
|
||||
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);
|
||||
@@ -77,7 +80,7 @@ async function discoverHost(
|
||||
console.error(`DEBUG: ${name} Proxmox discovery failed`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
name,
|
||||
ip,
|
||||
@@ -97,18 +100,53 @@ async function discoverHost(
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/discover - Discover all hosts via SSH
|
||||
// POST /api/discover - Trigger discovery but return cached immediately
|
||||
router.post('/discover', async (req, res) => {
|
||||
try {
|
||||
const hosts = getHostConfigs();
|
||||
|
||||
// 1. Fetch from cache immediately for fast loading
|
||||
const cachedSetting = await prisma.settings.findUnique({ where: { key: 'last_discovery' } });
|
||||
|
||||
let previousState: DiscoveryResponse = {
|
||||
hosts: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
errors: [],
|
||||
};
|
||||
|
||||
if (cachedSetting && cachedSetting.value) {
|
||||
try {
|
||||
previousState = JSON.parse(cachedSetting.value);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse cached discovery state');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Return cached response to unblock the UI request
|
||||
res.json(previousState);
|
||||
|
||||
// 3. Kick off background discovery
|
||||
runBackgroundDiscovery();
|
||||
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
hosts: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
errors: [error.message || 'Cache retrieval failed'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function runBackgroundDiscovery() {
|
||||
try {
|
||||
const hosts = await getHostConfigs();
|
||||
|
||||
if (hosts.length === 0) {
|
||||
const response: DiscoveryResponse = {
|
||||
hosts: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
errors: ['No hosts configured'],
|
||||
};
|
||||
return res.json(response);
|
||||
await cacheAndEmit(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const results: HostDiscoveryResult[] = [];
|
||||
@@ -134,7 +172,7 @@ router.post('/discover', async (req, res) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const errors: string[] = [];
|
||||
results.forEach((result: HostDiscoveryResult) => {
|
||||
if (!result.online && result.error) {
|
||||
@@ -155,15 +193,26 @@ router.post('/discover', async (req, res) => {
|
||||
errors,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
await cacheAndEmit(response);
|
||||
} catch (error: any) {
|
||||
const response: DiscoveryResponse = {
|
||||
hosts: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
errors: [error.message || 'Discovery failed'],
|
||||
};
|
||||
res.status(500).json(response);
|
||||
console.error('Background discovery failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function cacheAndEmit(response: DiscoveryResponse) {
|
||||
try {
|
||||
// Cache the standard response to the DB
|
||||
await prisma.settings.upsert({
|
||||
where: { key: 'last_discovery' },
|
||||
update: { value: JSON.stringify(response) },
|
||||
create: { key: 'last_discovery', value: JSON.stringify(response) },
|
||||
});
|
||||
|
||||
// Broadcast the full update via Socket.IO
|
||||
io.emit('topology:update', response);
|
||||
} catch (err) {
|
||||
console.error('Failed to cache and emit discovery results:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -136,7 +136,7 @@ router.get('/files/:host/:container', async (req, res) => {
|
||||
try {
|
||||
const { host, container } = req.params;
|
||||
|
||||
const hosts = getHostConfigs();
|
||||
const hosts = await getHostConfigs();
|
||||
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
|
||||
|
||||
if (!hostConfig) {
|
||||
@@ -240,7 +240,7 @@ router.get('/files/browse/:host', async (req, res) => {
|
||||
const { host } = req.params;
|
||||
const path = (req.query.path as string) || '/';
|
||||
|
||||
const hosts = getHostConfigs();
|
||||
const hosts = await getHostConfigs();
|
||||
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
|
||||
|
||||
if (!hostConfig) {
|
||||
|
||||
50
server/routes/hosts.ts
Normal file
50
server/routes/hosts.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Router } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// GET all host configurations
|
||||
router.get('/hosts', async (req, res) => {
|
||||
try {
|
||||
const hosts = await prisma.hostConfig.findMany();
|
||||
res.json({ status: 'success', hosts });
|
||||
} catch (error) {
|
||||
res.status(500).json({ status: 'error', message: 'Failed to fetch host configs' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST a new host configuration
|
||||
router.post('/hosts', async (req, res) => {
|
||||
try {
|
||||
const host = await prisma.hostConfig.create({ data: req.body });
|
||||
res.json({ status: 'success', host });
|
||||
} catch (error) {
|
||||
res.status(400).json({ status: 'error', message: 'Failed to create host config. Ensure name is unique.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT (update) a host configuration
|
||||
router.put('/hosts/:id', async (req, res) => {
|
||||
try {
|
||||
const host = await prisma.hostConfig.update({
|
||||
where: { id: req.params.id },
|
||||
data: req.body,
|
||||
});
|
||||
res.json({ status: 'success', host });
|
||||
} catch (error) {
|
||||
res.status(400).json({ status: 'error', message: 'Failed to update host config' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE a host configuration
|
||||
router.delete('/hosts/:id', async (req, res) => {
|
||||
try {
|
||||
await prisma.hostConfig.delete({ where: { id: req.params.id } });
|
||||
res.json({ status: 'success' });
|
||||
} catch (error) {
|
||||
res.status(400).json({ status: 'error', message: 'Failed to delete host config' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -131,7 +131,7 @@ router.get('/stats/:host/:container', async (req, res) => {
|
||||
const { host, container } = req.params;
|
||||
|
||||
// Find host config by name
|
||||
const hosts = getHostConfigs();
|
||||
const hosts = await getHostConfigs();
|
||||
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
|
||||
|
||||
if (!hostConfig) {
|
||||
|
||||
@@ -18,7 +18,7 @@ router.post('/terminal/exec', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Missing host or command' });
|
||||
}
|
||||
|
||||
const hosts = getHostConfigs();
|
||||
const hosts = await getHostConfigs();
|
||||
const hostConfig = hosts.find(h => h.name === hostName);
|
||||
|
||||
if (!hostConfig) {
|
||||
@@ -44,7 +44,7 @@ router.post('/terminal/exec', async (req, res) => {
|
||||
|
||||
router.get('/terminal/hosts', async (_req, res) => {
|
||||
try {
|
||||
const hosts = getHostConfigs();
|
||||
const hosts = await getHostConfigs();
|
||||
res.json({
|
||||
hosts: hosts.map(h => ({
|
||||
name: h.name,
|
||||
|
||||
71
server/routes/topology.ts
Normal file
71
server/routes/topology.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Router } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// GET all nodes and edges
|
||||
router.get('/topology', async (req, res) => {
|
||||
try {
|
||||
const nodes = await prisma.networkNode.findMany();
|
||||
const edges = await prisma.networkEdge.findMany();
|
||||
res.json({ status: 'success', nodes, edges });
|
||||
} catch (error) {
|
||||
res.status(500).json({ status: 'error', message: 'Failed to fetch topology' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST a new node
|
||||
router.post('/topology/nodes', async (req, res) => {
|
||||
try {
|
||||
const node = await prisma.networkNode.create({ data: req.body });
|
||||
res.json({ status: 'success', node });
|
||||
} catch (error) {
|
||||
res.status(400).json({ status: 'error', message: 'Failed to create node' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT (update) a node
|
||||
router.put('/topology/nodes/:id', async (req, res) => {
|
||||
try {
|
||||
const node = await prisma.networkNode.update({
|
||||
where: { id: req.params.id },
|
||||
data: req.body,
|
||||
});
|
||||
res.json({ status: 'success', node });
|
||||
} catch (error) {
|
||||
res.status(400).json({ status: 'error', message: 'Failed to update node' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE a node
|
||||
router.delete('/topology/nodes/:id', async (req, res) => {
|
||||
try {
|
||||
await prisma.networkNode.delete({ where: { id: req.params.id } });
|
||||
res.json({ status: 'success' });
|
||||
} catch (error) {
|
||||
res.status(400).json({ status: 'error', message: 'Failed to delete node' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST a new edge
|
||||
router.post('/topology/edges', async (req, res) => {
|
||||
try {
|
||||
const edge = await prisma.networkEdge.create({ data: req.body });
|
||||
res.json({ status: 'success', edge });
|
||||
} catch (error) {
|
||||
res.status(400).json({ status: 'error', message: 'Failed to create edge' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE an edge
|
||||
router.delete('/topology/edges/:id', async (req, res) => {
|
||||
try {
|
||||
await prisma.networkEdge.delete({ where: { id: req.params.id } });
|
||||
res.json({ status: 'success' });
|
||||
} catch (error) {
|
||||
res.status(400).json({ status: 'error', message: 'Failed to delete edge' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user