Next.js+React web interface for controlling HAProxy clusters (groups of servers), in conjunction with with https://gitgud.io/fatchan/haproxy-protection.
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
296 lines
8.7 KiB
296 lines
8.7 KiB
|
|
const { extractMap, dynamicResponse } = require('../util.js');
|
|
const { createCIDR, parse } = require('ip6addr');
|
|
const url = require('url');
|
|
|
|
/**
|
|
* GET /maps/:name
|
|
* Show map filtering to users domains
|
|
*/
|
|
exports.mapData = async (req, res, next) => {
|
|
let map,
|
|
mapInfo,
|
|
showValues = false;
|
|
try {
|
|
mapInfo = await res.locals
|
|
.dataPlane.getOneRuntimeMap(req.params.name)
|
|
.then(res => res.data)
|
|
.then(extractMap);
|
|
if (!mapInfo) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid map' });
|
|
}
|
|
map = await res.locals
|
|
.dataPlane.showRuntimeMap({
|
|
map: req.params.name
|
|
})
|
|
.then(res => res.data);
|
|
} catch (e) {
|
|
return next(e);
|
|
}
|
|
|
|
switch (req.params.name) {
|
|
case process.env.REWRITE_MAP_NAME:
|
|
case process.env.DDOS_MAP_NAME:
|
|
showValues = true;
|
|
/* falls through */
|
|
case process.env.BACKENDS_MAP_NAME:
|
|
case process.env.HOSTS_MAP_NAME:
|
|
if (process.env.CUSTOM_BACKENDS_ENABLED) {
|
|
showValues = true;
|
|
}
|
|
/* falls through */
|
|
case process.env.MAINTENANCE_MAP_NAME:
|
|
map = map.filter(a => {
|
|
const { hostname } = url.parse(`https://${a.key}`);
|
|
return res.locals.user.domains.includes(hostname);
|
|
});
|
|
break;
|
|
case process.env.BLOCKED_MAP_NAME:
|
|
case process.env.WHITELIST_MAP_NAME:
|
|
map = map.filter(a => {
|
|
return res.locals.user.username === a.value;
|
|
});
|
|
break;
|
|
default:
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid map' });
|
|
}
|
|
|
|
return {
|
|
mapValueNames: { '0': 'None', '1': 'Proof-of-work', '2': 'Proof-of-work+Captcha' },
|
|
mapInfo,
|
|
map,
|
|
csrf: req.csrfToken(),
|
|
name: req.params.name,
|
|
showValues,
|
|
};
|
|
};
|
|
|
|
exports.mapPage = async (app, req, res, next) => {
|
|
const data = await exports.mapData(req, res, next);
|
|
return app.render(req, res, `/map/${data.name}`, data);
|
|
};
|
|
|
|
exports.mapJson = async (req, res, next) => {
|
|
const data = await exports.mapData(req, res, next);
|
|
return res.json({ ...data, user: res.locals.user });
|
|
};
|
|
|
|
/**
|
|
* POST /maps/:name/delete
|
|
* Delete the map entries of the body 'domain'
|
|
*/
|
|
exports.deleteMapForm = async (req, res, next) => {
|
|
if(!req.body || !req.body.key || typeof req.body.key !== 'string' || req.body.key.length === 0) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid value' });
|
|
}
|
|
|
|
if (req.params.name === process.env.HOSTS_MAP_NAME
|
|
|| req.params.name === process.env.DDOS_MAP_NAME
|
|
|| req.params.name === process.env.MAINTENANCE_MAP_NAME
|
|
|| req.params.name === process.env.REWRITE_MAP_NAME) {
|
|
const { hostname } = url.parse(`https://${req.body.key}`);
|
|
const allowed = res.locals.user.domains.includes(hostname);
|
|
if (!allowed) {
|
|
return dynamicResponse(req, res, 403, { error: 'No permission for that domain' });
|
|
}
|
|
} else if (req.params.name === process.env.BLOCKED_MAP_NAME
|
|
|| req.params.name === process.env.WHITELIST_MAP_NAME) {
|
|
//TODO: permission check, see https://gitgud.io/fatchan/haproxy-panel/-/issues/10
|
|
}
|
|
|
|
try {
|
|
|
|
if (process.env.CUSTOM_BACKENDS_ENABLED && req.params.name === process.env.HOSTS_MAP_NAME) {
|
|
const backendMapEntry = await res.locals
|
|
.dataPlane.getRuntimeMapEntry({
|
|
map: process.env.BACKENDS_MAP_NAME,
|
|
id: req.body.key,
|
|
})
|
|
.then(res => res.data)
|
|
.catch(() => {});
|
|
if (backendMapEntry) {
|
|
await res.locals
|
|
.dataPlaneAll('deleteRuntimeServer', {
|
|
backend: 'servers',
|
|
name: backendMapEntry.value,
|
|
});
|
|
await res.locals
|
|
.dataPlaneAll('deleteRuntimeMapEntry', {
|
|
map: process.env.BACKENDS_MAP_NAME, //'backends'
|
|
id: req.body.key, //'example.com'
|
|
});
|
|
} else {
|
|
console.warn('no backend found to remove');
|
|
//dont return because otherwise they will have a domain stuck in the hosts map
|
|
}
|
|
}
|
|
|
|
await res.locals
|
|
.dataPlaneAll('deleteRuntimeMapEntry', {
|
|
map: req.params.name, //'ddos'
|
|
id: req.body.key, //'example.com'
|
|
});
|
|
return dynamicResponse(req, res, 302, { redirect: `/map/${req.params.name}` });
|
|
} catch (e) {
|
|
return next(e);
|
|
}
|
|
|
|
};
|
|
|
|
|
|
/**
|
|
* POST /maps/:name/add
|
|
* Add map entries of the body 'domain'
|
|
*/
|
|
exports.patchMapForm = async (req, res, next) => {
|
|
if(req.body && req.body.key && typeof req.body.key === 'string') {
|
|
|
|
//ddos must have valid 0, 1, 2
|
|
if (req.params.name === process.env.DDOS_MAP_NAME
|
|
&& (!req.body.value || !['0', '1', '2'].includes(req.body.value))) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid value' });
|
|
}
|
|
|
|
//validate key is domain
|
|
if (req.params.name === process.env.DDOS_MAP_NAME
|
|
|| req.params.name === process.env.HOSTS_MAP_NAME
|
|
|| req.params.name === process.env.MAINTENANCE_MAP_NAME
|
|
|| req.params.name === process.env.REWRITE_MAP_NAME) {
|
|
const { hostname } = url.parse(`https://${req.body.key}`);
|
|
const allowed = res.locals.user.domains.includes(hostname);
|
|
if (!allowed) {
|
|
return dynamicResponse(req, res, 403, { error: 'No permission for that domain' });
|
|
}
|
|
}
|
|
|
|
//validate key is valid ip address
|
|
if (req.params.name === process.env.BLOCKED_MAP_NAME
|
|
|| req.params.name === process.env.WHITELIST_MAP_NAME) {
|
|
let parsedIp, parsedSubnet;
|
|
try {
|
|
parsedIp = parse(req.body.key);
|
|
} catch { parsedIp = null; /*invalid ip, or a subnet*/ }
|
|
try {
|
|
parsedSubnet = createCIDR(req.body.key);
|
|
} catch { parsedSubnet = null; /*invalid subnet or just an ip*/ }
|
|
const parsedIpOrSubnet = parsedIp || parsedSubnet;
|
|
if (!parsedIpOrSubnet) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid input' });
|
|
}
|
|
req.body.key = parsedIpOrSubnet.toString({zeroElide: false, zeroPad:false});
|
|
}
|
|
|
|
//validate value is url (roughly)
|
|
if (req.params.name === process.env.REWRITE_MAP_NAME) {
|
|
try {
|
|
new URL(`http://${req.body.value}`);
|
|
} catch {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid input' });
|
|
}
|
|
}
|
|
|
|
//validate value is IP:port
|
|
if (process.env.CUSTOM_BACKENDS_ENABLED && req.params.name === process.env.HOSTS_MAP_NAME) {
|
|
let parsedValue;
|
|
try {
|
|
parsedValue = url.parse(`https://${req.body.value}`)
|
|
if (!parsedValue.host || !parsedValue.port) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid input' });
|
|
}
|
|
parse(parsedValue.hostname); //better ip parsing, will error if invalid
|
|
} catch {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid input' });
|
|
}
|
|
req.body.value = parsedValue.host; //host includes port
|
|
}
|
|
|
|
let value;
|
|
switch (req.params.name) {
|
|
case process.env.REWRITE_MAP_NAME:
|
|
case process.env.DDOS_MAP_NAME:
|
|
value = req.body.value;
|
|
break;
|
|
case process.env.HOSTS_MAP_NAME:
|
|
if (process.env.CUSTOM_BACKENDS_ENABLED) {
|
|
value = req.body.value;
|
|
} else {
|
|
value = 0;
|
|
}
|
|
break;
|
|
case process.env.BLOCKED_MAP_NAME:
|
|
case process.env.WHITELIST_MAP_NAME:
|
|
case process.env.MAINTENANCE_MAP_NAME:
|
|
value = res.locals.user.username;
|
|
break;
|
|
default:
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid map' });
|
|
}
|
|
|
|
try {
|
|
|
|
if (process.env.CUSTOM_BACKENDS_ENABLED && req.params.name === process.env.HOSTS_MAP_NAME) {
|
|
const backendMapEntry = await res.locals
|
|
.dataPlane.getRuntimeMapEntry({
|
|
map: process.env.BACKENDS_MAP_NAME,
|
|
id: req.body.key,
|
|
})
|
|
.then(res => res.data)
|
|
.catch(() => {});
|
|
if (backendMapEntry) {
|
|
//TODO: allow multiple backends (but requires reworking haproxy.cfg)
|
|
return dynamicResponse(req, res, 400, { error: 'Domain already has a backend server mapping' });
|
|
} else {
|
|
const freeSlotId = await res.locals.dataPlane
|
|
.getRuntimeServers({
|
|
backend: 'servers'
|
|
})
|
|
.then(res => res.data)
|
|
.then(servers => {
|
|
if (servers.length > 0) {
|
|
const serverIds = servers
|
|
.map(s => parseInt(s.id))
|
|
.sort((a, b) => a-b);
|
|
return serverIds[serverIds.length-1]+1;
|
|
}
|
|
return 1;
|
|
});
|
|
if (!freeSlotId) {
|
|
return dynamicResponse(req, res, 400, { error: 'No server slots available' });
|
|
}
|
|
const [address, port] = value.split(':');
|
|
const runtimeServerResp = await res.locals
|
|
.dataPlaneAll('addRuntimeServer', {
|
|
backend: 'servers',
|
|
}, {
|
|
address,
|
|
port: parseInt(port),
|
|
name: `websrv${freeSlotId}`,
|
|
id: `${freeSlotId}`,
|
|
ssl: 'enabled',
|
|
verify: 'none',
|
|
});
|
|
console.log('added runtime server', req.body.key, runtimeServerResp.data);
|
|
await res.locals
|
|
.dataPlaneAll('addPayloadRuntimeMap', {
|
|
name: process.env.BACKENDS_MAP_NAME,
|
|
}, [{
|
|
key: req.body.key,
|
|
value: `websrv${freeSlotId}`,
|
|
}]);
|
|
}
|
|
}
|
|
|
|
await res.locals
|
|
.dataPlaneAll('addPayloadRuntimeMap', {
|
|
name: req.params.name
|
|
}, [{
|
|
key: req.body.key,
|
|
value: value,
|
|
}]);
|
|
return dynamicResponse(req, res, 302, { redirect: req.body.onboarding ? '/onboarding' : `/map/${req.params.name}` });
|
|
} catch (e) {
|
|
return next(e);
|
|
}
|
|
}
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid value' });
|
|
};
|
|
|