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.
197 lines
5.7 KiB
197 lines
5.7 KiB
import * as db from '../db.js';
|
|
import * as acme from '../acme.js';
|
|
import url from 'node:url';
|
|
import { dynamicResponse } from '../util.js';
|
|
import * as redis from '../redis.js';
|
|
import psl from 'psl';
|
|
import { nsTemplate, soaTemplate } from '../templates.js';
|
|
|
|
/**
|
|
* GET /domains
|
|
* domains page
|
|
*/
|
|
export async function domainsPage(app, req, res) {
|
|
const certs = await db.db().collection('certs')
|
|
.find({
|
|
username: res.locals.user.username,
|
|
}, {
|
|
projection: {
|
|
_id: 1,
|
|
subject: 1,
|
|
altnames: 1,
|
|
date: 1,
|
|
storageName: 1,
|
|
}
|
|
})
|
|
.toArray();
|
|
certs.forEach(c => c.date = c.date.toISOString());
|
|
res.locals.data = {
|
|
user: res.locals.user,
|
|
csrf: req.csrfToken(),
|
|
certs: certs || [],
|
|
};
|
|
return app.render(req, res, '/domains');
|
|
};
|
|
|
|
/**
|
|
* GET /domains.json
|
|
* domains json data
|
|
*/
|
|
export async function domainsJson(req, res) {
|
|
const certs = await db.db().collection('certs')
|
|
.find({
|
|
username: res.locals.user.username,
|
|
}, {
|
|
projection: {
|
|
_id: 1,
|
|
subject: 1,
|
|
altnames: 1,
|
|
date: 1,
|
|
storageName: 1,
|
|
}
|
|
})
|
|
.toArray();
|
|
certs.forEach(c => c.date = c.date.toISOString());
|
|
return res.json({
|
|
csrf: req.csrfToken(),
|
|
user: res.locals.user,
|
|
certs: certs || [],
|
|
});
|
|
};
|
|
|
|
/**
|
|
* POST /domain/add
|
|
* add domain
|
|
*/
|
|
export async function addDomain(req, res, next) {
|
|
|
|
if (!req.body.domain || typeof req.body.domain !== 'string' || req.body.domain.length === 0) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid input' });
|
|
}
|
|
|
|
if (res.locals.user.username !== 'admin' && res.locals.user.domains && res.locals.user.domains.length >= 50) {
|
|
return dynamicResponse(req, res, 403, { error: 'Domain limit reached' });
|
|
}
|
|
|
|
let domain = req.body.domain.toLowerCase();
|
|
|
|
if (res.locals.user.domains.includes(domain)) {
|
|
return dynamicResponse(req, res, 403, { error: 'Domain already added' });
|
|
}
|
|
|
|
try {
|
|
const { hostname } = url.parse(`https://${domain}`);
|
|
domain = hostname;
|
|
} catch (e) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid input' });
|
|
}
|
|
|
|
try {
|
|
const parsed = psl.parse(domain);
|
|
if (!parsed || !parsed.domain) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid input' });
|
|
}
|
|
if (parsed.domain !== domain && !res.locals.user.domains.includes(parsed.domain)) {
|
|
return dynamicResponse(req, res, 403, { error: 'Add the root domain before adding subdomains' });
|
|
}
|
|
const domains = [domain, parsed.domain];
|
|
const existing = await db.db().collection('accounts')
|
|
.findOne({
|
|
'$or': [
|
|
{ domains: domain },
|
|
{ domains: new RegExp(`${parsed.domain}$`), _id: { '$ne': res.locals.user.username } },
|
|
]
|
|
});
|
|
if (existing) {
|
|
return dynamicResponse(req, res, 400, { error: 'This domain is already in use or belongs to another user' });
|
|
}
|
|
await db.db().collection('accounts')
|
|
.updateOne({
|
|
_id: res.locals.user.username
|
|
}, {
|
|
$addToSet: {
|
|
domains: {
|
|
'$each': domains,
|
|
}
|
|
}
|
|
});
|
|
await res.locals
|
|
.dataPlaneAll('addPayloadRuntimeMap', {
|
|
name: process.env.DOMTOACC_MAP_NAME,
|
|
}, [{
|
|
key: domain,
|
|
value: res.locals.user.username,
|
|
}], null, false, false);
|
|
if (domain.split('.').length < 3 //naive
|
|
&& soaTemplate && nsTemplate) {
|
|
const records = [];
|
|
const soaRecords = JSON.parse(JSON.stringify(soaTemplate()));
|
|
soaRecords[0].MBox = `root.${domain}.`;
|
|
soaRecords[0].l = true;
|
|
soaRecords[0].t = true;
|
|
const nsRecords = JSON.parse(JSON.stringify(nsTemplate()));
|
|
nsRecords.forEach(r => {
|
|
r.l = true;
|
|
r.t = true;
|
|
});
|
|
let recordSetRaw = await redis.hget(`dns:${domain}.`, '@');
|
|
if (!recordSetRaw) {
|
|
recordSetRaw = {};
|
|
}
|
|
recordSetRaw['soa'] = soaRecords[0];
|
|
recordSetRaw['ns'] = nsRecords;
|
|
await redis.hset(`dns:${domain}.`, '@', recordSetRaw);
|
|
}
|
|
} catch (e) {
|
|
return next(e);
|
|
}
|
|
|
|
return dynamicResponse(req, res, 302, { redirect: req.body.onboarding ? '/onboarding' : '/domains' });
|
|
|
|
};
|
|
|
|
/**
|
|
* POST /domain/delete
|
|
* delete domain
|
|
*/
|
|
export async function deleteDomain(req, res) {
|
|
|
|
if (!req.body.domain || typeof req.body.domain !== 'string' || req.body.domain.length === 0
|
|
|| !res.locals.user.domains.includes(req.body.domain)) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid input' });
|
|
}
|
|
|
|
const domain = req.body.domain.toLowerCase();
|
|
|
|
//TODO: make loop through each cluster? or make domains per-cluster, hmmm
|
|
const [existingHost, existingMaintenance, existingRewrite, existingDdos] = await Promise.all([
|
|
res.locals.dataPlaneRetry('showRuntimeMap', { map: process.env.HOSTS_MAP_NAME })
|
|
.then(res => res.data).then(map => map.some(e => e.key === domain)),
|
|
res.locals.dataPlaneRetry('showRuntimeMap', { map: process.env.MAINTENANCE_MAP_NAME })
|
|
.then(res => res.data).then(map => map.some(e => e.key === domain)),
|
|
res.locals.dataPlaneRetry('showRuntimeMap', { map: process.env.REWRITE_MAP_NAME })
|
|
.then(res => res.data).then(map => map.some(e => e.key === domain)),
|
|
res.locals.dataPlaneRetry('showRuntimeMap', { map: process.env.DDOS_MAP_NAME })
|
|
.then(res => res.data).then(map => map.some(e => {
|
|
const { hostname, pathname } = url.parse(`https://${e.key}`);
|
|
return hostname === domain;
|
|
}))
|
|
]);
|
|
|
|
if (existingHost || existingMaintenance || existingRewrite || existingDdos) {
|
|
return dynamicResponse(req, res, 400, { error: 'Cannot remove domain while still in use. Remove it from backends/maintenance/rewrites/protection first.' });
|
|
}
|
|
|
|
await db.db().collection('accounts')
|
|
.updateOne({_id: res.locals.user.username}, {$pull: {domains: domain }});
|
|
await res.locals
|
|
.dataPlaneAll('deleteRuntimeMapEntry', {
|
|
map: process.env.DOMTOACC_MAP_NAME,
|
|
id: domain,
|
|
}, null, null, false, false);
|
|
await redis.del(`dns:${domain}.`);
|
|
|
|
return dynamicResponse(req, res, 302, { redirect: '/domains' });
|
|
|
|
};
|
|
|
|
|