import { clientSiteResources, db, newts, orgs, roles, roleSiteResources, SiteResource, siteResources, sites, userSiteResources } from "@server/db"; import { getUniqueSiteResourceName } from "@server/db/names"; import { getNextAvailableAliasAddress, isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; import { and, eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; const createSiteResourceParamsSchema = z.strictObject({ orgId: z.string() }); const createSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255), mode: z.enum(["host", "cidr", "port"]), siteId: z.int(), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(), destination: z.string().min(1), enabled: z.boolean().default(true), alias: z .string() .regex( /^(?:[a-zA-Z0-9*?](?:[a-zA-Z0-9*?-]{0,61}[a-zA-Z0-9*?])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, "Alias must be a fully qualified domain name with optional wildcards (e.g., example.com, *.example.com, host-0?.example.internal)" ) .optional(), userIds: z.array(z.string()), roleIds: z.array(z.int()), clientIds: z.array(z.int()), tcpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema, disableIcmp: z.boolean().optional() }) .strict() .refine( (data) => { if (data.mode === "host") { // Check if it's a valid IP address using zod (v4 or v6) const isValidIP = z // .union([z.ipv4(), z.ipv6()]) .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere .safeParse(data.destination).success; if (isValidIP) { return true; } // Check if it's a valid domain (hostname pattern, TLD not required) const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; const isValidDomain = domainRegex.test(data.destination); const isValidAlias = data.alias !== undefined && data.alias !== null && data.alias.trim() !== ""; return isValidDomain && isValidAlias; // require the alias to be set in the case of domain } return true; }, { message: "Destination must be a valid IP address or valid domain AND alias is required" } ) .refine( (data) => { if (data.mode === "cidr") { // Check if it's a valid CIDR (v4 or v6) const isValidCIDR = z .union([z.cidrv4(), z.cidrv6()]) .safeParse(data.destination).success; return isValidCIDR; } return true; }, { message: "Destination must be a valid CIDR notation for cidr mode" } ); export type CreateSiteResourceBody = z.infer; export type CreateSiteResourceResponse = SiteResource; registry.registerPath({ method: "put", path: "/org/{orgId}/site-resource", description: "Create a new site resource.", tags: [OpenAPITags.Client, OpenAPITags.Org], request: { params: createSiteResourceParamsSchema, body: { content: { "application/json": { schema: createSiteResourceSchema } } } }, responses: {} }); export async function createSiteResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = createSiteResourceParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = createSiteResourceSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { orgId } = parsedParams.data; const { name, siteId, mode, // protocol, // proxyPort, // destinationPort, destination, enabled, alias, userIds, roleIds, clientIds, tcpPortRangeString, udpPortRangeString, disableIcmp } = parsedBody.data; // Verify the site exists and belongs to the org const [site] = await db .select() .from(sites) .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) .limit(1); if (!site) { return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } const [org] = await db .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); if (!org) { return next( createHttpError(HttpCode.NOT_FOUND, "Organization not found") ); } if (!org.subnet || !org.utilitySubnet) { return next( createHttpError( HttpCode.BAD_REQUEST, `Organization with ID ${orgId} has no subnet or utilitySubnet defined defined` ) ); } // Only check if destination is an IP address const isIp = z .union([z.ipv4(), z.ipv6()]) .safeParse(destination).success; if ( isIp && (isIpInCidr(destination, org.subnet) || isIpInCidr(destination, org.utilitySubnet)) ) { return next( createHttpError( HttpCode.BAD_REQUEST, "IP can not be in the CIDR range of the organization's subnet or utility subnet" ) ); } // // check if resource with same protocol and proxy port already exists (only for port mode) // if (mode === "port" && protocol && proxyPort) { // const [existingResource] = await db // .select() // .from(siteResources) // .where( // and( // eq(siteResources.siteId, siteId), // eq(siteResources.orgId, orgId), // eq(siteResources.protocol, protocol), // eq(siteResources.proxyPort, proxyPort) // ) // ) // .limit(1); // if (existingResource && existingResource.siteResourceId) { // return next( // createHttpError( // HttpCode.CONFLICT, // "A resource with the same protocol and proxy port already exists" // ) // ); // } // } // make sure the alias is unique within the org if provided if (alias) { const [conflict] = await db .select() .from(siteResources) .where( and( eq(siteResources.orgId, orgId), eq(siteResources.alias, alias.trim()) ) ) .limit(1); if (conflict) { return next( createHttpError( HttpCode.CONFLICT, "Alias already in use by another site resource" ) ); } } const niceId = await getUniqueSiteResourceName(orgId); let aliasAddress: string | null = null; if (mode == "host") { // we can only have an alias on a host aliasAddress = await getNextAvailableAliasAddress(orgId); } let newSiteResource: SiteResource | undefined; await db.transaction(async (trx) => { // Create the site resource [newSiteResource] = await trx .insert(siteResources) .values({ siteId, niceId, orgId, name, mode, // protocol: mode === "port" ? protocol : null, // proxyPort: mode === "port" ? proxyPort : null, // destinationPort: mode === "port" ? destinationPort : null, destination, enabled, alias, aliasAddress, tcpPortRangeString, udpPortRangeString, disableIcmp }) .returning(); const siteResourceId = newSiteResource.siteResourceId; //////////////////// update the associations //////////////////// const [adminRole] = await trx .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) .limit(1); if (!adminRole) { return next( createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) ); } await trx.insert(roleSiteResources).values({ roleId: adminRole.roleId, siteResourceId: siteResourceId }); if (roleIds.length > 0) { await trx .insert(roleSiteResources) .values( roleIds.map((roleId) => ({ roleId, siteResourceId })) ); } if (userIds.length > 0) { await trx .insert(userSiteResources) .values( userIds.map((userId) => ({ userId, siteResourceId })) ); } if (clientIds.length > 0) { await trx.insert(clientSiteResources).values( clientIds.map((clientId) => ({ clientId, siteResourceId })) ); } const [newt] = await trx .select() .from(newts) .where(eq(newts.siteId, site.siteId)) .limit(1); if (!newt) { return next( createHttpError(HttpCode.NOT_FOUND, "Newt not found") ); } await rebuildClientAssociationsFromSiteResource( newSiteResource, trx ); // we need to call this because we added to the admin role }); if (!newSiteResource) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Site resource creation failed" ) ); } logger.info( `Created site resource ${newSiteResource.siteResourceId} for site ${siteId}` ); return response(res, { data: newSiteResource, success: true, error: false, message: "Site resource created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error("Error creating site resource:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create site resource" ) ); } }