Explorar el Código

DNS Provider configuration

Jamie Curnow hace 2 meses
padre
commit
94375bbc5f

+ 68 - 34
backend/routes/nginx/certificates.js

@@ -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;

+ 2 - 1
backend/routes/reports.js

@@ -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);

+ 9 - 0
frontend/src/api/backend/getCertificateDNSProviders.ts

@@ -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,
+	});
+}

+ 1 - 0
frontend/src/api/backend/index.ts

@@ -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";

+ 6 - 0
frontend/src/api/backend/models.ts

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

+ 16 - 0
frontend/src/components/Form/DNSProviderFields.module.css

@@ -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;
+}

+ 114 - 0
frontend/src/components/Form/DNSProviderFields.tsx

@@ -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>
+	);
+}

+ 21 - 6
frontend/src/components/Form/SSLCertificateField.tsx

@@ -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" />,
 		});

+ 128 - 0
frontend/src/components/Form/SSLOptionsFields.tsx

@@ -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}
+		</>
+	);
+}

+ 2 - 0
frontend/src/components/Form/index.ts

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

+ 1 - 0
frontend/src/hooks/index.ts

@@ -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";

+ 17 - 0
frontend/src/hooks/useDnsProviders.ts

@@ -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 };

+ 2 - 1
frontend/src/modals/DeadHostModal.tsx

@@ -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>