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.
517 lines
16 KiB
517 lines
16 KiB
import { extractMap, dynamicResponse } from '../util.js';
|
|
import { createCIDR, parse } from 'ip6addr';
|
|
import * as db from '../db.js';
|
|
import url from 'url';
|
|
import countries from 'i18n-iso-countries';
|
|
const countryMap = countries.getAlpha2Codes();
|
|
const continentMap = {
|
|
'NA': 'North America',
|
|
'SA': 'South America',
|
|
'EU': 'Europe',
|
|
'AS': 'Asia',
|
|
'OC': 'Oceania',
|
|
'AF': 'Africa',
|
|
'AN': 'Antarctica',
|
|
};
|
|
/**
|
|
* GET /maps/:name
|
|
* Show map filtering to users domains
|
|
*/
|
|
export async function mapData(req, res, next) {
|
|
let map,
|
|
mapInfo,
|
|
showValues = false,
|
|
mapNotes = {};
|
|
try {
|
|
mapNotes = await db.db().collection('mapnotes').find({
|
|
username: res.locals.user.username,
|
|
map: req.params.name
|
|
}).toArray();
|
|
mapNotes = mapNotes.reduce((acc, note) => {
|
|
acc[note.key] = note.note;
|
|
return acc;
|
|
}, {});
|
|
mapInfo = await res.locals
|
|
.dataPlaneRetry('getOneRuntimeMap', req.params.name)
|
|
.then(res => res.data)
|
|
.then(extractMap);
|
|
if (!mapInfo) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid map' });
|
|
}
|
|
map = await res.locals
|
|
.dataPlaneRetry('showRuntimeMap', {
|
|
map: req.params.name
|
|
})
|
|
.then(res => res.data);
|
|
} catch (e) {
|
|
console.error(e);
|
|
return next(e);
|
|
}
|
|
|
|
switch (req.params.name) {
|
|
case process.env.DDOS_MAP_NAME:
|
|
case process.env.DDOS_CONFIG_MAP_NAME:
|
|
map = map.map(a => {
|
|
a.value = JSON.parse(a.value);
|
|
return a;
|
|
});
|
|
/* falls through */
|
|
case process.env.REWRITE_MAP_NAME:
|
|
case process.env.REDIRECT_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_IP_MAP_NAME:
|
|
case process.env.BLOCKED_ASN_MAP_NAME:
|
|
case process.env.BLOCKED_CC_MAP_NAME:
|
|
case process.env.BLOCKED_CN_MAP_NAME:
|
|
case process.env.WHITELIST_MAP_NAME:
|
|
map = map
|
|
.filter(a => {
|
|
return a.value && a.value.split(':').includes(res.locals.user.username);
|
|
})
|
|
.map(x => {
|
|
x.value = res.locals.user.username;
|
|
return x;
|
|
});
|
|
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,
|
|
mapNotes,
|
|
};
|
|
}
|
|
|
|
export async function mapPage(app, req, res, next) {
|
|
const data = await mapData(req, res, next);
|
|
res.locals.data = { ...data, user: res.locals.user };
|
|
return app.render(req, res, `/map/${data.name}`);
|
|
}
|
|
|
|
export async function mapJson(req, res, next) {
|
|
const data = await mapData(req, res, next);
|
|
return res.json({ ...data, user: res.locals.user });
|
|
}
|
|
|
|
/**
|
|
* POST /maps/:name/delete
|
|
* Delete the map entries of the body 'domain'
|
|
*/
|
|
export async function deleteMapForm(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.body && req.body.note && (typeof req.body.note !== 'string' || req.body.note.length > 200)) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid note' });
|
|
}
|
|
|
|
if (req.params.name === process.env.BLOCKED_IP_MAP_NAME
|
|
|| req.params.name === process.env.BLOCKED_ASN_MAP_NAME
|
|
|| req.params.name === process.env.BLOCKED_CC_MAP_NAME
|
|
|| req.params.name === process.env.BLOCKED_CN_MAP_NAME
|
|
|| req.params.name === process.env.WHITELIST_MAP_NAME) {
|
|
let value;
|
|
const existingEntries = await res.locals
|
|
.dataPlaneRetry('showRuntimeMap', {
|
|
map: req.params.name,
|
|
// id: req.body.key,
|
|
})
|
|
.then((res) => res.data)
|
|
.catch(() => {});
|
|
const existingEntry = existingEntries && existingEntries
|
|
.find(en => en.key === req.body.key);
|
|
console.log('existingEntry', existingEntry);
|
|
if (existingEntry && existingEntry.value) {
|
|
let existingEntries = existingEntry.value.split(':');
|
|
if (!existingEntries || !existingEntries.includes(res.locals.user.username)) {
|
|
return dynamicResponse(req, res, 403, { error: 'No permission to remove that entry' });
|
|
}
|
|
existingEntries = existingEntries.filter(e => e !== res.locals.user.username);
|
|
value = existingEntries.join(':'); //0 length if was only name
|
|
try {
|
|
if (value && value.length > 0) {
|
|
//if value still exists, other user has whitelisted, so replace withg updated value
|
|
await res.locals
|
|
.dataPlaneAll('replaceRuntimeMapEntry', {
|
|
map: req.params.name,
|
|
id: req.body.key,
|
|
}, {
|
|
value: value,
|
|
}, null, false, false);
|
|
} else {
|
|
//else we were the last/only one, so remove
|
|
await res.locals
|
|
.dataPlaneAll('deleteRuntimeMapEntry', {
|
|
map: req.params.name,
|
|
id: req.body.key,
|
|
}, null, null, false, false);
|
|
}
|
|
} catch (e) {
|
|
return next(e);
|
|
}
|
|
}
|
|
} else if (req.params.name === process.env.HOSTS_MAP_NAME
|
|
|| req.params.name === process.env.DDOS_MAP_NAME
|
|
|| req.params.name === process.env.DDOS_CONFIG_MAP_NAME
|
|
|| req.params.name === process.env.MAINTENANCE_MAP_NAME
|
|
|| req.params.name === process.env.REDIRECT_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' });
|
|
}
|
|
try {
|
|
if (process.env.CUSTOM_BACKENDS_ENABLED && req.params.name === process.env.HOSTS_MAP_NAME) {
|
|
//Make sure to also update backends map if editing hosts map and putting duplicate
|
|
const backendEntries = await res.locals
|
|
.dataPlaneRetry('showRuntimeMap', {
|
|
map: process.env.BACKENDS_MAP_NAME,
|
|
})
|
|
.then((res) => res.data);
|
|
const matchingBackends = backendEntries
|
|
.filter(mb => mb.key === req.body.key);
|
|
console.log('matchingBackends', matchingBackends);
|
|
await Promise.all(matchingBackends.map(async mb => {
|
|
return Promise.all([
|
|
res.locals
|
|
.dataPlaneAll('deleteRuntimeServer', {
|
|
backend: 'servers',
|
|
name: mb.value,
|
|
}, null, null, false, true),
|
|
res.locals
|
|
.dataPlaneAll('deleteRuntimeMapEntry', {
|
|
map: process.env.BACKENDS_MAP_NAME,
|
|
id: mb.key, //'example.com'
|
|
}, null, null, false, true)
|
|
]);
|
|
}));
|
|
}
|
|
await res.locals
|
|
.dataPlaneAll('deleteRuntimeMapEntry', {
|
|
map: req.params.name, //'ddos'
|
|
id: req.body.key, //'example.com'
|
|
}, null, null, false, false);
|
|
} catch (e) {
|
|
return next(e);
|
|
}
|
|
}
|
|
await db.db().collection('mapnotes').deleteMany({
|
|
username: res.locals.user.username,
|
|
map: req.params.name,
|
|
key: req.body.key,
|
|
});
|
|
return dynamicResponse(req, res, 302, { redirect: `/map/${req.params.name}` });
|
|
}
|
|
|
|
/**
|
|
* POST /maps/:name/add
|
|
* Add map entries of the body 'domain'
|
|
*/
|
|
export async function patchMapForm(req, res, next) {
|
|
if(req.body && req.body.key && typeof req.body.key === 'string') {
|
|
|
|
//validate key is domain
|
|
if (req.params.name === process.env.DDOS_MAP_NAME
|
|
|| req.params.name === process.env.DDOS_CONFIG_MAP_NAME
|
|
|| req.params.name === process.env.HOSTS_MAP_NAME
|
|
|| req.params.name === process.env.MAINTENANCE_MAP_NAME
|
|
|| req.params.name === process.env.REDIRECT_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_IP_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 key is ASN
|
|
if (req.params.name === process.env.BLOCKED_ASN_MAP_NAME) {
|
|
if (!/^\d+$/.test(req.body.key)) {
|
|
return dynamicResponse(req, res, 403, { error: 'Invalid ASN' });
|
|
}
|
|
//req.body.key is a number
|
|
}
|
|
|
|
//validate key is country code
|
|
if (req.params.name === process.env.BLOCKED_CC_MAP_NAME) {
|
|
if (!countryMap[req.body.key]) {
|
|
return dynamicResponse(req, res, 403, { error: 'Invalid country code' });
|
|
}
|
|
//req.body.key is a cc
|
|
}
|
|
|
|
//validate key is country code
|
|
if (req.params.name === process.env.BLOCKED_CN_MAP_NAME) {
|
|
if (!continentMap[req.body.key]) {
|
|
return dynamicResponse(req, res, 403, { error: 'Invalid continent code' });
|
|
}
|
|
//req.body.key is a cn
|
|
}
|
|
|
|
//validate value is url (roughly)
|
|
if (req.params.name === process.env.REWRITE_MAP_NAME
|
|
|| req.params.name === process.env.REDIRECT_MAP_NAME) {
|
|
try {
|
|
new URL(`http://${req.body.value}`);
|
|
} catch {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid input' });
|
|
}
|
|
}
|
|
|
|
//validate ddos_config
|
|
if (req.params.name === process.env.DDOS_CONFIG_MAP_NAME) {
|
|
const { pd, cex } = req.body;
|
|
if ((pd && (isNaN(pd) || parseInt(pd) !== +pd || pd < 8))
|
|
|| (cex && (isNaN(cex) || parseInt(cex) !== +cex))) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid input' });
|
|
}
|
|
}
|
|
|
|
//validate ddos
|
|
if (req.params.name === process.env.DDOS_MAP_NAME
|
|
&& (!req.body.m || !['0', '1', '2'].includes(req.body.m))) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid value' });
|
|
}
|
|
if (req.params.name === process.env.DDOS_MAP_NAME) {
|
|
const { m } = req.body; //t, v, etc
|
|
if (m && (isNaN(m) || parseInt(m) !== +m || m < 0)) {
|
|
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.REDIRECT_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_IP_MAP_NAME:
|
|
case process.env.BLOCKED_ASN_MAP_NAME:
|
|
case process.env.BLOCKED_CC_MAP_NAME:
|
|
case process.env.BLOCKED_CN_MAP_NAME:
|
|
case process.env.WHITELIST_MAP_NAME: {
|
|
const existingEntry = await res.locals
|
|
.dataPlaneRetry('getRuntimeMapEntry', {
|
|
map: req.params.name,
|
|
id: req.body.key,
|
|
})
|
|
.then((res) => res.data)
|
|
.catch(() => {});
|
|
if (existingEntry && existingEntry.value) {
|
|
const existingSplitEntries = existingEntry.value.split(':');
|
|
existingSplitEntries.push(res.locals.user.username);
|
|
const dedupedSplitEntries = [...new Set(existingSplitEntries)];
|
|
value = dedupedSplitEntries.join(':');
|
|
} else {
|
|
value = res.locals.user.username;
|
|
}
|
|
break;
|
|
}
|
|
case process.env.MAINTENANCE_MAP_NAME:
|
|
value = res.locals.user.username;
|
|
break;
|
|
case process.env.DDOS_MAP_NAME:
|
|
value = JSON.stringify({
|
|
m: parseInt(req.body.m || 1),
|
|
t: req.body.t != null,
|
|
});
|
|
break;
|
|
case process.env.DDOS_CONFIG_MAP_NAME:
|
|
value = JSON.stringify({
|
|
pd: parseInt(req.body.pd || 24),
|
|
pt: req.body.pt === 'argon2' ? 'argon2' : 'sha256',
|
|
cex: parseInt(req.body.cex || 21600),
|
|
cip: req.body.cip ? true : false,
|
|
});
|
|
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
|
|
.dataPlaneRetry('getRuntimeMapEntry', {
|
|
map: process.env.BACKENDS_MAP_NAME,
|
|
id: req.body.key,
|
|
})
|
|
.then(res => res.data)
|
|
.catch(() => {});
|
|
const freeSlotId = await res.locals
|
|
.dataPlaneRetry('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);
|
|
const serverNameIds = servers
|
|
.map(s => parseInt(s.name.substr(6)))
|
|
.sort((a, b) => a-b);
|
|
return Math.max(serverIds[serverIds.length-1], serverNameIds[serverNameIds.length-1])+1;
|
|
}
|
|
return 1;
|
|
});
|
|
if (!freeSlotId) {
|
|
return dynamicResponse(req, res, 400, { error: 'No server slots available' });
|
|
}
|
|
const { hostname: address, port } = new URL(`http://${value}`);
|
|
const serverName = `websrv${freeSlotId}`;
|
|
const runtimeServerResp = await res.locals
|
|
.dataPlaneAll('addRuntimeServer', {
|
|
backend: 'servers',
|
|
}, {
|
|
address,
|
|
port: parseInt(port),
|
|
name: serverName,
|
|
// id: `${freeSlotId}`,
|
|
// ssl_cafile: '/usr/local/share/ca-certificates/dev-priv-ca/ca-cert.pem',
|
|
// ssl_cafile: '@system-ca',
|
|
ssl_reuse: 'enabled',
|
|
ssl: 'enabled',
|
|
verify: 'required',
|
|
}, null, false, true);
|
|
console.log('added runtime server', req.body.key, runtimeServerResp.data);
|
|
await res.locals
|
|
.dataPlaneAll("replaceRuntimeServer", {
|
|
name: serverName,
|
|
backend: "servers",
|
|
}, {
|
|
admin_state: "ready",
|
|
operational_state: "up",
|
|
}, null, false, true);
|
|
if (backendMapEntry) {
|
|
console.info('Setting multiple domain->ip entries for', req.body.key, backendMapEntry);
|
|
// Have to show the whole map because getRuntimeMapEntry will only have first value (why? beats me)
|
|
const fullBackendMap = await res.locals
|
|
.dataPlaneRetry('showRuntimeMap', {
|
|
map: process.env.BACKENDS_MAP_NAME
|
|
})
|
|
.then(res => res.data);
|
|
const fullBackendMapEntry = fullBackendMap
|
|
.find(entry => entry.key === req.body.key); //Find is OK because there shouldn't be duplicate keys
|
|
await res.locals
|
|
.dataPlaneAll('replaceRuntimeMapEntry', {
|
|
map: process.env.BACKENDS_MAP_NAME,
|
|
id: req.body.key,
|
|
}, {
|
|
value: `${fullBackendMapEntry.value},websrv${freeSlotId}`,
|
|
}, null, false, false);
|
|
} else {
|
|
await res.locals
|
|
.dataPlaneAll('addPayloadRuntimeMap', {
|
|
name: process.env.BACKENDS_MAP_NAME,
|
|
}, [{
|
|
key: req.body.key,
|
|
value: `websrv${freeSlotId}`,
|
|
}], null, false, false);
|
|
}
|
|
}
|
|
|
|
const existingEntry = req.params.name === process.env.HOSTS_MAP_NAME
|
|
? null
|
|
: (await res.locals
|
|
.dataPlaneRetry('getRuntimeMapEntry', {
|
|
map: req.params.name,
|
|
id: req.body.key,
|
|
})
|
|
.then(res => res.data)
|
|
.catch(() => {}));
|
|
if (existingEntry) {
|
|
await res.locals
|
|
.dataPlaneAll('replaceRuntimeMapEntry', {
|
|
map: req.params.name,
|
|
id: req.body.key,
|
|
}, {
|
|
value: value,
|
|
}, null, false, false);
|
|
} else {
|
|
await res.locals
|
|
.dataPlaneAll('addPayloadRuntimeMap', {
|
|
name: req.params.name
|
|
}, [{
|
|
key: req.body.key,
|
|
value: value,
|
|
}], null, null, false);
|
|
}
|
|
await db.db().collection('mapnotes').replaceOne({
|
|
username: res.locals.user.username,
|
|
map: req.params.name,
|
|
key: req.body.key,
|
|
}, {
|
|
username: res.locals.user.username,
|
|
map: req.params.name,
|
|
key: req.body.key,
|
|
note: req.body.note,
|
|
}, {
|
|
upsert: true,
|
|
});
|
|
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' });
|
|
}
|
|
|