DNS Provider configuration

This commit is contained in:
Jamie Curnow
2025-09-22 22:19:18 +10:00
parent 553178aa6b
commit 53507f88b3
13 changed files with 387 additions and 42 deletions

View File

@@ -1,4 +1,5 @@
import express from "express";
import dnsPlugins from "../../global/certbot-dns-plugins.json" with { type: "json" };
import internalCertificate from "../../internal/certificate.js";
import errs from "../../lib/error.js";
import jwtdecode from "../../lib/express/jwt-decode.js";
@@ -72,6 +73,38 @@ router
}
});
/**
* /api/nginx/certificates/dns-providers
*/
router
.route("/dns-providers")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
/**
* GET /api/nginx/certificates/dns-providers
*
* Get list of all supported DNS providers
*/
.get(async (req, res, next) => {
try {
if (!res.locals.access.token.getUserId()) {
throw new errs.PermissionError("Login required");
}
const clean = Object.keys(dnsPlugins).map((key) => ({
id: key,
name: dnsPlugins[key].name,
credentials: dnsPlugins[key].credentials,
}));
res.status(200).send(clean);
} catch (err) {
logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
/**
* Test HTTP challenge for domains
*
@@ -107,6 +140,41 @@ router
}
});
/**
* Validate Certs before saving
*
* /api/nginx/certificates/validate
*/
router
.route("/validate")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
/**
* POST /api/nginx/certificates/validate
*
* Validate certificates
*/
.post(async (req, res, next) => {
if (!req.files) {
res.status(400).send({ error: "No files were uploaded" });
return;
}
try {
const result = await internalCertificate.validate({
files: req.files,
});
res.status(200).send(result);
} catch (err) {
logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
/**
* Specific certificate
*
@@ -266,38 +334,4 @@ router
}
});
/**
* Validate Certs before saving
*
* /api/nginx/certificates/validate
*/
router
.route("/validate")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
/**
* POST /api/nginx/certificates/validate
*
* Validate certificates
*/
.post(async (req, res, next) => {
if (!req.files) {
res.status(400).send({ error: "No files were uploaded" });
return;
}
try {
const result = await internalCertificate.validate({
files: req.files,
});
res.status(200).send(result);
} catch (err) {
logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
export default router;

View File

@@ -14,11 +14,12 @@ router
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
/**
* GET /reports/hosts
*/
.get(jwtdecode(), async (req, res, next) => {
.get(async (req, res, next) => {
try {
const data = await internalReport.getHostsReport(res.locals.access);
res.status(200).send(data);

View File

@@ -0,0 +1,9 @@
import * as api from "./base";
import type { DNSProvider } from "./models";
export async function getCertificateDNSProviders(params = {}): Promise<DNSProvider[]> {
return await api.get({
url: "/nginx/certificates/dns-providers",
params,
});
}

View File

@@ -19,6 +19,7 @@ export * from "./getAccessLists";
export * from "./getAuditLog";
export * from "./getAuditLogs";
export * from "./getCertificate";
export * from "./getCertificateDNSProviders";
export * from "./getCertificates";
export * from "./getDeadHost";
export * from "./getDeadHosts";

View File

@@ -193,3 +193,9 @@ export interface Setting {
value: string;
meta: Record<string, any>;
}
export interface DNSProvider {
id: string;
name: string;
credentials: string;
}

View File

@@ -0,0 +1,16 @@
.dnsChallengeWarning {
border: 1px solid #fecaca; /* Tailwind's red-300 */
padding: 1rem;
border-radius: 0.375rem; /* Tailwind's rounded-md */
margin-top: 1rem;
}
.textareaMono {
font-family: 'Courier New', Courier, monospace !important;
/* background-color: #f9fafb;
border: 1px solid #d1d5db;
padding: 0.5rem;
border-radius: 0.375rem;
width: 100%; */
resize: vertical;
}

View File

@@ -0,0 +1,114 @@
import cn from "classnames";
import { Field, useFormikContext } from "formik";
import { useState } from "react";
import Select, { type ActionMeta } from "react-select";
import type { DNSProvider } from "src/api/backend";
import { useDnsProviders } from "src/hooks";
import styles from "./DNSProviderFields.module.css";
interface DNSProviderOption {
readonly value: string;
readonly label: string;
readonly credentials: string;
}
export function DNSProviderFields() {
const { values, setFieldValue } = useFormikContext();
const { data: dnsProviders, isLoading } = useDnsProviders();
const [dnsProviderId, setDnsProviderId] = useState<string | null>(null);
const v: any = values || {};
const handleChange = (newValue: any, _actionMeta: ActionMeta<DNSProviderOption>) => {
setFieldValue("dnsProvider", newValue?.value);
setFieldValue("dnsProviderCredentials", newValue?.credentials);
setDnsProviderId(newValue?.value);
};
const options: DNSProviderOption[] =
dnsProviders?.map((p: DNSProvider) => ({
value: p.id,
label: p.name,
credentials: p.credentials,
})) || [];
return (
<div className={styles.dnsChallengeWarning}>
<p className="text-danger">
This section requires some knowledge about Certbot and its DNS plugins. Please consult the respective
plugins documentation.
</p>
<Field name="dnsProvider">
{({ field }: any) => (
<div className="row">
<label htmlFor="dnsProvider" className="form-label">
DNS Provider
</label>
<Select
name={field.name}
id="dnsProvider"
closeMenuOnSelect={true}
isClearable={false}
placeholder="Select a Provider..."
isLoading={isLoading}
isSearchable
onChange={handleChange}
options={options}
/>
</div>
)}
</Field>
{dnsProviderId ? (
<>
<Field name="dnsProviderCredentials">
{({ field }: any) => (
<div className="row mt-3">
<label htmlFor="dnsProviderCredentials" className="form-label">
Credentials File Content
</label>
<textarea
id="dnsProviderCredentials"
className={cn("form-control", styles.textareaMono)}
rows={3}
spellCheck={false}
value={v.dnsProviderCredentials || ""}
{...field}
/>
<small className="text-muted">
This plugin requires a configuration file containing an API token or other
credentials to your provider
</small>
<small className="text-danger">
This data will be stored as plaintext in the database and in a file!
</small>
</div>
)}
</Field>
<Field name="propagationSeconds">
{({ field }: any) => (
<div className="row mt-3">
<label htmlFor="propagationSeconds" className="form-label">
Propagation Seconds
</label>
<input
id="propagationSeconds"
type="number"
className="form-control"
min={0}
max={600}
{...field}
/>
<small className="text-muted">
Leave empty to use the plugins default value. Number of seconds to wait for DNS
propagation.
</small>
</div>
)}
</Field>
</>
) : null}
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { IconShield } from "@tabler/icons-react";
import { Field, useFormikContext } from "formik";
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
import type { Certificate } from "src/api/backend";
import { useCertificates } from "src/hooks";
import { useCertificates, useUser } from "src/hooks";
import { DateTimeFormat, intl } from "src/locale";
interface CertOption {
@@ -39,12 +39,27 @@ export function SSLCertificateField({
required,
allowNew,
}: Props) {
const { data: currentUser } = useUser("me");
const { isLoading, isError, error, data } = useCertificates();
const { values, setFieldValue } = useFormikContext();
const v: any = values || {};
const { setFieldValue } = useFormikContext();
const handleChange = (v: any, _actionMeta: ActionMeta<CertOption>) => {
setFieldValue(name, v?.value);
const handleChange = (newValue: any, _actionMeta: ActionMeta<CertOption>) => {
setFieldValue(name, newValue?.value);
const { sslForced, http2Support, hstsEnabled, hstsSubdomains, dnsChallenge, letsencryptEmail } = v;
if (!newValue?.value) {
sslForced && setFieldValue("sslForced", false);
http2Support && setFieldValue("http2Support", false);
hstsEnabled && setFieldValue("hstsEnabled", false);
hstsSubdomains && setFieldValue("hstsSubdomains", false);
}
if (newValue?.value === "new") {
if (!letsencryptEmail) {
setFieldValue("letsencryptEmail", currentUser?.email);
}
} else {
dnsChallenge && setFieldValue("dnsChallenge", false);
}
};
const options: CertOption[] =
@@ -61,7 +76,7 @@ export function SSLCertificateField({
if (allowNew) {
options?.unshift({
value: "new",
label: "Request a new HTTP certificate",
label: "Request a new Certificate",
subLabel: "with Let's Encrypt",
icon: <IconShield size={14} className="text-lime" />,
});

View File

@@ -0,0 +1,128 @@
import cn from "classnames";
import { Field, useFormikContext } from "formik";
import { DNSProviderFields } from "src/components";
export function SSLOptionsFields() {
const { values, setFieldValue } = useFormikContext();
const v: any = values || {};
const newCertificate = v?.certificateId === "new";
const hasCertificate = newCertificate || (v?.certificateId && v?.certificateId > 0);
const { sslForced, http2Support, hstsEnabled, hstsSubdomains, dnsChallenge } = v;
const handleToggleChange = (e: any, fieldName: string) => {
setFieldValue(fieldName, e.target.checked);
};
const toggleClasses = "form-check-input";
const toggleEnabled = cn(toggleClasses, "bg-cyan");
return (
<>
<div className="row">
<div className="col-6">
<Field name="sslForced">
{({ field }: any) => (
<label className="form-check form-switch mt-1">
<input
className={sslForced ? toggleEnabled : toggleClasses}
type="checkbox"
checked={!!sslForced}
onChange={(e) => handleToggleChange(e, field.name)}
disabled={!hasCertificate}
/>
<span className="form-check-label">Force SSL</span>
</label>
)}
</Field>
</div>
<div className="col-6">
<Field name="http2Support">
{({ field }: any) => (
<label className="form-check form-switch mt-1">
<input
className={http2Support ? toggleEnabled : toggleClasses}
type="checkbox"
checked={!!http2Support}
onChange={(e) => handleToggleChange(e, field.name)}
disabled={!hasCertificate}
/>
<span className="form-check-label">HTTP/2 Support</span>
</label>
)}
</Field>
</div>
</div>
<div className="row">
<div className="col-6">
<Field name="hstsEnabled">
{({ field }: any) => (
<label className="form-check form-switch mt-1">
<input
className={hstsEnabled ? toggleEnabled : toggleClasses}
type="checkbox"
checked={!!hstsEnabled}
onChange={(e) => handleToggleChange(e, field.name)}
disabled={!hasCertificate || !sslForced}
/>
<span className="form-check-label">HSTS Enabled</span>
</label>
)}
</Field>
</div>
<div className="col-6">
<Field name="hstsSubdomains">
{({ field }: any) => (
<label className="form-check form-switch mt-1">
<input
className={hstsSubdomains ? toggleEnabled : toggleClasses}
type="checkbox"
checked={!!hstsSubdomains}
onChange={(e) => handleToggleChange(e, field.name)}
disabled={!hasCertificate || !hstsEnabled}
/>
<span className="form-check-label">HSTS Enabled</span>
</label>
)}
</Field>
</div>
</div>
{newCertificate ? (
<>
<Field name="dnsChallenge">
{({ field }: any) => (
<label className="form-check form-switch mt-1">
<input
className={dnsChallenge ? toggleEnabled : toggleClasses}
type="checkbox"
checked={!!dnsChallenge}
onChange={(e) => handleToggleChange(e, field.name)}
/>
<span className="form-check-label">Use a DNS Challenge</span>
</label>
)}
</Field>
{dnsChallenge ? <DNSProviderFields /> : null}
<Field name="letsencryptEmail">
{({ field }: any) => (
<div className="row mt-5">
<label htmlFor="letsencryptEmail" className="form-label">
Email Address for Let's Encrypt
</label>
<input
id="letsencryptEmail"
type="email"
className="form-control"
required
{...field}
/>
</div>
)}
</Field>
</>
) : null}
</>
);
}

View File

@@ -1,2 +1,4 @@
export * from "./DNSProviderFields";
export * from "./DomainNamesField";
export * from "./SSLCertificateField";
export * from "./SSLOptionsFields";

View File

@@ -4,6 +4,7 @@ export * from "./useAuditLogs";
export * from "./useCertificates";
export * from "./useDeadHost";
export * from "./useDeadHosts";
export * from "./useDnsProviders";
export * from "./useHealth";
export * from "./useHostReport";
export * from "./useProxyHosts";

View File

@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { type DNSProvider, getCertificateDNSProviders } from "src/api/backend";
const fetchDnsProviders = () => {
return getCertificateDNSProviders();
};
const useDnsProviders = (options = {}) => {
return useQuery<DNSProvider[], Error>({
queryKey: ["dns-providers"],
queryFn: () => fetchDnsProviders(),
staleTime: 300 * 1000,
...options,
});
};
export { fetchDnsProviders, useDnsProviders };

View File

@@ -3,7 +3,7 @@ import { Form, Formik } from "formik";
import { useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { Button, DomainNamesField, Loading, SSLCertificateField } from "src/components";
import { Button, DomainNamesField, Loading, SSLCertificateField, SSLOptionsFields } from "src/components";
import { useDeadHost } from "src/hooks";
import { intl } from "src/locale";
@@ -124,6 +124,7 @@ export function DeadHostModal({ id, onClose }: Props) {
label="ssl-certificate"
allowNew
/>
<SSLOptionsFields />
</div>
<div className="tab-pane" id="tab-advanced" role="tabpanel">
<h4>Advanced</h4>