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.
 
 

271 lines
7.5 KiB

import React, { useState, useEffect } from 'react';
import Head from 'next/head';
import BackButton from '../components/BackButton.js';
import ErrorAlert from '../components/ErrorAlert.js';
import SearchFilter from '../components/SearchFilter.js';
import * as API from '../api.js';
import { getApproxSubject } from '../util.js';
import { useRouter } from 'next/router';
export default function Certs(props) {
const router = useRouter();
const [state, dispatch] = useState(props);
const [error, setError] = useState();
const [filter, setFilter] = useState('');
useEffect(() => {
if (!state.user || state.dbCerts == null) {
API.getCerts(dispatch, setError, router)
.then(() => {
setTimeout(() => {
location.hash = location.hash;
}, 10);
});
}
}, [state.user, state.dbCerts, router]);
const { user, csrf, dbCerts, clusterCerts } = state || {};
useEffect(() => {
if (user && !user.onboarding) {
router.push('/onboarding');
}
}, []);
if (!state.user || state.dbCerts == null) {
return (
<div className='d-flex flex-column'>
{error && <ErrorAlert error={error} />}
<div className='text-center mb-4'>
<div className='spinner-border mt-5' role='status'>
<span className='visually-hidden'>Loading...</span>
</div>
</div>
</div>
);
}
async function addCert(e) {
e.preventDefault();
try {
setError(null);
const subject = e.target.subject.value.trim();
const altnames = e.target.altnames.value.split(/\r?\n/).map(x => x.trim());
if (subject.startsWith('*.')
&& altnames.every(an => an.startsWith('*.'))) {
if (!confirm('Are you sure you want to generate a wildcard certificate without including the non-wildcard domain?')) {
return;
}
}
await API.addCert({
_csrf: csrf,
subject: subject,
altnames: altnames,
email: e.target.email.value,
}, dispatch, setError, router);
e.target.reset();
} catch(err) {
console.warn(err);
await new Promise(res => setTimeout(res, 10000));
}
await API.getCerts(dispatch, setError, router);
}
async function deleteCert(csrf, subject, storageName) {
setError(null);
await API.deleteCert({
_csrf: csrf,
subject,
storage_name: storageName,
}, dispatch, setError, router);
await API.getCerts(dispatch, setError, router);
}
async function uploadCert(csrf, domain) {
try {
setError(null);
await API.uploadCert({
_csrf: csrf,
domain: domain
}, dispatch, setError, router);
} catch(err) {
console.warn(err);
await new Promise(res => setTimeout(res, 10000));
}
await API.getCerts(dispatch, setError, router);
}
const clusterOnlyCerts = clusterCerts
.filter(c => !dbCerts.some(dc => dc.storageName === c.storage_name))
.filter(c => c.storage_name !== 'server-cert.pem')
.filter(c => c.storage_name !== '_00_server-cert.pem');
const clusterOnlyCertList = clusterOnlyCerts.map((c, i) => {
const approxSubject = getApproxSubject(c.storage_name);
return (
<tr id={c.storage_name} key={'clusterOnlyCertList'+i} className='align-middle'>
<td className='col-1 text-center'>
<a className='btn btn-sm btn-danger' onClick={() => deleteCert(csrf, approxSubject, c.storage_name)}>
<i className='bi-trash-fill pe-none' width='16' height='16' />
</a>
</td>
<td>
{approxSubject || '-'}
</td>
<td>
-
</td>
<td>
-
</td>
<td>
{c.storage_name || '-'}
</td>
</tr>
);
});
const certList = dbCerts
.filter(d => d.subject.includes(filter) || d.altnames.some(an => an.includes(filter)))
.map((d, i) => {
//TODO: refactor, to component
let creation = new Date(d.date);
const expiry = creation.setDate(creation.getDate()+90);
const daysRemaining = (Math.floor(expiry - Date.now()) / 86400000).toFixed(1);
const inCluster = clusterCerts.some(c => c.storage_name === d.storageName);
return (
<tr id={d.storageName} key={'certList'+i} className='align-middle'>
<td className='text-left' style={{width:0}}>
{inCluster
? <a className='btn btn-sm btn-danger' onClick={() => deleteCert(csrf, (d.subject || d._id), d.storageName)}>
<i className='bi-trash-fill pe-none' width='16' height='16' />
</a>
: (<>
<a className='btn btn-sm btn-warning' onClick={() => uploadCert(csrf, (d.subject || d._id))}>
<i className='bi-cloud-upload pe-none' width='16' height='16' />
</a>
<a className='btn btn-sm btn-warning ms-2' onClick={() => deleteCert(csrf, (d.subject || d._id))}>
<i className='bi-trash-fill pe-none' width='16' height='16' />
</a>
</>)
}
</td>
<td>
{d.subject || '-'}
</td>
<td>
<textarea
className='w-100'
style={{border:'none'}}
readOnly
cols={20}
rows={Math.min(3, d.altnames.length)}
defaultValue={d.altnames && d.altnames.join('\n') || '-'}
/>
</td>
<td suppressHydrationWarning={true}>
{expiry ? `${daysRemaining} days` : '-'} <span className={`dot ms-2 ${daysRemaining < 30 ? 'bg-warning' : ''}`} title='Auto-renewal enabled' />
</td>
<td>
{d.storageName || '-'}
</td>
</tr>
);
});
return (
<>
<Head>
<title>Certificates</title>
</Head>
<h5 className='fw-bold'>
HTTPS Certificates:
</h5>
<SearchFilter filter={filter} setFilter={setFilter} />
{/* Certs table */}
<div className='table-responsive round-shadow'>
<form className='d-flex' onSubmit={addCert} action='/forms/cert/add' method='post'>
<input type='hidden' name='_csrf' value={csrf} />
<table className='table text-nowrap notaborder'>
<tbody>
<tr className='align-middle'>
<th style={{width:0}} />
<th>
Subject
</th>
<th>
Altname(s)
</th>
<th>
Expires
</th>
<th>
Storage Name
</th>
</tr>
{certList}
{clusterOnlyCerts && clusterOnlyCerts.length > 0 && (<>
<tr className='align-middle'>
<th colSpan='5'>
Not in local DB:
</th>
</tr>
{clusterOnlyCertList}
</>)}
</tbody>
</table>
</form>
</div>
{/* Add new cert form */}
<div className='list-group-item list-group my-2 pb-4'>
<form onSubmit={addCert} action='/forms/cert/add' method='post'>
<input type='hidden' name='_csrf' value={csrf} />
<div className='mb-2'>
<label className='form-label w-100'>Subject
<input className='form-control' type='text' name='subject' placeholder='domain.com' required />
</label>
</div>
<div className='mb-2'>
<label className='form-label w-100'>Altname(s)
<textarea
className='form-control'
name='altnames'
placeholder={'www.domain.com\r\ntest.example.com\r\netc...'}
rows={4}
required />
</label>
</div>
<div className='mb-3'>
<label className='form-label w-100'>Email (Optional, for expiry notices)
<input className='form-control' type='email' name='email' placeholder='email@example.com' />
</label>
</div>
<button className='btn btn-sm btn-success' type='submit'>
<i className='bi-plus-lg pe-1' width='16' height='16' />
New Certificate
</button>
</form>
</div>
{error && <span className='mx-2'><ErrorAlert error={error} /></span>}
{/* back to account */}
<BackButton to='/account' />
</>
);
};
export async function getServerSideProps({ req, res, query, resolvedUrl, locale, locales, defaultLocale}) {
return { props: res.locals.data };
};