589 lines
18 KiB
JavaScript

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 += `<hr>${serviceInstructions}`;
else console.error("Error getting instructions for ", service);
}
} else {
const serviceInstructions = await fetchInstructions(services);
if (serviceInstructions) instructions += `<hr>${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);
})