@@ -162,7 +164,9 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
required
{...field}
>
-
+
@@ -224,12 +228,24 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
required
{...field}
>
-
-
-
-
-
-
+
+
+
+
+
+
{form.errors.forwardHttpCode ? (
@@ -302,6 +318,7 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
label="ssl-certificate"
allowNew
/>
+
diff --git a/frontend/src/modules/CertificateMatch.test.ts b/frontend/src/modules/CertificateMatch.test.ts
new file mode 100644
index 0000000000..0ab7e3c35f
--- /dev/null
+++ b/frontend/src/modules/CertificateMatch.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vitest";
+import type { Certificate } from "src/api/backend";
+import { matchCertificate, uncoveredDomains } from "./CertificateMatch";
+
+const cert = (id: number, ...domainNames: string[]) => ({ id, domainNames }) as Certificate;
+
+describe("matchCertificate", () => {
+ const wildcard = cert(1, "*.grasfer.app");
+ const exact = cert(2, "pbs.grasfer.app");
+ const other = cert(3, "example.com", "www.example.com");
+
+ it("matches a wildcard cert one label deep", () => {
+ expect(matchCertificate([wildcard, other], ["whatever.grasfer.app"])?.id).toEqual(1);
+ });
+
+ it("prefers an exact match over a wildcard", () => {
+ expect(matchCertificate([wildcard, exact], ["pbs.grasfer.app"])?.id).toEqual(2);
+ expect(matchCertificate([exact, wildcard], ["pbs.grasfer.app"])?.id).toEqual(2);
+ });
+
+ it("does not match a wildcard two labels deep", () => {
+ expect(matchCertificate([wildcard], ["a.b.grasfer.app"])).toBeUndefined();
+ });
+
+ it("does not match the bare wildcard base domain", () => {
+ expect(matchCertificate([wildcard], ["grasfer.app"])).toBeUndefined();
+ });
+
+ it("requires the cert to cover every typed domain", () => {
+ // covering only one of two aliases would break TLS for the other
+ expect(matchCertificate([other], ["nope.org", "www.example.com"])).toBeUndefined();
+ expect(matchCertificate([wildcard], ["a.grasfer.app", "b.example.net"])).toBeUndefined();
+ });
+
+ it("matches when all domains are covered", () => {
+ expect(matchCertificate([other], ["example.com", "www.example.com"])?.id).toEqual(3);
+ expect(matchCertificate([wildcard], ["a.grasfer.app", "b.grasfer.app"])?.id).toEqual(1);
+ });
+
+ it("prefers a cert covering all domains exactly over a wildcard covering all", () => {
+ const both = cert(4, "a.grasfer.app", "b.grasfer.app");
+ expect(matchCertificate([wildcard, both], ["a.grasfer.app", "b.grasfer.app"])?.id).toEqual(4);
+ });
+
+ it("is case insensitive", () => {
+ expect(matchCertificate([other], ["WWW.Example.COM"])?.id).toEqual(3);
+ });
+
+ it("returns undefined when nothing matches or input is empty", () => {
+ expect(matchCertificate([wildcard, exact, other], ["unrelated.net"])).toBeUndefined();
+ expect(matchCertificate([], ["whatever.grasfer.app"])).toBeUndefined();
+ expect(matchCertificate([wildcard], [])).toBeUndefined();
+ });
+});
+
+describe("uncoveredDomains", () => {
+ const wildcard = cert(1, "*.grasfer.app");
+ const other = cert(3, "example.com", "www.example.com");
+
+ it("returns nothing when every domain is covered", () => {
+ expect(uncoveredDomains(other, ["example.com", "www.example.com"])).toEqual([]);
+ expect(uncoveredDomains(wildcard, ["a.grasfer.app", "b.grasfer.app"])).toEqual([]);
+ });
+
+ it("returns only the uncovered domains, original casing preserved", () => {
+ expect(uncoveredDomains(other, ["Example.COM", "Nope.org"])).toEqual(["Nope.org"]);
+ });
+
+ it("applies one-label wildcard semantics", () => {
+ expect(uncoveredDomains(wildcard, ["a.grasfer.app", "a.b.grasfer.app", "grasfer.app"])).toEqual([
+ "a.b.grasfer.app",
+ "grasfer.app",
+ ]);
+ });
+
+ it("returns nothing without a certificate or domains", () => {
+ expect(uncoveredDomains(undefined, ["example.com"])).toEqual([]);
+ expect(uncoveredDomains(other, [])).toEqual([]);
+ });
+});
diff --git a/frontend/src/modules/CertificateMatch.ts b/frontend/src/modules/CertificateMatch.ts
new file mode 100644
index 0000000000..fb95c00800
--- /dev/null
+++ b/frontend/src/modules/CertificateMatch.ts
@@ -0,0 +1,62 @@
+import type { Certificate } from "src/api/backend";
+
+// "exact" beats "wildcard"; null = not covered
+const covers = (certDomain: string, domain: string): "exact" | "wildcard" | null => {
+ if (certDomain === domain) {
+ return "exact";
+ }
+ if (certDomain.startsWith("*.")) {
+ const suffix = certDomain.slice(1); // ".example.com"
+ const prefix = domain.slice(0, -suffix.length);
+ // nginx semantics: a wildcard covers exactly one extra label
+ if (domain.endsWith(suffix) && prefix && !prefix.includes(".")) {
+ return "wildcard";
+ }
+ }
+ return null;
+};
+
+/**
+ * The typed domains (original casing, for display) that the given certificate
+ * does not cover. No certificate means nothing to warn about: [].
+ */
+export function uncoveredDomains(cert: Certificate | undefined, domains: string[]): string[] {
+ if (!cert) {
+ return [];
+ }
+ const certDomains = (cert.domainNames || []).map((d) => d.toLowerCase());
+ return domains.filter((domain) => !certDomains.some((certDomain) => covers(certDomain, domain.toLowerCase())));
+}
+
+/**
+ * Finds a certificate covering ALL of the given domains (a host serves every
+ * domain with the one selected certificate). A cert covering every domain
+ * exactly wins; otherwise the first cert covering all of them.
+ */
+export function matchCertificate(certs: Certificate[], domains: string[]): Certificate | undefined {
+ const wanted = domains.map((d) => d.toLowerCase());
+ if (!wanted.length) {
+ return undefined;
+ }
+ let partialExact: Certificate | undefined;
+ for (const cert of certs) {
+ const certDomains = (cert.domainNames || []).map((d) => d.toLowerCase());
+ const kinds = wanted.map((domain) => {
+ let best: "exact" | "wildcard" | null = null;
+ for (const certDomain of certDomains) {
+ const kind = covers(certDomain, domain);
+ if (kind === "exact") return "exact";
+ if (kind === "wildcard") best = "wildcard";
+ }
+ return best;
+ });
+ if (kinds.includes(null)) {
+ continue; // must cover every domain
+ }
+ if (!kinds.includes("wildcard")) {
+ return cert; // all-exact match wins outright
+ }
+ partialExact ||= cert;
+ }
+ return partialExact;
+}