589 lines
18 KiB
JavaScript
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);
|
|
})
|