diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 281f4f7dd..40c09dd10 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -1,6 +1,8 @@ import { clients, clientSiteResources, + domains, + orgDomains, roles, roleSiteResources, SiteResource, @@ -11,10 +13,83 @@ import { userSiteResources } from "@server/db"; import { sites } from "@server/db"; -import { eq, and, ne, inArray, or } from "drizzle-orm"; +import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm"; import { Config } from "./types"; import logger from "@server/logger"; import { getNextAvailableAliasAddress } from "../ip"; +import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; + +async function getDomainForSiteResource( + siteResourceId: number | undefined, + fullDomain: string, + orgId: string, + trx: Transaction +): Promise<{ subdomain: string | null; domainId: string }> { + const [fullDomainExists] = await trx + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .where( + and( + eq(siteResources.fullDomain, fullDomain), + eq(siteResources.orgId, orgId), + siteResourceId + ? ne(siteResources.siteResourceId, siteResourceId) + : isNotNull(siteResources.siteResourceId) + ) + ) + .limit(1); + + if (fullDomainExists) { + throw new Error( + `Site resource already exists with domain: ${fullDomain} in org ${orgId}` + ); + } + + const possibleDomains = await trx + .select() + .from(domains) + .innerJoin(orgDomains, eq(domains.domainId, orgDomains.domainId)) + .where(and(eq(orgDomains.orgId, orgId), eq(domains.verified, true))) + .execute(); + + if (possibleDomains.length === 0) { + throw new Error( + `Domain not found for full-domain: ${fullDomain} in org ${orgId}` + ); + } + + const validDomains = possibleDomains.filter((domain) => { + if (domain.domains.type == "ns" || domain.domains.type == "wildcard") { + return ( + fullDomain === domain.domains.baseDomain || + fullDomain.endsWith(`.${domain.domains.baseDomain}`) + ); + } else if (domain.domains.type == "cname") { + return fullDomain === domain.domains.baseDomain; + } + }); + + if (validDomains.length === 0) { + throw new Error( + `Domain not found for full-domain: ${fullDomain} in org ${orgId}` + ); + } + + const domainSelection = validDomains[0].domains; + const baseDomain = domainSelection.baseDomain; + + let subdomain: string | null = null; + if (fullDomain !== baseDomain) { + subdomain = fullDomain.replace(`.${baseDomain}`, ""); + } + + await createCertificate(domainSelection.domainId, fullDomain, trx); + + return { + subdomain, + domainId: domainSelection.domainId + }; +} function siteResourceModeForDb(mode: "host" | "cidr" | "http" | "https"): { mode: "host" | "cidr" | "http"; @@ -91,6 +166,19 @@ export async function updateClientResources( if (existingResource) { const mappedMode = siteResourceModeForDb(resourceData.mode); + + let domainInfo: + | { subdomain: string | null; domainId: string } + | undefined; + if (resourceData["full-domain"] && mappedMode.mode === "http") { + domainInfo = await getDomainForSiteResource( + existingResource.siteResourceId, + resourceData["full-domain"], + orgId, + trx + ); + } + // Update existing resource const [updatedResource] = await trx .update(siteResources) @@ -107,7 +195,10 @@ export async function updateClientResources( alias: resourceData.alias || null, disableIcmp: resourceData["disable-icmp"], tcpPortRangeString: resourceData["tcp-ports"], - udpPortRangeString: resourceData["udp-ports"] + udpPortRangeString: resourceData["udp-ports"], + fullDomain: resourceData["full-domain"] || null, + subdomain: domainInfo ? domainInfo.subdomain : null, + domainId: domainInfo ? domainInfo.domainId : null }) .where( eq( @@ -118,7 +209,6 @@ export async function updateClientResources( .returning(); const siteResourceId = existingResource.siteResourceId; - const orgId = existingResource.orgId; await trx .delete(clientSiteResources) @@ -231,6 +321,18 @@ export async function updateClientResources( aliasAddress = await getNextAvailableAliasAddress(orgId); } + let domainInfo: + | { subdomain: string | null; domainId: string } + | undefined; + if (resourceData["full-domain"] && mappedMode.mode === "http") { + domainInfo = await getDomainForSiteResource( + undefined, + resourceData["full-domain"], + orgId, + trx + ); + } + // Create new resource const [newResource] = await trx .insert(siteResources) @@ -250,7 +352,10 @@ export async function updateClientResources( aliasAddress: aliasAddress, disableIcmp: resourceData["disable-icmp"], tcpPortRangeString: resourceData["tcp-ports"], - udpPortRangeString: resourceData["udp-ports"] + udpPortRangeString: resourceData["udp-ports"], + fullDomain: resourceData["full-domain"] || null, + subdomain: domainInfo ? domainInfo.subdomain : null, + domainId: domainInfo ? domainInfo.domainId : null }) .returning(); diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index e16da2ea5..4d78e946d 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -1100,7 +1100,7 @@ function checkIfTargetChanged( return false; } -async function getDomain( +export async function getDomain( resourceId: number | undefined, fullDomain: string, orgId: string, diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 4a8dc272f..7939e6e24 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -325,7 +325,7 @@ export function isTargetsOnlyResource(resource: any): boolean { export const ClientResourceSchema = z .object({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr", "http", "https"]), + mode: z.enum(["host", "cidr", "http"]), site: z.string(), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), @@ -335,6 +335,8 @@ export const ClientResourceSchema = z "tcp-ports": portRangeStringSchema.optional().default("*"), "udp-ports": portRangeStringSchema.optional().default("*"), "disable-icmp": z.boolean().optional().default(false), + "full-domain": z.string().optional(), + ssl: z.boolean().optional(), alias: z .string() .regex( @@ -477,6 +479,39 @@ export const ConfigSchema = z }); } + // Enforce the full-domain uniqueness across client-resources in the same stack + const clientFullDomainMap = new Map(); + + Object.entries(config["client-resources"]).forEach( + ([resourceKey, resource]) => { + const fullDomain = resource["full-domain"]; + if (fullDomain) { + if (!clientFullDomainMap.has(fullDomain)) { + clientFullDomainMap.set(fullDomain, []); + } + clientFullDomainMap.get(fullDomain)!.push(resourceKey); + } + } + ); + + const clientFullDomainDuplicates = Array.from( + clientFullDomainMap.entries() + ) + .filter(([_, resourceKeys]) => resourceKeys.length > 1) + .map( + ([fullDomain, resourceKeys]) => + `'${fullDomain}' used by resources: ${resourceKeys.join(", ")}` + ) + .join("; "); + + if (clientFullDomainDuplicates.length !== 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["client-resources"], + message: `Duplicate 'full-domain' values found: ${clientFullDomainDuplicates}` + }); + } + // Enforce proxy-port uniqueness within proxy-resources per protocol const protocolPortMap = new Map(); diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 252abc1e1..6f04b8170 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -478,9 +478,9 @@ export type Alias = { alias: string | null; aliasAddress: string | null }; export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { return allSiteResources - .filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host") + .filter((sr) => sr.aliasAddress && ((sr.alias && sr.mode == "host") || (sr.fullDomain && sr.mode == "http"))) .map((sr) => ({ - alias: sr.alias, + alias: sr.alias || sr.fullDomain, aliasAddress: sr.aliasAddress })); } @@ -672,7 +672,6 @@ export async function generateSubnetProxyTargetV2( ); return; } - const publicProtocol = siteResource.ssl ? "https" : "http"; // also push a match for the alias address let tlsCert: string | undefined; let tlsKey: string | undefined; diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 01495de3b..a4a62973d 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -17,7 +17,6 @@ import { getUserDeviceName } from "@server/db/names"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { OlmErrorCodes, sendOlmError } from "./error"; import { handleFingerprintInsertion } from "./fingerprintingUtils"; -import { Alias } from "@server/lib/ip"; import { build } from "@server/build"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index d51bf54db..f871990fa 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -66,7 +66,7 @@ const createSiteResourceSchema = z .strict() .refine( (data) => { - if (data.mode === "host" || data.mode == "http") { + if (data.mode === "host") { if (data.mode == "host") { // Check if it's a valid IP address using zod (v4 or v6) const isValidIP = z @@ -262,7 +262,6 @@ export async function createSiteResource( let fullDomain: string | null = null; let finalSubdomain: string | null = null; - let finalAlias = alias ? alias.trim() : null; if (domainId && subdomain) { // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( @@ -279,18 +278,32 @@ export async function createSiteResource( fullDomain = domainResult.fullDomain; finalSubdomain = domainResult.subdomain; - finalAlias = fullDomain; // we will use the full domain as the alias for uniqueness checks and routing + + // make sure the full domain is unique + const existingResource = await db + .select() + .from(siteResources) + .where(eq(siteResources.fullDomain, fullDomain)); + + if (existingResource.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } } // make sure the alias is unique within the org if provided - if (finalAlias) { + if (alias) { const [conflict] = await db .select() .from(siteResources) .where( and( eq(siteResources.orgId, orgId), - eq(siteResources.alias, finalAlias.trim()) + eq(siteResources.alias, alias.trim()) ) ) .limit(1); @@ -330,13 +343,14 @@ export async function createSiteResource( scheme, destinationPort, enabled, - alias: finalAlias, + alias: alias ? alias.trim() : null, aliasAddress, tcpPortRangeString, udpPortRangeString, disableIcmp, domainId, - subdomain: finalSubdomain + subdomain: finalSubdomain, + fullDomain }; if (isLicensedSshPam) { if (authDaemonPort !== undefined) diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 896dc77f5..3495d9767 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -101,6 +101,9 @@ function querySiteResourcesBase() { disableIcmp: siteResources.disableIcmp, authDaemonMode: siteResources.authDaemonMode, authDaemonPort: siteResources.authDaemonPort, + subdomain: siteResources.subdomain, + domainId: siteResources.domainId, + fullDomain: siteResources.fullDomain, siteName: sites.name, siteNiceId: sites.niceId, siteAddress: sites.address diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index b66792c75..ef72ebd84 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -81,11 +81,9 @@ const updateSiteResourceSchema = z .refine( (data) => { if ( - (data.mode === "host" || - data.mode == "http") && + data.mode === "host" && data.destination ) { - if (data.mode == "host") { const isValidIP = z // .union([z.ipv4(), z.ipv6()]) .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere @@ -94,7 +92,6 @@ const updateSiteResourceSchema = z if (isValidIP) { return true; } - } // Check if it's a valid domain (hostname pattern, TLD not required) const domainRegex = @@ -309,7 +306,6 @@ export async function updateSiteResource( let fullDomain: string | null = null; let finalSubdomain: string | null = null; - let finalAlias = alias ? alias.trim() : null; if (domainId && subdomain) { // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( @@ -326,18 +322,32 @@ export async function updateSiteResource( fullDomain = domainResult.fullDomain; finalSubdomain = domainResult.subdomain; - finalAlias = fullDomain; // we will use the full domain as the alias for uniqueness checks and routing + + // make sure the full domain is unique + const existingResource = await db + .select() + .from(siteResources) + .where(eq(siteResources.fullDomain, fullDomain)); + + if (existingResource.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } } // make sure the alias is unique within the org if provided - if (finalAlias) { + if (alias) { const [conflict] = await db .select() .from(siteResources) .where( and( eq(siteResources.orgId, existingSiteResource.orgId), - eq(siteResources.alias, finalAlias.trim()), + eq(siteResources.alias, alias.trim()), ne(siteResources.siteResourceId, siteResourceId) // exclude self ) ) @@ -405,12 +415,13 @@ export async function updateSiteResource( destination, destinationPort, enabled, - alias: finalAlias, + alias: alias ? alias.trim() : null, tcpPortRangeString, udpPortRangeString, disableIcmp, domainId, subdomain: finalSubdomain, + fullDomain, ...sshPamSet }) .where( @@ -507,18 +518,20 @@ export async function updateSiteResource( .set({ name: name, siteId: siteId, + niceId: niceId, mode: mode, scheme, ssl, destination: destination, destinationPort: destinationPort, enabled: enabled, - alias: finalAlias, + alias: alias ? alias.trim() : null, tcpPortRangeString: tcpPortRangeString, udpPortRangeString: udpPortRangeString, disableIcmp: disableIcmp, domainId, subdomain: finalSubdomain, + fullDomain, ...sshPamSet }) .where( diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 46dfeb9cc..537124ad1 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -88,7 +88,10 @@ export default async function ClientResourcesPage( udpPortRangeString: siteResource.udpPortRangeString || null, disableIcmp: siteResource.disableIcmp || false, authDaemonMode: siteResource.authDaemonMode ?? null, - authDaemonPort: siteResource.authDaemonPort ?? null + authDaemonPort: siteResource.authDaemonPort ?? null, + subdomain: siteResource.subdomain ?? null, + domainId: siteResource.domainId ?? null, + fullDomain: siteResource.fullDomain ?? null }; } ); diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 6adce8fd9..c531d506d 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -63,6 +63,9 @@ export type InternalResourceRow = { disableIcmp: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; + subdomain?: string | null; + domainId?: string | null; + fullDomain?: string | null; }; function resolveHttpHttpsDisplayPort( @@ -313,8 +316,8 @@ export default function ClientResourcesTable({ /> ); } - if (resourceRow.mode === "http" && resourceRow.alias) { - const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.alias}`; + if (resourceRow.mode === "http") { + const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.fullDomain}`; return ( parseInt(r.id)) : [], diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 5f20dd458..8e8795a0d 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -77,22 +77,28 @@ export default function EditInternalResourceDialog({ ...(data.mode === "http" && { scheme: data.scheme, ssl: data.ssl ?? false, - destinationPort: data.httpHttpsPort ?? null + destinationPort: data.httpHttpsPort ?? null, + domainId: data.httpConfigDomainId ? data.httpConfigDomainId : undefined, + subdomain: data.httpConfigSubdomain ? data.httpConfigSubdomain : undefined }), - alias: - data.alias && - typeof data.alias === "string" && - data.alias.trim() - ? data.alias - : null, - tcpPortRangeString: data.tcpPortRangeString, - udpPortRangeString: data.udpPortRangeString, - disableIcmp: data.disableIcmp ?? false, - ...(data.authDaemonMode != null && { - authDaemonMode: data.authDaemonMode + ...(data.mode === "host" && { + alias: + data.alias && + typeof data.alias === "string" && + data.alias.trim() + ? data.alias + : null, + ...(data.authDaemonMode != null && { + authDaemonMode: data.authDaemonMode + }), + ...(data.authDaemonMode === "remote" && { + authDaemonPort: data.authDaemonPort || null + }) }), - ...(data.authDaemonMode === "remote" && { - authDaemonPort: data.authDaemonPort || null + ...((data.mode === "host" || data.mode === "cidr") && { + tcpPortRangeString: data.tcpPortRangeString, + udpPortRangeString: data.udpPortRangeString, + disableIcmp: data.disableIcmp ?? false }), roleIds: (data.roles || []).map((r) => parseInt(r.id)), userIds: (data.users || []).map((u) => u.id), diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index f7254d6b7..d669c3b15 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -146,9 +146,9 @@ export type InternalResourceData = { httpHttpsPort?: number | null; scheme?: "http" | "https" | null; ssl?: boolean; - httpConfigSubdomain?: string | null; - httpConfigDomainId?: string | null; - httpConfigFullDomain?: string | null; + subdomain?: string | null; + domainId?: string | null; + fullDomain?: string | null; }; const tagSchema = z.object({ id: z.string(), text: z.string() }); @@ -479,9 +479,9 @@ export function InternalResourceForm({ httpHttpsPort: resource.httpHttpsPort ?? null, scheme: resource.scheme ?? "http", ssl: resource.ssl ?? false, - httpConfigSubdomain: resource.httpConfigSubdomain ?? null, - httpConfigDomainId: resource.httpConfigDomainId ?? null, - httpConfigFullDomain: resource.httpConfigFullDomain ?? null, + httpConfigSubdomain: resource.subdomain ?? null, + httpConfigDomainId: resource.domainId ?? null, + httpConfigFullDomain: resource.fullDomain ?? null, niceId: resource.niceId, roles: [], users: [], @@ -582,9 +582,9 @@ export function InternalResourceForm({ httpHttpsPort: resource.httpHttpsPort ?? null, scheme: resource.scheme ?? "http", ssl: resource.ssl ?? false, - httpConfigSubdomain: resource.httpConfigSubdomain ?? null, - httpConfigDomainId: resource.httpConfigDomainId ?? null, - httpConfigFullDomain: resource.httpConfigFullDomain ?? null, + httpConfigSubdomain: resource.subdomain ?? null, + httpConfigDomainId: resource.domainId ?? null, + httpConfigFullDomain: resource.fullDomain ?? null, tcpPortRangeString: resource.tcpPortRangeString ?? "*", udpPortRangeString: resource.udpPortRangeString ?? "*", disableIcmp: resource.disableIcmp ?? false, @@ -1023,7 +1023,6 @@ export function InternalResourceForm({ "httpConfigFullDomain", null ); - form.setValue("alias", null); return; } form.setValue( @@ -1038,7 +1037,6 @@ export function InternalResourceForm({ "httpConfigFullDomain", res.fullDomain ); - form.setValue("alias", res.fullDomain); }} />