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.
316 lines
9.3 KiB
316 lines
9.3 KiB
import dotenv from 'dotenv';
|
|
await dotenv.config({ path: '.env' });
|
|
import * as db from '../db.js';
|
|
import * as acme from '../acme.js';
|
|
import url from 'node:url';
|
|
import { dynamicResponse, wildcardAllowed, filterCertsByDomain } from '../util.js';
|
|
import { verifyCSR } from '../ca.js';
|
|
import { Resolver } from 'node:dns/promises';
|
|
import psl from 'psl';
|
|
|
|
const resolver = new Resolver();
|
|
resolver.setServers(process.env.NAMESERVERS.split(','));
|
|
|
|
/**
|
|
* GET /certs
|
|
* certs page
|
|
*/
|
|
export async function certsPage(app, req, res) {
|
|
const dbCerts = await db.db().collection('certs')
|
|
.find({
|
|
username: res.locals.user.username,
|
|
}, {
|
|
projection: {
|
|
_id: 1,
|
|
subject: 1,
|
|
altnames: 1,
|
|
date: 1,
|
|
storageName: 1,
|
|
}
|
|
})
|
|
.toArray();
|
|
dbCerts.forEach(c => c.date = c.date.toISOString());
|
|
const clusterCerts = await res.locals
|
|
.dataPlaneRetry('getAllStorageSSLCertificates')
|
|
.then(certs => filterCertsByDomain(certs.data, res.locals.user.domains));
|
|
res.locals.data = {
|
|
csrf: req.csrfToken(),
|
|
user: res.locals.user,
|
|
dbCerts: dbCerts || [],
|
|
clusterCerts: clusterCerts || [],
|
|
};
|
|
return app.render(req, res, '/certs');
|
|
};
|
|
|
|
/**
|
|
* GET /certs.json
|
|
* certs json data
|
|
*/
|
|
export async function certsJson(req, res) {
|
|
const dbCerts = await db.db().collection('certs')
|
|
.find({
|
|
username: res.locals.user.username,
|
|
}, {
|
|
projection: {
|
|
_id: 1,
|
|
subject: 1,
|
|
altnames: 1,
|
|
date: 1,
|
|
storageName: 1,
|
|
}
|
|
})
|
|
.toArray();
|
|
dbCerts.forEach(c => c.date = c.date.toISOString());
|
|
const clusterCerts = await res.locals
|
|
.dataPlaneRetry('getAllStorageSSLCertificates')
|
|
.then(certs => filterCertsByDomain(certs.data, res.locals.user.domains));
|
|
return res.json({
|
|
csrf: req.csrfToken(),
|
|
user: res.locals.user,
|
|
dbCerts: dbCerts || [],
|
|
clusterCerts: clusterCerts || [],
|
|
});
|
|
};
|
|
|
|
/**
|
|
* POST /cert/add
|
|
* add cert
|
|
*/
|
|
export async function addCert(req, res, next) {
|
|
if (!req.body.subject || typeof req.body.subject !== 'string' || req.body.subject.length === 0) {
|
|
return dynamicResponse(req, res, 400, { error: 'Missing subject' });
|
|
}
|
|
req.body.subject = req.body.subject.trim();
|
|
if (!req.body.altnames || typeof req.body.altnames !== 'object') {
|
|
return dynamicResponse(req, res, 400, { error: 'Missing altname(s)' });
|
|
}
|
|
req.body.altnames = req.body.altnames.map(x => x.trim());
|
|
|
|
const rootDomain = psl.parse(req.body.subject.replace('*', 'x')).domain;
|
|
let certDomainNameservers = [];
|
|
try {
|
|
certDomainNameservers = await resolver.resolve(rootDomain, 'NS');
|
|
} catch(e) {
|
|
console.warn(e); //probably just no NS records, bad domain
|
|
certDomainNameservers = null;
|
|
}
|
|
if (!certDomainNameservers || certDomainNameservers.some(d => ![ 'ns1.basedns.net', 'ns2.basedns.cloud', 'ns3.basedns.services' ].includes(d))) {
|
|
return dynamicResponse(req, res, 400, { error: 'Domain nameservers are not correct, please visit onboarding' });
|
|
}
|
|
|
|
if (!(res.locals.user.domains.includes(req.body.subject)
|
|
|| (req.body.subject.startsWith('*.') && wildcardAllowed(req.body.subject, res.locals.user.domains)))) {
|
|
return dynamicResponse(req, res, 400, { error: `You don't have permission to generate a certificate with subject ${req.body.subject}` });
|
|
}
|
|
if (!req.body.altnames.every(altName => {
|
|
return res.locals.user.domains.includes(altName)
|
|
|| (altName.startsWith('*.') && wildcardAllowed(req.body.subject, res.locals.user.domains));
|
|
})) {
|
|
return dynamicResponse(req, res, 400, { error: `You don't have permission to generate a certificate with altname(s) ${req.body.altnames}` });
|
|
}
|
|
|
|
if (req.body.email && (typeof req.body.email !== 'string'
|
|
|| !/^\S+@\S+\.\S+$/.test(req.body.email))) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid email' });
|
|
}
|
|
|
|
const subject = req.body.subject.toLowerCase();
|
|
const altnames = req.body.altnames.map(a => a.toLowerCase());
|
|
const email = req.body.email;
|
|
|
|
const existingCert = await db.db().collection('certs').findOne({ _id: subject });
|
|
if (existingCert) {
|
|
return dynamicResponse(req, res, 400, { error: 'Cert with this subject already exists' });
|
|
}
|
|
|
|
try {
|
|
url.parse(`https://${subject}`);
|
|
altnames.forEach(d => {
|
|
url.parse(`https://${d}`);
|
|
});
|
|
} catch (e) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid input' });
|
|
}
|
|
|
|
try {
|
|
console.log('Add cert request:', subject, altnames);
|
|
const { csr, key, cert, haproxyCert, date } = await acme.generate(subject, altnames, email, ['dns-01', 'http-01']);
|
|
const { message, description, file, storage_name: storageName } = await res.locals.postFileAll(
|
|
'/v3/services/haproxy/storage/ssl_certificates',
|
|
{
|
|
method: 'POST',
|
|
headers: { 'authorization': res.locals.dataPlane.defaults.headers.authorization },
|
|
},
|
|
haproxyCert,
|
|
{
|
|
filename: `${subject}.pem`,
|
|
contentType: 'text/plain',
|
|
}
|
|
);
|
|
if (message) {
|
|
return dynamicResponse(req, res, 400, { error: message });
|
|
}
|
|
let update = {
|
|
_id: subject,
|
|
subject: subject,
|
|
altnames: altnames,
|
|
email: email,
|
|
username: res.locals.user.username,
|
|
csr, key, cert, haproxyCert, // cert creation data
|
|
date,
|
|
};
|
|
if (description) {
|
|
//may be null due to "already exists", so we keep existing props
|
|
update = { ...update, description, file, storageName };
|
|
}
|
|
await db.db().collection('certs')
|
|
.updateOne({
|
|
_id: subject,
|
|
}, {
|
|
$set: update,
|
|
}, {
|
|
upsert: true,
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
return next(e);
|
|
}
|
|
|
|
return dynamicResponse(req, res, 302, { redirect: req.body.onboarding ? '/onboarding' : '/certs' });
|
|
|
|
};
|
|
|
|
/**
|
|
* POST /cert/upload
|
|
* push existing db cert to cluster
|
|
*/
|
|
export async function uploadCert(req, res, next) {
|
|
|
|
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();
|
|
|
|
const existingCert = await db.db().collection('certs').findOne({ _id: domain, username: res.locals.user.username });
|
|
if (!existingCert || !existingCert.haproxyCert) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid input' });
|
|
}
|
|
|
|
try {
|
|
console.log('Upload cert:', existingCert.subject);
|
|
const { message } = await res.locals.postFileAll(
|
|
'/v3/services/haproxy/storage/ssl_certificates',
|
|
{
|
|
method: 'POST',
|
|
headers: { 'authorization': res.locals.dataPlane.defaults.headers.authorization },
|
|
},
|
|
existingCert.haproxyCert,
|
|
{
|
|
filename: `${existingCert.subject}.pem`,
|
|
contentType: 'text/plain',
|
|
}
|
|
);
|
|
if (message) {
|
|
return dynamicResponse(req, res, 400, { error: message });
|
|
}
|
|
} catch (e) {
|
|
return next(e);
|
|
}
|
|
|
|
return dynamicResponse(req, res, 302, { redirect: '/certs' });
|
|
|
|
};
|
|
|
|
/**
|
|
* POST /cert/delete
|
|
* delete cers
|
|
*/
|
|
export async function deleteCert(req, res) {
|
|
|
|
if (!req.body.subject || typeof req.body.subject !== 'string' || req.body.subject.length === 0
|
|
//|| !res.locals.user.domains.includes(req.body.subject)
|
|
) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid input' });
|
|
}
|
|
|
|
const subject = req.body.subject.toLowerCase();
|
|
|
|
const clusterCerts = await res.locals
|
|
.dataPlaneRetry('getAllStorageSSLCertificates')
|
|
.then(certs => filterCertsByDomain(certs.data, res.locals.user.domains));
|
|
|
|
//Delete cert from cluster if storage_name sent
|
|
if (req.body.storage_name && typeof req.body.storage_name === 'string') {
|
|
const storageName = req.body.storage_name;
|
|
const clusterCerts = await res.locals
|
|
.dataPlaneRetry('getAllStorageSSLCertificates')
|
|
.then(certs => filterCertsByDomain(certs.data, res.locals.user.domains));
|
|
if (!clusterCerts.find(c => c.storage_name === req.body.storage_name)) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid input' });
|
|
}
|
|
await res.locals
|
|
.dataPlaneAll('deleteStorageSSLCertificate', {
|
|
name: storageName,
|
|
skip_reload: true,
|
|
}, null, null, false, true);
|
|
return dynamicResponse(req, res, 302, { redirect: '/certs' });
|
|
}
|
|
|
|
//otherwise completely delete from db
|
|
await db.db().collection('certs')
|
|
.deleteOne({ _id: subject, username: res.locals.user.username });
|
|
|
|
return dynamicResponse(req, res, 302, { redirect: '/certs' });
|
|
|
|
};
|
|
|
|
/**
|
|
* POST /csr/verify
|
|
* Delete the map entries of the body 'domain'
|
|
*/
|
|
export async function verifyUserCSR(req, res, next) {
|
|
if(!req.body || !req.body.csr || typeof req.body.csr !== 'string' || req.body.csr.length === 0) {
|
|
return dynamicResponse(req, res, 400, { error: 'Invalid CSR' });
|
|
}
|
|
try {
|
|
const serial = await db.db().collection('certs')
|
|
.findOneAndUpdate({
|
|
_id: 'serial',
|
|
}, {
|
|
$inc: {
|
|
number: 1,
|
|
},
|
|
}, {
|
|
upsert: true,
|
|
});
|
|
await db.db().collection('accounts')
|
|
.updateOne({
|
|
_id: res.locals.user.username
|
|
}, {
|
|
'$set': {
|
|
onboarding: true, //skip during onboarding
|
|
}
|
|
});
|
|
const serialNumber = serial && serial.value && serial.value.number || 1;
|
|
console.log('Attempting to sign CSR, serial', serialNumber);
|
|
const signedCert = verifyCSR(req.body.csr, res.locals.user.domains, serialNumber);
|
|
if (req.body.json) {
|
|
return res.json({
|
|
csrf: req.csrfToken(),
|
|
user: res.locals.user,
|
|
csr: signedCert,
|
|
});
|
|
} if (req.headers['accept'].toLowerCase() === 'application/json') {
|
|
return res.send(signedCert); //for ansible
|
|
}
|
|
return dynamicResponse(req, res, 200, `<pre>${signedCert}</pre>`);
|
|
} catch (e) {
|
|
console.error(e);
|
|
if (e.message) {
|
|
return dynamicResponse(req, res, 400, { error: e.message });
|
|
}
|
|
return next(e);
|
|
}
|
|
};
|
|
|