Create wildcard domains

This commit is contained in:
Owen 2025-07-14 12:18:12 -07:00
parent b75800c583
commit 69d253fba3
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
6 changed files with 214 additions and 157 deletions

1
.gitignore vendored
View file

@ -34,3 +34,4 @@ bin
test_event.json test_event.json
.idea/ .idea/
server/db/index.ts server/db/index.ts
build.ts

View file

@ -1159,6 +1159,8 @@
"selectDomainTypeNsDescription": "This domain and all its subdomains. Use this when you want to control an entire domain zone.", "selectDomainTypeNsDescription": "This domain and all its subdomains. Use this when you want to control an entire domain zone.",
"selectDomainTypeCnameName": "Single Domain (CNAME)", "selectDomainTypeCnameName": "Single Domain (CNAME)",
"selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.", "selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.",
"selectDomainTypeWildcardName": "Wildcard Domain (CNAME)",
"selectDomainTypeWildcardDescription": "This domain and its first level of subdomains.",
"domainDelegation": "Single Domain", "domainDelegation": "Single Domain",
"selectType": "Select a type", "selectType": "Select a type",
"actions": "Actions", "actions": "Actions",

View file

@ -10,6 +10,7 @@ import { subdomainSchema } from "@server/lib/schemas";
import { generateId } from "@server/auth/sessions/app"; import { generateId } from "@server/auth/sessions/app";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { isValidDomain } from "@server/lib/validators"; import { isValidDomain } from "@server/lib/validators";
import { build } from "@server/build";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@ -19,7 +20,7 @@ const paramsSchema = z
const bodySchema = z const bodySchema = z
.object({ .object({
type: z.enum(["ns", "cname"]), type: z.enum(["ns", "cname", "wildcard"]),
baseDomain: subdomainSchema baseDomain: subdomainSchema
}) })
.strict(); .strict();
@ -68,6 +69,26 @@ export async function createOrgDomain(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const { type, baseDomain } = parsedBody.data; const { type, baseDomain } = parsedBody.data;
if (build == "oss") {
if (type !== "wildcard") {
return next(
createHttpError(
HttpCode.NOT_IMPLEMENTED,
"Creating NS or CNAME records is not supported"
)
);
}
} else if (build == "enterprise" || build == "saas") {
if (type !== "ns" && type !== "cname") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid domain type. Only NS, CNAME are allowed."
)
);
}
}
// Validate organization exists // Validate organization exists
if (!isValidDomain(baseDomain)) { if (!isValidDomain(baseDomain)) {
return next( return next(
@ -132,7 +153,7 @@ export async function createOrgDomain(
.from(domains) .from(domains)
.where(eq(domains.verified, true)); .where(eq(domains.verified, true));
if (type === "cname") { if (type == "cname") {
// Block if a verified CNAME exists at the same name // Block if a verified CNAME exists at the same name
const cnameExists = verifiedDomains.some( const cnameExists = verifiedDomains.some(
(d) => d.type === "cname" && d.baseDomain === baseDomain (d) => d.type === "cname" && d.baseDomain === baseDomain
@ -160,7 +181,7 @@ export async function createOrgDomain(
) )
); );
} }
} else if (type === "ns") { } else if (type == "ns") {
// Block if a verified NS exists at or below (same or subdomain) // Block if a verified NS exists at or below (same or subdomain)
const nsAtOrBelow = verifiedDomains.some( const nsAtOrBelow = verifiedDomains.some(
(d) => (d) =>
@ -176,6 +197,8 @@ export async function createOrgDomain(
) )
); );
} }
} else if (type == "wildcard") {
// TODO: Figure out how to handle wildcards
} }
const domainId = generateId(15); const domainId = generateId(15);
@ -185,7 +208,8 @@ export async function createOrgDomain(
.values({ .values({
domainId, domainId,
baseDomain, baseDomain,
type type,
verified: build == "oss" ? true : false
}) })
.returning(); .returning();
@ -214,6 +238,17 @@ export async function createOrgDomain(
baseDomain: `_acme-challenge.${baseDomain}` baseDomain: `_acme-challenge.${baseDomain}`
} }
]; ];
} else if (type === "wildcard") {
cnameRecords = [
{
value: `Server IP Address`,
baseDomain: `*.${baseDomain}`
},
{
value: `Server IP Address`,
baseDomain: `${baseDomain}`
}
];
} }
numOrgDomains = await trx numOrgDomains = await trx

