import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import WebSocket from 'ws'; import jwt from 'jsonwebtoken'; import DockerController from './services/DockerController'; import { FileSystemService } from './services/FileSystemService'; import { ComposeService } from './services/ComposeService'; import { ConfigService } from './services/ConfigService'; import composerize from 'composerize'; import si from 'systeminformation'; import http from 'http'; const app = express(); const PORT = 3000; // Environment variables const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'; // ConfigService for persistent auth storage const configService = new ConfigService(); // Cookie settings const COOKIE_NAME = 'sencho_token'; const COOKIE_OPTIONS = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' as const, maxAge: 24 * 60 * 60 * 1000, // 24 hours }; // Middleware app.use(cors({ origin: true, credentials: true, })); app.use(express.json()); app.use(cookieParser()); // Extend Express Request type for user declare module 'express' { interface Request { user?: { username: string }; } } // Authentication Middleware const authMiddleware = (req: Request, res: Response, next: NextFunction): void => { const token = req.cookies[COOKIE_NAME]; if (!token) { res.status(401).json({ error: 'Authentication required' }); return; } try { const decoded = jwt.verify(token, JWT_SECRET) as { username: string }; req.user = { username: decoded.username }; next(); } catch { res.status(401).json({ error: 'Invalid or expired token' }); return; } }; // Auth Routes (no authentication required) // Check if setup is needed app.get('/api/auth/status', async (req: Request, res: Response): Promise => { try { const needsSetup = await configService.needsSetup(); res.json({ needsSetup }); } catch (error) { console.error('Error checking setup status:', error); res.json({ needsSetup: true }); } }); // Initial setup endpoint app.post('/api/auth/setup', async (req: Request, res: Response): Promise => { try { // Check if setup is still needed const needsSetup = await configService.needsSetup(); if (!needsSetup) { res.status(400).json({ error: 'Setup has already been completed' }); return; } const { username, password, confirmPassword } = req.body; // Validation if (!username || !password || !confirmPassword) { res.status(400).json({ error: 'All fields are required' }); return; } if (username.length < 3) { res.status(400).json({ error: 'Username must be at least 3 characters' }); return; } if (password.length < 6) { res.status(400).json({ error: 'Password must be at least 6 characters' }); return; } if (password !== confirmPassword) { res.status(400).json({ error: 'Passwords do not match' }); return; } // Save credentials await configService.saveConfig(username, password); // Issue JWT and log user in const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '24h' }); res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS); res.json({ success: true, message: 'Setup completed successfully' }); } catch (error) { console.error('Setup error:', error); res.status(500).json({ error: 'Failed to complete setup' }); } }); // Login endpoint app.post('/api/auth/login', async (req: Request, res: Response): Promise => { const { username, password } = req.body; if (!username || !password) { res.status(400).json({ error: 'Username and password are required' }); return; } try { const isValid = await configService.validateCredentials(username, password); if (isValid) { const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '24h' }); res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS); res.json({ success: true, message: 'Login successful' }); return; } res.status(401).json({ error: 'Invalid credentials' }); } catch (error) { console.error('Login error:', error); res.status(500).json({ error: 'Login failed' }); } }); app.post('/api/auth/logout', (req: Request, res: Response): void => { res.clearCookie(COOKIE_NAME, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', }); res.json({ success: true, message: 'Logged out successfully' }); }); // Check authentication status app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void => { res.json({ authenticated: true, user: req.user }); }); // Apply authentication middleware to all /api/* routes except /api/auth/* app.use('/api', (req: Request, res: Response, next: NextFunction): void => { if (req.path.startsWith('/auth/')) { next(); return; } authMiddleware(req, res, next); }); // Create HTTP server for WebSocket upgrade handling const server = http.createServer(app); // WebSocket server with authentication const wss = new WebSocket.Server({ noServer: true }); let terminalWs: WebSocket | null = null; // Handle WebSocket upgrade with JWT authentication server.on('upgrade', (req, socket, head) => { // Parse cookies from the upgrade request const cookieHeader = req.headers.cookie || ''; const cookies = Object.fromEntries( cookieHeader.split(';').map(c => c.trim().split('=')).filter(([k, v]) => k && v) ); const token = cookies[COOKIE_NAME]; if (!token) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } try { jwt.verify(token, JWT_SECRET); // Authentication successful, proceed with WebSocket connection wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req); }); } catch (error) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } }); wss.on('connection', (ws) => { console.log('WebSocket connected'); ws.on('message', (message) => { try { const data = JSON.parse(message.toString()); if (data.action === 'connectTerminal') { terminalWs = ws; } else if (data.action === 'streamStats') { const dockerController = DockerController.getInstance(); dockerController.streamStats(data.containerId, ws); } else if (data.action === 'execContainer') { // Handle container exec for bash access const dockerController = DockerController.getInstance(); dockerController.execContainer(data.containerId, ws); } else if (data.action === 'input') { // Forward input to exec stream const dockerController = DockerController.getInstance(); dockerController.sendExecInput(data.data); } else if (data.action === 'resize') { const dockerController = DockerController.getInstance(); dockerController.resizeExec(data.cols, data.rows); } } catch (error) { ws.send(JSON.stringify({ error: 'Invalid message' })); } }); }); // API Routes (all protected by authMiddleware) app.get('/api/containers', async (req: Request, res: Response) => { try { const dockerController = DockerController.getInstance(); const containers = await dockerController.getRunningContainers(); res.json(containers); } catch (error) { res.status(500).json({ error: 'Failed to fetch containers' }); } }); const fileSystemService = new FileSystemService(); app.get('/api/stacks', async (req: Request, res: Response) => { try { const files = await fileSystemService.getStackFiles(); res.json(files); } catch (error) { res.status(500).json({ error: 'Failed to fetch stack files' }); } }); app.get('/api/stacks/:filename', async (req: Request, res: Response) => { try { const filename = req.params.filename as string; const content = await fileSystemService.getStackContent(filename); res.send(content); } catch (error) { res.status(500).json({ error: 'Failed to read file' }); } }); app.put('/api/stacks/:filename', async (req: Request, res: Response) => { try { const filename = req.params.filename as string; const { content } = req.body; console.log('PUT /api/stacks/:filename', { filename, contentType: typeof content, contentLength: content?.length }); if (typeof content !== 'string') { console.error('Content is not a string:', content); return res.status(400).json({ error: 'Content must be a string' }); } await fileSystemService.saveStackContent(filename, content); console.log('File saved successfully:', filename); res.json({ message: 'File saved successfully' }); } catch (error) { console.error('Failed to save file:', error); res.status(500).json({ error: 'Failed to save file' }); } }); app.get('/api/stacks/:filename/env', async (req: Request, res: Response) => { try { const filename = req.params.filename as string; const exists = await fileSystemService.envExists(filename); if (!exists) { return res.status(404).json({ error: 'Env file not found' }); } const content = await fileSystemService.getEnvContent(filename); res.send(content); } catch (error) { res.status(500).json({ error: 'Failed to read env file' }); } }); app.put('/api/stacks/:filename/env', async (req: Request, res: Response) => { try { const filename = req.params.filename as string; const { content } = req.body; if (typeof content !== 'string') { return res.status(400).json({ error: 'Content must be a string' }); } await fileSystemService.saveEnvContent(filename, content); res.json({ message: 'Env file saved successfully' }); } catch (error) { console.error('Failed to save env file:', error); res.status(500).json({ error: 'Failed to save env file' }); } }); app.post('/api/stacks', async (req: Request, res: Response) => { try { const { filename } = req.body; if (!filename || typeof filename !== 'string') { return res.status(400).json({ error: 'Filename is required and must be a string' }); } await fileSystemService.createStack(filename); res.json({ message: 'Stack created successfully' }); } catch (error) { console.error('Failed to create stack:', error); res.status(500).json({ error: 'Failed to create stack' }); } }); app.delete('/api/stacks/:filename', async (req: Request, res: Response) => { try { const filename = req.params.filename as string; await fileSystemService.deleteStack(filename); res.json({ message: 'Stack deleted successfully' }); } catch (error) { console.error('Failed to delete stack:', error); res.status(500).json({ error: 'Failed to delete stack' }); } }); app.get('/api/stacks/:filename/containers', async (req: Request, res: Response) => { try { const filename = req.params.filename as string; const stackName = filename.replace(/\.yml$/, ''); const dockerController = DockerController.getInstance(); const containers = await dockerController.getContainersByStack(stackName); res.json(containers); } catch (error) { res.status(500).json({ error: 'Failed to fetch containers' }); } }); app.post('/api/containers/:id/start', async (req: Request, res: Response) => { try { const id = req.params.id as string; const dockerController = DockerController.getInstance(); await dockerController.startContainer(id); res.json({ message: 'Container started' }); } catch (error) { res.status(500).json({ error: 'Failed to start container' }); } }); app.post('/api/containers/:id/stop', async (req: Request, res: Response) => { try { const id = req.params.id as string; const dockerController = DockerController.getInstance(); await dockerController.stopContainer(id); res.json({ message: 'Container stopped' }); } catch (error) { res.status(500).json({ error: 'Failed to stop container' }); } }); app.post('/api/containers/:id/restart', async (req: Request, res: Response) => { try { const id = req.params.id as string; const dockerController = DockerController.getInstance(); await dockerController.restartContainer(id); res.json({ message: 'Container restarted' }); } catch (error) { res.status(500).json({ error: 'Failed to restart container' }); } }); const composeService = new ComposeService(); app.post('/api/stacks/:filename/up', async (req: Request, res: Response) => { try { const filename = req.params.filename as string; composeService.runCommand(filename, 'up', terminalWs || undefined); res.json({ status: 'Command started' }); } catch (error) { res.status(500).json({ error: 'Failed to start command' }); } }); app.post('/api/stacks/:filename/down', async (req: Request, res: Response) => { try { const filename = req.params.filename as string; composeService.runCommand(filename, 'down', terminalWs || undefined); res.json({ status: 'Command started' }); } catch (error) { res.status(500).json({ error: 'Failed to start command' }); } }); // Update stack: pull images and recreate containers app.post('/api/stacks/:filename/update', async (req: Request, res: Response) => { try { const filename = req.params.filename as string; // Run update asynchronously, don't wait for completion composeService.updateStack(filename, terminalWs || undefined).catch(error => { console.error('Update stack error:', error); }); res.json({ status: 'Update started' }); } catch (error) { res.status(500).json({ error: 'Failed to start update' }); } }); // Docker Run to Compose converter endpoint app.post('/api/convert', async (req: Request, res: Response) => { try { const { dockerRun } = req.body; if (!dockerRun || typeof dockerRun !== 'string') { return res.status(400).json({ error: 'dockerRun command is required' }); } const yaml = composerize(dockerRun); res.json({ yaml }); } catch (error) { console.error('Conversion error:', error); res.status(500).json({ error: 'Failed to convert docker run command' }); } }); // Get all containers stats for dashboard app.get('/api/stats', async (req: Request, res: Response) => { try { const dockerController = DockerController.getInstance(); const containers = await dockerController.getRunningContainers(); const allContainers = await dockerController.getAllContainers(); const active = containers.length; const exited = allContainers.filter((c: { State: string }) => c.State === 'exited').length; const total = allContainers.length; res.json({ active, exited, total, inactive: total - active - exited }); } catch (error) { res.status(500).json({ error: 'Failed to fetch stats' }); } }); // Get host system stats app.get('/api/system/stats', async (req: Request, res: Response) => { try { const [currentLoad, mem, fsSize] = await Promise.all([ si.currentLoad(), si.mem(), si.fsSize(), ]); // Find the main mount (usually the largest or root mount) const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0]; res.json({ cpu: { usage: currentLoad.currentLoad.toFixed(1), cores: currentLoad.cpus.length, }, memory: { total: mem.total, used: mem.used, free: mem.free, usagePercent: ((mem.used / mem.total) * 100).toFixed(1), }, disk: mainDisk ? { fs: mainDisk.fs, mount: mainDisk.mount, total: mainDisk.size, used: mainDisk.used, free: mainDisk.available, usagePercent: mainDisk.use ? mainDisk.use.toFixed(1) : '0', } : null, }); } catch (error) { console.error('Failed to fetch system stats:', error); res.status(500).json({ error: 'Failed to fetch system stats' }); } }); // Serve static files in production (for Docker deployment) if (process.env.NODE_ENV === 'production') { app.use(express.static('public')); // Handle SPA routing - serve index.html for non-API routes app.get('*', (req: Request, res: Response) => { if (!req.path.startsWith('/api')) { res.sendFile('index.html', { root: 'public' }); } }); } server.listen(PORT, () => { console.log(`Server running on port ${PORT}`); });