const express = require('express'); const session = require('express-session'); const fetch = require('node-fetch'); const cors = require('cors'); const WebSocket = require('ws'); const https = require('https'); const path = require('path'); const fs = require('fs'); const ini = require('ini'); const cookieParser = require('cookie-parser'); require('console-stamp')(console, { format: ':date(yyyy/mm/dd HH:MM:ss) :label' }); const app = express(); const agent = new https.Agent({ rejectUnauthorized: false }); const config = ini.parse(fs.readFileSync('./config.ini', 'utf-8')); const OAuth2Client = require('./oauth2'); const createDB = require('./mariadb.js'); const { Console } = require('console'); const db = createDB.init(config.database); const baseUrl = config.proxmox.base_url; const statusUsername = config.proxmox.username; const statusPassword = config.proxmox.password; const sessionSecret = config.session.secret; const instructionsPath = path.join(__dirname, 'instructions'); const PORT = 3000; app.use(cors()); app.use(express.json()); app.use(cookieParser()); tokenCookie = 'access_token'; accessCookie = 'access'; userCookie = 'username'; const hostname = config.site.hostname; const oauth = new OAuth2Client(config.oauth2); let vncSessions = new Map(); const VNC_TTL_MS = 30000; let vmStatusCache = {}; setInterval(fetchVmStatues, 20000); fetchVmStatues(); app.use((req,res,next) => { if (!req.headers['x-forwarded-for'] || !req.headers['x-forwarded-host']) { return res.status(403).send('Forbidden: Direct access not allowed'); } res.setHeader("Content-Security-Policy", "default-src 'self'; " + "style-src 'self'; " + "script-src 'self'" + `img-src 'self' https://${hostname}.dsv.su.se; ` + "connect-src 'self'; " + "font-src 'self';" ); next(); }); app.use(session({ secret: sessionSecret, resave: false, saveUninitialized: false, cookie: { secure: true, httpOnly: true, sameSite: 'lax' } })); app.set('trust proxy', 1); const server = app.listen(PORT, '127.0.0.1', () => console.log('Running server on http://localhost:3000')); async function apiRequest(endpoint, headers, method = 'GET', data = null, cookies = null, apiUrl = baseUrl) { const url = `https://${apiUrl}${endpoint}`; console.log(`Attempting to connect to ${url} with Method: ${method}`) const options = { credentials: 'include', method, headers, agent, cookies }; if (data !== null && data !== undefined && method !== 'GET') { options.body = data; } try { const res = await fetch(url, options); if (!res.ok) { const errorText = await res.text(); throw new Error(`Request failed: ${res.status} ${errorText}`); } console.log("Success") return await res.json(); } catch (err) { console.error(`Error: (${method} ${endpoint}:)`, err.message) } } async function fetchInstructions(service){ const instructionPath = path.join(instructionsPath, `${service}.html`); try{ const instructions = await fs.promises.readFile(instructionPath, 'utf-8'); return instructions; } catch(err){ console.error ("Error fetching instructions", err.message); return null; } } async function fetchVmStatues() { const username = statusUsername; const password = statusPassword; try{ accessBody = new URLSearchParams({username,password}) const accessTicket = await apiRequest('/access/ticket', { 'Content-Type': 'application/x-www-form-urlencoded'}, 'POST', accessBody); PVEAuthCookie = accessTicket.data.ticket CSRFPreventionToken = accessTicket.data.CSRFPreventionToken headers = { 'CSRFPreventionToken': CSRFPreventionToken, Cookie: `PVEAuthCookie=${PVEAuthCookie}` } const vms = await apiRequest('/cluster/resources?type=vm', headers, 'GET'); vmStatusCache = {} vms.data.forEach(vm => { vmStatusCache[vm.name] = vm.status; }) console.log('Fetched current status of all VMs') } catch (err){ console.error('Error getting status for all VMs', err.message); } } app.get('/', async (req, res) => { const token = req.cookies[tokenCookie]; if(!token) return res.redirect('/login'); const userInfo = await oauth.introspectToken(token); if (!userInfo?.active) return res.redirect('login'); req.session.userInfo = userInfo; const dbUser = await db.getUser(userInfo.sub); const entitlementConf = config.security.required_entitlement; req.session.save(err => { if (err) { console.error('Session error:', err.message); return res.status(500).send('Session error'); }; const requiredEntitlementList = entitlementConf.split(","); const hasEntitlement = userInfo['entitlements'].some(item => requiredEntitlementList.includes(item)); if(!hasEntitlement || !dbUser) return res.status(403).send('Access denied: You lack permission to use this service'); res.sendFile(path.join(__dirname, 'public/index.html')); }); }); app.get('/login', (req, res) => { const state = Math.random().toString(36).slice(2); req.session.oauthState = state; const url = oauth.authUrl; res.redirect(url); }); app.get('/auth', async(req, res) => { const { code, state } = req.query; if (state !== req.session.oauthState) { return res.status(403).send('Invalid state parameter'); } delete req.session.oauthState; try { const tokenData = await oauth.exchangeCodeForToken(code); const tokenInfo = await oauth.introspectToken(tokenData.access_token); req.session.userInfo = tokenInfo; res.cookie('access_token', tokenData.access_token, { secure: true, sameSite: 'Strict', httpOnly: false }); res.cookie('username', tokenInfo.sub, { secure: true, sameSite: 'Strict', httpOnly: false }); const nextUrl = req.query.next ? decodeURIComponent(req.query.next) : '/'; console.log(nextUrl); res.redirect(nextUrl); } catch (err) { res.status(500).json({ error: err.response?.data || err.message }) } }); app.get('/api/vmlist', async(req, res) => { const user = req.cookies.username; const userid = await db.getUserid(user); console.log(`Getting list of VMs for ${user} with userid: ${userid}`); const allowed = await db.getVMList(userid); if(allowed.length == 0){ console.log('Denied'); return res.status(403).json({access: false, message: 'Access denied'}); } res.json({access: true, allowed}); }); app.get('/api/vmaccess', async(req, res) => { const user = req.cookies.username; const params = new URL(req.url, `https://${req.headers.host}`); const vmName = params.searchParams.get('vm'); const userid = await db.getUserid(user); const access = await db.getUserAccess(userid, vmName); if (!access){ console.log(`${user} does not have access to ${vmName}`); return res.status(403).json({access: false, message: 'User not allwoed'}); } console.log(`${user} with userid ${userid} is accessing ${vmName}`) return res.status(200).json({access: true, message: ''}) }); app.get('/api/vncproxy', async(req, res) => { const user = req.cookies.username; const params = new URL(req.url, `https://${req.headers.host}`); const vmName = params.searchParams.get('vm'); const vmConf = await db.getVM(vmName); const vmid = vmConf.vmid; const username = vmConf.pveuser; const password = vmConf.pvepass; console.log(`${user} is attemping to setup a vnc connection to ${vmName}`); try { accessBody = new URLSearchParams({username,password}) const accessTicket = await apiRequest('/access/ticket', { 'Content-Type': 'application/x-www-form-urlencoded'}, 'POST', accessBody); PVEAuthCookie = accessTicket.data.ticket CSRFPreventionToken = accessTicket.data.CSRFPreventionToken headers = { 'CSRFPreventionToken': CSRFPreventionToken, Cookie: `PVEAuthCookie=${PVEAuthCookie}` } vms = await apiRequest('/cluster/resources?type=vm', headers, 'GET'); vm = vms.data.find(vm => vm.vmid === vmid); if (!vm) throw new Error(`VM with ${vmid} not found`); node = vm.node; nodeUrl = `${node}.dsv.local.su.se:8006/api2/json` vnc = await apiRequest(`/nodes/${node}/qemu/${vmid}/vncproxy?websocket=1`, headers, 'POST', null, null, nodeUrl); port = `${vnc.data.port}` ticket = `${vnc.data.ticket}` vncpass = `${vnc.data.password}` vncInfo = { nodeUrl, port, vmid, PVEAuthCookie }; vncSessions.set(ticket, vncInfo); setTimeout(() => vncSessions.delete(ticket), VNC_TTL_MS); console.log('Set up VNC proxy'); res.json({message: 'VNC proxy ready:', ready: true, hostname, ticket}) } catch (err) { console.error('Error setting up VNC proxy', err.message); res.status(500).json({ error: err.message}); } }); app.get('/api/status', async(req,res) => { const params = new URL(req.url, `https://${req.headers.host}`); const vmName = params.searchParams.get('vm'); console.log('Getting status for ', vmName); const status = vmStatusCache[vmName] === 'running'; res.status(200).json(status); }) app.get('/api/stop', async(req, res) => { const params = new URL(req.url, `https://${req.headers.host}`); const vmName = params.searchParams.get('vm'); const vmConf = await db.getVM(vmName); const vmid = vmConf.vmid; const username = vmConf.pveuser; const password = vmConf.pvepass; try { accessBody = new URLSearchParams({username,password}) const accessTicket = await apiRequest('/access/ticket', { 'Content-Type': 'application/x-www-form-urlencoded'}, 'POST', accessBody); PVEAuthCookie = accessTicket.data.ticket CSRFPreventionToken = accessTicket.data.CSRFPreventionToken headers = { 'CSRFPreventionToken': CSRFPreventionToken, Cookie: `PVEAuthCookie=${PVEAuthCookie}` } const vms = await apiRequest('/cluster/resources?type=vm', headers, 'GET'); const vm = vms.data.find(vm => vm.vmid === vmid) if (!vm) throw new Error('VM not found'); const node = vm.node; const stop = await apiRequest(`/nodes/${node}/qemu/${vmid}/status/stop`, headers, 'POST'); res.json({message: 'VM successfully stopped'}) } catch (err) { console.error('Error stopping VM', err.message); res.status(500).json({ error: err.message}); } }); app.get('/api/start', async(req, res) => { const params = new URL(req.url, `https://${req.headers.host}`); const vmName = params.searchParams.get('vm'); const vmConf = await db.getVM(vmName); const vmid = vmConf.vmid; const username = vmConf.pveuser; const password = vmConf.pvepass; try { accessBody = new URLSearchParams({username,password}); const accessTicket = await apiRequest('/access/ticket', { 'Content-Type': 'application/x-www-form-urlencoded'}, 'POST', accessBody); PVEAuthCookie = accessTicket.data.ticket CSRFPreventionToken = accessTicket.data.CSRFPreventionToken headers = { 'CSRFPreventionToken': CSRFPreventionToken, Cookie: `PVEAuthCookie=${PVEAuthCookie}` } const vms = await apiRequest('/cluster/resources?type=vm', headers, 'GET'); const vm = vms.data.find(vm => vm.vmid === vmid) if (!vm) throw new Error('VM not found'); const node = vm.node; const start = await apiRequest(`/nodes/${node}/qemu/${vmid}/status/start`, headers, 'POST'); res.json({message: 'VM successfully started'}) } catch (err) { console.error('Error starting VM', err.message); res.status(500).json({ error: err.message}); } }); app.get('/api/metadata', async(req,res) => { const user = req.cookies.username; const params = new URL(req.url, `https://${req.headers.host}`); const vmName = params.searchParams.get('vm'); const vmInfo = await db.getVMInfo(vmName); console.log(`${user} is getting information for ${vmName}`); let instructions = ''; instructions += await fetchInstructions('information'); const services = vmInfo.services.split(',').filter(service => service.trim() !== ''); if (services){ if (Array.isArray(services)){ for (const service of services){ const serviceInstructions = await fetchInstructions(service); if (serviceInstructions) instructions += `
${serviceInstructions}`; else console.error("Error getting instructions for ", service); } } else { const serviceInstructions = await fetchInstructions(services); if (serviceInstructions) instructions += `
${serviceInstructions}`; else console.error("Error getting instructions for ", services); } } for (const[key, value] of Object.entries(vmInfo)) { const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g'); instructions = instructions.replace(regex, value); } return res.json({access: false, instructions}); }); app.get('/api/getsnapshots', async(req,res) => { const params = new URL(req.url, `https://${req.headers.host}`); const vmName = params.searchParams.get('vm'); const vmConf = await db.getVM(vmName); const vmid = vmConf.vmid; const username = vmConf.pveuser; const password = vmConf.pvepass; try{ console.log(`Trying to fetch snapshots for ${vmName}`) accessBody = new URLSearchParams({username,password}); const accessTicket = await apiRequest('/access/ticket', { 'Content-Type': 'application/x-www-form-urlencoded'}, 'POST', accessBody); PVEAuthCookie = accessTicket.data.ticket CSRFPreventionToken = accessTicket.data.CSRFPreventionToken headers = { 'CSRFPreventionToken': CSRFPreventionToken, Cookie: `PVEAuthCookie=${PVEAuthCookie}` } const vms = await apiRequest('/cluster/resources?type=vm', headers, 'GET'); const vm = vms.data.find(vm => vm.vmid === vmid) if (!vm) throw new Error('VM not found'); const node = vm.node; const snapshots = await apiRequest(`/nodes/${node}/qemu/${vmid}/snapshot`, headers, 'GET'); snapshots.data.sort((a,b) => a.snaptime - b.snaptime); const data = snapshots.data; let snapshotList = { "Snapshot name" : [ "Description", "Date", "Rollback" ] }; for(let i = 0; i < (data.length - 1); i++){ snapshotList[data[i].name] = [ data[i].description, data[i].snaptime ] }; return res.json(snapshotList); } catch (err){ console.error("Issue with retrieving list of snapshots", err.message); } }); app.get('/api/rollback', async (req,res) => { const params = new URL(req.url, `https://${req.headers.host}`); const vmName = params.searchParams.get('vm'); const snapName = params.searchParams.get('snap'); const vmConf = await db.getVM(vmName); const vmid = vmConf.vmid; const username = vmConf.pveuser; const password = vmConf.pvepass; try { console.log(`Trying to rollback ${vmName} to snapshot ${snapName}`) accessBody = new URLSearchParams({username,password}); const accessTicket = await apiRequest('/access/ticket', { 'Content-Type': 'application/x-www-form-urlencoded'}, 'POST', accessBody); PVEAuthCookie = accessTicket.data.ticket CSRFPreventionToken = accessTicket.data.CSRFPreventionToken headers = { 'CSRFPreventionToken': CSRFPreventionToken, Cookie: `PVEAuthCookie=${PVEAuthCookie}` } const vms = await apiRequest('/cluster/resources?type=vm', headers, 'GET'); const vm = vms.data.find(vm => vm.vmid === vmid) if (!vm) throw new Error('VM not found'); const node = vm.node; const rollback = await apiRequest(`/nodes/${node}/qemu/${vmid}/snapshot/${snapName}/rollback`, headers, 'POST'); return res.json({success: true}); } catch (err){ return res.status(500).json({success: false, message: "Error rollinback", err}); } }); app.use(express.static(path.join(__dirname, 'public'))); app.get('/*splat', async (req, res) => { console.log(req.originalUrl); const token = req.cookies[tokenCookie]; if(!token) return res.redirect('/login'); const userInfo = await oauth.introspectToken(token); if (!userInfo?.active) return res.redirect(`/login?next=${req.originalUrl}`); res.sendFile(path.join(__dirname, 'public', 'index.html')); }); const wss = new WebSocket.Server({server, path: '/vnc'}); wss.on('connection', (clientSocket, req) => { const params = new URL(req.url, `https://${req.headers.host}`); const ticket = decodeURIComponent(params.searchParams.get('ticket')); console.log(`WebSocket connection recieved at /vnc with ticket: ${ticket}`); const vncSession = vncSessions.get(ticket); if (!vncSession) { clientSocket.close(1008, 'invalid or expired ticket'); return; } vncSessions.delete(ticket); const {nodeUrl, port, vmid, PVEAuthCookie} = vncSession; const targetUrl = `wss://${nodeUrl}/nodes/${node}/qemu/${vmid}/vncwebsocket?port=${port}&vncticket=${encodeURIComponent(ticket)}`; console.log('Attemping to connect websocket to:', targetUrl) const targetSocket = new WebSocket(targetUrl, 'binary', { headers: { Cookie: `PVEAuthCookie=${PVEAuthCookie}` }, agent }) targetSocket.on('error', (err) => { console.error('TCP socket error:', err.message); clientSocket.close(); }); clientSocket.on('message', (msg) => { targetSocket.send(msg); }); targetSocket.on('message', (msg) => { clientSocket.send(msg); }); const closeAll = () => { clientSocket.close(); targetSocket.close(); }; clientSocket.on('close', closeAll); targetSocket.on('close', closeAll); })