View file

@ -43,6 +43,7 @@ import { createNewt, getNewtToken } from "./newt";
import { getOlmToken } from "./olm"; import { getOlmToken } from "./olm";
import rateLimit from "express-rate-limit"; import rateLimit from "express-rate-limit";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { build } from "@server/build";
// Root routes // Root routes
export const unauthenticated = Router(); export const unauthenticated = Router();
@ -57,7 +58,9 @@ authenticated.use(verifySessionUserMiddleware);
authenticated.get("/pick-org-defaults", org.pickOrgDefaults); authenticated.get("/pick-org-defaults", org.pickOrgDefaults);
authenticated.get("/org/checkId", org.checkId); authenticated.get("/org/checkId", org.checkId);
authenticated.put("/org", getUserOrgs, org.createOrg); if (build === "oss" || build === "enterprise") {
authenticated.put("/org", getUserOrgs, org.createOrg);
}
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs); authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs); authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);

View file

@ -42,10 +42,11 @@ import {
InfoSectionTitle InfoSectionTitle
} from "@app/components/InfoSection"; } from "@app/components/InfoSection";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { build } from "@server/build";
const formSchema = z.object({ const formSchema = z.object({
baseDomain: z.string().min(1, "Domain is required"), baseDomain: z.string().min(1, "Domain is required"),
type: z.enum(["ns", "cname"]) type: z.enum(["ns", "cname", "wildcard"])
}); });
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@ -73,7 +74,7 @@ export default function CreateDomainForm({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
baseDomain: "", baseDomain: "",
type: "ns" type: build == "oss" ? "wildcard" : "ns"
} }
}); });
@ -111,6 +112,30 @@ export default function CreateDomainForm({
const domainType = form.watch("type"); const domainType = form.watch("type");
const baseDomain = form.watch("baseDomain"); const baseDomain = form.watch("baseDomain");
let domainOptions: any = [];
if (build == "enterprise" || build == "saas") {
domainOptions = [
{
id: "ns",
title: t("selectDomainTypeNsName"),
description: t("selectDomainTypeNsDescription")
},
{
id: "cname",
title: t("selectDomainTypeCnameName"),
description: t("selectDomainTypeCnameDescription")
}
];
} else if (build == "oss") {
domainOptions = [
{
id: "wildcard",
title: t("selectDomainTypeWildcardName"),
description: t("selectDomainTypeWildcardDescription")
}
];
}
return ( return (
<Credenza <Credenza
open={open} open={open}
@ -140,26 +165,7 @@ export default function CreateDomainForm({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<StrategySelect <StrategySelect
options={[ options={domainOptions}
{
id: "ns",
title: t(
"selectDomainTypeNsName"
),
description: t(
"selectDomainTypeNsDescription"
)
},
{
id: "cname",
title: t(
"selectDomainTypeCnameName"
),
description: t(
"selectDomainTypeCnameDescription"
)
}
]}
defaultValue={field.value} defaultValue={field.value}
onChange={field.onChange} onChange={field.onChange}
cols={1} cols={1}
@ -258,141 +264,149 @@ export default function CreateDomainForm({
</div> </div>
)} )}
{domainType === "cname" && ( {domainType === "cname" ||
<> (domainType == "wildcard" && (
{createdDomain.cnameRecords && <>
createdDomain.cnameRecords.length > {createdDomain.cnameRecords &&
0 && ( createdDomain.cnameRecords
<div> .length > 0 && (
<h3 className="font-medium mb-3"> <div>
CNAME Records <h3 className="font-medium mb-3">
</h3> CNAME Records
<InfoSections cols={1}> </h3>
{createdDomain.cnameRecords.map( <InfoSections cols={1}>
( {createdDomain.cnameRecords.map(
cnameRecord, (
index cnameRecord,
) => ( index
<InfoSection ) => (
key={index} <InfoSection
> key={
<InfoSectionTitle> index
Record{" "} }
{index + >
1} <InfoSectionTitle>
</InfoSectionTitle> Record{" "}
<InfoSectionContent> {index +
<div className="space-y-2"> 1}
<div className="flex justify-between items-center"> </InfoSectionTitle>
<span className="text-sm font-medium"> <InfoSectionContent>
Type: <div className="space-y-2">
</span> <div className="flex justify-between items-center">
<span className="text-sm font-mono"> <span className="text-sm font-medium">
CNAME Type:
</span> </span>
<span className="text-sm font-mono">
CNAME
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Name:
</span>
<span className="text-sm font-mono">
{
cnameRecord.baseDomain
}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Value:
</span>
<CopyToClipboard
text={
cnameRecord.value
}
/>
</div>
</div> </div>
<div className="flex justify-between items-center"> </InfoSectionContent>
<span className="text-sm font-medium"> </InfoSection>
Name: )
</span> )}
<span className="text-sm font-mono"> </InfoSections>
{ </div>
cnameRecord.baseDomain )}
}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Value:
</span>
<CopyToClipboard
text={
cnameRecord.value
}
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
{createdDomain.txtRecords && {createdDomain.txtRecords &&
createdDomain.txtRecords.length > createdDomain.txtRecords
0 && ( .length > 0 && (
<div> <div>
<h3 className="font-medium mb-3"> <h3 className="font-medium mb-3">
TXT Records TXT Records
</h3> </h3>
<InfoSections cols={1}> <InfoSections cols={1}>
{createdDomain.txtRecords.map( {createdDomain.txtRecords.map(
( (
txtRecord, txtRecord,
index index
) => ( ) => (
<InfoSection <InfoSection
key={index} key={
> index
<InfoSectionTitle> }
Record{" "} >
{index + <InfoSectionTitle>
1} Record{" "}
</InfoSectionTitle> {index +
<InfoSectionContent> 1}
<div className="space-y-2"> </InfoSectionTitle>
<div className="flex justify-between items-center"> <InfoSectionContent>
<span className="text-sm font-medium"> <div className="space-y-2">
Type: <div className="flex justify-between items-center">
</span> <span className="text-sm font-medium">
<span className="text-sm font-mono"> Type:
TXT </span>
</span> <span className="text-sm font-mono">
TXT
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Name:
</span>
<span className="text-sm font-mono">
{
txtRecord.baseDomain
}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Value:
</span>
<CopyToClipboard
text={
txtRecord.value
}
/>
</div>
</div> </div>
<div className="flex justify-between items-center"> </InfoSectionContent>
<span className="text-sm font-medium"> </InfoSection>
Name: )
</span> )}
<span className="text-sm font-mono"> </InfoSections>
{ </div>
txtRecord.baseDomain )}
} </>
</span> ))}
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Value:
</span>
<CopyToClipboard
text={
txtRecord.value
}
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
</>
)}
</div> </div>
<Alert variant="destructive"> {build == "saas" ||
<AlertTriangle className="h-4 w-4" /> (build == "enterprise" && (
<AlertTitle className="font-semibold"> <Alert variant="destructive">
Save These Records <AlertTriangle className="h-4 w-4" />
</AlertTitle> <AlertTitle className="font-semibold">
<AlertDescription> Save These Records
Make sure to save these DNS records as you </AlertTitle>
will not see them again. <AlertDescription>
</AlertDescription> Make sure to save these DNS records
</Alert> as you will not see them again.
</AlertDescription>
</Alert>
))}
<Alert variant="info"> <Alert variant="info">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />

View file

@ -106,6 +106,8 @@ export default function DomainsTable({ domains }: Props) {
return t("selectDomainTypeNsName"); return t("selectDomainTypeNsName");
case "cname": case "cname":
return t("selectDomainTypeCnameName"); return t("selectDomainTypeCnameName");
case "wildcard":
return t("selectDomainTypeWildcardName");
default: default:
return type; return type;
} }