diff --git a/messages/en-US.json b/messages/en-US.json index 8c0c404d..f70b9beb 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1400,8 +1400,6 @@ "editInternalResourceDialogProtocol": "Protocol", "editInternalResourceDialogSitePort": "Site Port", "editInternalResourceDialogTargetConfiguration": "Target Configuration", - "editInternalResourceDialogDestinationIP": "Destination IP", - "editInternalResourceDialogDestinationPort": "Destination Port", "editInternalResourceDialogCancel": "Cancel", "editInternalResourceDialogSaveResource": "Save Resource", "editInternalResourceDialogSuccess": "Success", @@ -1432,9 +1430,7 @@ "createInternalResourceDialogSitePort": "Site Port", "createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.", "createInternalResourceDialogTargetConfiguration": "Target Configuration", - "createInternalResourceDialogDestinationIP": "Destination IP", - "createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.", - "createInternalResourceDialogDestinationPort": "Destination Port", + "createInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.", "createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.", "createInternalResourceDialogCancel": "Cancel", "createInternalResourceDialogCreateResource": "Create Resource", diff --git a/server/db/names.ts b/server/db/names.ts index 9c671a22..2da38f10 100644 --- a/server/db/names.ts +++ b/server/db/names.ts @@ -1,6 +1,6 @@ import { join } from "path"; import { readFileSync } from "fs"; -import { db, resources } from "@server/db"; +import { db, resources, siteResources } from "@server/db"; import { exitNodes, sites } from "@server/db"; import { eq, and } from "drizzle-orm"; import { __DIRNAME } from "@server/lib/consts"; @@ -53,6 +53,25 @@ export async function getUniqueResourceName(orgId: string): Promise { } } +export async function getUniqueSiteResourceName(orgId: string): Promise { + let loops = 0; + while (true) { + if (loops > 100) { + throw new Error("Could not generate a unique name"); + } + + const name = generateName(); + const count = await db + .select({ niceId: siteResources.niceId, orgId: siteResources.orgId }) + .from(siteResources) + .where(and(eq(siteResources.niceId, name), eq(siteResources.orgId, orgId))); + if (count.length === 0) { + return name; + } + loops++; + } +} + export async function getUniqueExitNodeEndpointName(): Promise { let loops = 0; const count = await db diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index c31b790e..3cb5486b 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -143,6 +143,7 @@ export const siteResources = pgTable("siteResources", { // this is for the clien orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), + niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), protocol: varchar("protocol").notNull(), proxyPort: integer("proxyPort").notNull(), diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 99fad56c..7362f28a 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -158,6 +158,7 @@ export const siteResources = sqliteTable("siteResources", { orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), + niceId: text("niceId").notNull(), name: text("name").notNull(), protocol: text("protocol").notNull(), proxyPort: integer("proxyPort").notNull(), diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 6f5fc4ee..47193420 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -1,11 +1,16 @@ import { db, newts, Target } from "@server/db"; import { Config, ConfigSchema } from "./types"; -import { ResourcesResults, updateResources } from "./resources"; +import { ProxyResourcesResults, updateProxyResources } from "./proxyResources"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { resources, targets, sites } from "@server/db"; import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm"; -import { addTargets } from "@server/routers/newt/targets"; +import { addTargets as addProxyTargets } from "@server/routers/newt/targets"; +import { addTargets as addClientTargets } from "@server/routers/client/targets"; +import { + ClientResourcesResults, + updateClientResources +} from "./clientResources"; export async function applyBlueprint( orgId: string, @@ -21,17 +26,29 @@ export async function applyBlueprint( const config: Config = validationResult.data; try { - let resourcesResults: ResourcesResults = []; + let proxyResourcesResults: ProxyResourcesResults = []; + let clientResourcesResults: ClientResourcesResults = []; await db.transaction(async (trx) => { - resourcesResults = await updateResources(orgId, config, trx, siteId); + proxyResourcesResults = await updateProxyResources( + orgId, + config, + trx, + siteId + ); + clientResourcesResults = await updateClientResources( + orgId, + config, + trx, + siteId + ); }); logger.debug( - `Successfully updated resources for org ${orgId}: ${JSON.stringify(resourcesResults)}` + `Successfully updated proxy resources for org ${orgId}: ${JSON.stringify(proxyResourcesResults)}` ); // We need to update the targets on the newts from the successfully updated information - for (const result of resourcesResults) { + for (const result of proxyResourcesResults) { for (const target of result.targetsToUpdate) { const [site] = await db .select() @@ -52,15 +69,50 @@ export async function applyBlueprint( `Updating target ${target.targetId} on site ${site.sites.siteId}` ); - await addTargets( + await addProxyTargets( site.newt.newtId, [target], - result.resource.protocol, - result.resource.proxyPort + result.proxyResource.protocol, + result.proxyResource.proxyPort ); } } } + + logger.debug( + `Successfully updated client resources for org ${orgId}: ${JSON.stringify(clientResourcesResults)}` + ); + + // We need to update the targets on the newts from the successfully updated information + for (const result of clientResourcesResults) { + const [site] = await db + .select() + .from(sites) + .innerJoin(newts, eq(sites.siteId, newts.siteId)) + .where( + and( + eq(sites.siteId, result.resource.siteId), + eq(sites.orgId, orgId), + eq(sites.type, "newt"), + isNotNull(sites.pubKey) + ) + ) + .limit(1); + + if (site) { + logger.debug( + `Updating client resource ${result.resource.siteResourceId} on site ${site.sites.siteId}` + ); + + await addClientTargets( + site.newt.newtId, + result.resource.destinationIp, + result.resource.destinationPort, + result.resource.protocol, + result.resource.proxyPort + ); + } + } } catch (error) { logger.error(`Failed to update database from config: ${error}`); throw error; @@ -102,17 +154,17 @@ export async function applyBlueprint( // } // ] // }, - // "resource-nice-id2": { - // name: "http server", - // protocol: "tcp", - // "proxy-port": 3000, - // targets: [ - // { - // site: "glossy-plains-viscacha-rat", - // hostname: "localhost", - // port: 3000, - // } - // ] - // } +// "resource-nice-id2": { +// name: "http server", +// protocol: "tcp", +// "proxy-port": 3000, +// targets: [ +// { +// site: "glossy-plains-viscacha-rat", +// hostname: "localhost", +// port: 3000, +// } +// ] +// } // } // }); diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts new file mode 100644 index 00000000..59bbc346 --- /dev/null +++ b/server/lib/blueprints/clientResources.ts @@ -0,0 +1,117 @@ +import { + SiteResource, + siteResources, + Transaction, +} from "@server/db"; +import { sites } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import { + Config, +} from "./types"; +import logger from "@server/logger"; + +export type ClientResourcesResults = { + resource: SiteResource; +}[]; + +export async function updateClientResources( + orgId: string, + config: Config, + trx: Transaction, + siteId?: number +): Promise { + const results: ClientResourcesResults = []; + + for (const [resourceNiceId, resourceData] of Object.entries( + config["client-resources"] + )) { + const [existingResource] = await trx + .select() + .from(siteResources) + .where( + and( + eq(siteResources.orgId, orgId), + eq(siteResources.niceId, resourceNiceId) + ) + ) + .limit(1); + + const resourceSiteId = resourceData.site; + let site; + + if (resourceSiteId) { + // Look up site by niceId + [site] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where( + and( + eq(sites.niceId, resourceSiteId), + eq(sites.orgId, orgId) + ) + ) + .limit(1); + } else if (siteId) { + // Use the provided siteId directly, but verify it belongs to the org + [site] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) + .limit(1); + } else { + throw new Error(`Target site is required`); + } + + if (!site) { + throw new Error( + `Site not found: ${resourceSiteId} in org ${orgId}` + ); + } + + if (existingResource) { + // Update existing resource + const [updatedResource] = await trx + .update(siteResources) + .set({ + name: resourceData.name || resourceNiceId, + siteId: site.siteId, + proxyPort: resourceData["proxy-port"]!, + destinationIp: resourceData.hostname, + destinationPort: resourceData["internal-port"], + protocol: resourceData.protocol + }) + .where( + eq( + siteResources.siteResourceId, + existingResource.siteResourceId + ) + ) + .returning(); + + results.push({ resource: updatedResource }); + } else { + // Create new resource + const [newResource] = await trx + .insert(siteResources) + .values({ + orgId: orgId, + siteId: site.siteId, + niceId: resourceNiceId, + name: resourceData.name || resourceNiceId, + proxyPort: resourceData["proxy-port"]!, + destinationIp: resourceData.hostname, + destinationPort: resourceData["internal-port"], + protocol: resourceData.protocol + }) + .returning(); + + logger.info( + `Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}` + ); + + results.push({ resource: newResource }); + } + } + + return results; +} diff --git a/server/lib/blueprints/parseDockerContainers.ts b/server/lib/blueprints/parseDockerContainers.ts index 03ab609f..d00cbd73 100644 --- a/server/lib/blueprints/parseDockerContainers.ts +++ b/server/lib/blueprints/parseDockerContainers.ts @@ -66,9 +66,9 @@ export function processContainerLabels(containers: Container[]): { const resourceLabels: DockerLabels = {}; - // Filter labels that start with "pangolin.resources." + // Filter labels that start with "pangolin.proxy-resources." Object.entries(container.labels).forEach(([key, value]) => { - if (key.startsWith("pangolin.resources.")) { + if (key.startsWith("pangolin.proxy-resources.") || key.startsWith("pangolin.client-resources.")) { // remove the pangolin. prefix const strippedKey = key.replace("pangolin.", ""); resourceLabels[strippedKey] = value; diff --git a/server/lib/blueprints/resources.ts b/server/lib/blueprints/proxyResources.ts similarity index 85% rename from server/lib/blueprints/resources.ts rename to server/lib/blueprints/proxyResources.ts index ed766c7d..ecaa00cb 100644 --- a/server/lib/blueprints/resources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -3,6 +3,7 @@ import { orgDomains, Resource, resourcePincode, + resourceRules, resourceWhitelist, roleResources, roles, @@ -14,27 +15,33 @@ import { } from "@server/db"; import { resources, targets, sites } from "@server/db"; import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm"; -import { Config, ConfigSchema, isTargetsOnlyResource, TargetData } from "./types"; +import { + Config, + ConfigSchema, + isTargetsOnlyResource, + TargetData +} from "./types"; import logger from "@server/logger"; import { pickPort } from "@server/routers/target/helpers"; import { resourcePassword } from "@server/db"; import { hashPassword } from "@server/auth/password"; +import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; -export type ResourcesResults = { - resource: Resource; +export type ProxyResourcesResults = { + proxyResource: Resource; targetsToUpdate: Target[]; }[]; -export async function updateResources( +export async function updateProxyResources( orgId: string, config: Config, trx: Transaction, siteId?: number -): Promise { - const results: ResourcesResults = []; +): Promise { + const results: ProxyResourcesResults = []; for (const [resourceNiceId, resourceData] of Object.entries( - config.resources + config["proxy-resources"] )) { const targetsToUpdate: Target[] = []; let resource: Resource; @@ -122,8 +129,14 @@ export async function updateResources( const http = resourceData.protocol == "http"; const protocol = resourceData.protocol == "http" ? "tcp" : resourceData.protocol; - const resourceEnabled = resourceData.enabled == undefined || resourceData.enabled == null ? true : resourceData.enabled; - const resourceSsl = resourceData.ssl == undefined || resourceData.ssl == null ? true : resourceData.ssl; + const resourceEnabled = + resourceData.enabled == undefined || resourceData.enabled == null + ? true + : resourceData.enabled; + const resourceSsl = + resourceData.ssl == undefined || resourceData.ssl == null + ? true + : resourceData.ssl; let headers = ""; for (const headerObj of resourceData.headers || []) { for (const [key, value] of Object.entries(headerObj)) { @@ -147,9 +160,7 @@ export async function updateResources( } // check if the only key in the resource is targets, if so, skip the update - if ( - isTargetsOnlyResource(resourceData) - ) { + if (isTargetsOnlyResource(resourceData)) { logger.debug( `Skipping update for resource ${existingResource.resourceId} as only targets are provided` ); @@ -177,6 +188,8 @@ export async function updateResources( ? resourceData.auth["whitelist-users"].length > 0 : false, headers: headers || null, + applyRules: + resourceData.rules && resourceData.rules.length > 0 }) .where( eq(resources.resourceId, existingResource.resourceId) @@ -262,7 +275,11 @@ export async function updateResources( // Create new targets for (const [index, targetData] of resourceData.targets.entries()) { - if (!targetData || (typeof targetData === 'object' && Object.keys(targetData).length === 0)) { + if ( + !targetData || + (typeof targetData === "object" && + Object.keys(targetData).length === 0) + ) { // If targetData is null or an empty object, we can skip it continue; } @@ -354,19 +371,65 @@ export async function updateResources( const targetsToDelete = existingResourceTargets.slice( resourceData.targets.length ); - logger.debug(`Targets to delete: ${JSON.stringify(targetsToDelete)}`); + logger.debug( + `Targets to delete: ${JSON.stringify(targetsToDelete)}` + ); for (const target of targetsToDelete) { if (!target) { continue; - } + } if (siteId && target.siteId !== siteId) { - logger.debug(`Skipping target ${target.targetId} for deletion. Site ID does not match filter.`); + logger.debug( + `Skipping target ${target.targetId} for deletion. Site ID does not match filter.` + ); continue; // only delete targets for the specified siteId } logger.debug(`Deleting target ${target.targetId}`); await trx .delete(targets) - .where(eq(targets.targetId, target.targetId)); + .where(eq(targets.targetId, target.targetId)); + } + } + + const existingRules = await trx + .select() + .from(resourceRules) + .where( + eq(resourceRules.resourceId, existingResource.resourceId) + ) + .orderBy(resourceRules.priority); + + // Sync rules + for (const [index, rule] of resourceData.rules?.entries() || []) { + const existingRule = existingRules[index]; + if (existingRule) { + if ( + existingRule.action !== rule.action || + existingRule.match !== rule.match || + existingRule.value !== rule.value + ) { + await trx + .update(resourceRules) + .set({ + action: rule.action, + match: rule.match, + value: rule.value + }) + .where( + eq(resourceRules.ruleId, existingRule.ruleId) + ); + } + } + } + + if (existingRules.length > (resourceData.rules?.length || 0)) { + const rulesToDelete = existingRules.slice( + resourceData.rules?.length || 0 + ); + for (const rule of rulesToDelete) { + await trx + .delete(resourceRules) + .where(eq(resourceRules.ruleId, rule.ruleId)); } } @@ -401,7 +464,9 @@ export async function updateResources( setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, ssl: resourceSsl, - headers: headers || null + headers: headers || null, + applyRules: + resourceData.rules && resourceData.rules.length > 0 }) .returning(); @@ -484,12 +549,37 @@ export async function updateResources( await createTarget(newResource.resourceId, targetData); } + for (const [index, rule] of resourceData.rules?.entries() || []) { + if (rule.match === "cidr") { + if (!isValidCIDR(rule.value)) { + throw new Error(`Invalid CIDR provided: ${rule.value}`); + } + } else if (rule.match === "ip") { + if (!isValidIP(rule.value)) { + throw new Error(`Invalid IP provided: ${rule.value}`); + } + } else if (rule.match === "path") { + if (!isValidUrlGlobPattern(rule.value)) { + throw new Error( + `Invalid URL glob pattern: ${rule.value}` + ); + } + } + await trx.insert(resourceRules).values({ + resourceId: newResource.resourceId, + action: rule.action, + match: rule.match, + value: rule.value, + priority: index + 1 // start priorities at 1 + }); + } + logger.debug(`Created resource ${newResource.resourceId}`); } results.push({ - resource: resource, - targetsToUpdate, + proxyResource: resource, + targetsToUpdate }); } diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index d668c894..786e5246 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -31,7 +31,13 @@ export const AuthSchema = z.object({ message: "Admin role cannot be included in sso-roles" }), "sso-users": z.array(z.string().email()).optional().default([]), - "whitelist-users": z.array(z.string().email()).optional().default([]) + "whitelist-users": z.array(z.string().email()).optional().default([]), +}); + +export const RuleSchema = z.object({ + action: z.enum(["allow", "deny", "pass"]), + match: z.enum(["cidr", "path", "ip", "country"]), + value: z.string() }); // Schema for individual resource @@ -48,6 +54,7 @@ export const ResourceSchema = z "host-header": z.string().optional(), "tls-server-name": z.string().optional(), headers: z.array(z.record(z.string(), z.string())).optional().default([]), + rules: z.array(RuleSchema).optional().default([]), }) .refine( (resource) => { @@ -164,10 +171,21 @@ export function isTargetsOnlyResource(resource: any): boolean { return Object.keys(resource).length === 1 && resource.targets; } +export const ClientResourceSchema = z.object({ + name: z.string().min(2).max(100), + site: z.string().min(2).max(100), + protocol: z.enum(["tcp", "udp"]), + "proxy-port": z.number().min(1).max(65535), + "hostname": z.string().min(1).max(255), + "internal-port": z.number().min(1).max(65535), + enabled: z.boolean().optional().default(true) +}); + // Schema for the entire configuration object export const ConfigSchema = z .object({ - resources: z.record(z.string(), ResourceSchema).optional().default({}), + "proxy-resources": z.record(z.string(), ResourceSchema).optional().default({}), + "client-resources": z.record(z.string(), ClientResourceSchema).optional().default({}), sites: z.record(z.string(), SiteSchema).optional().default({}) }) .refine( @@ -176,7 +194,7 @@ export const ConfigSchema = z // Extract all full-domain values with their resource keys const fullDomainMap = new Map(); - Object.entries(config.resources).forEach( + Object.entries(config["proxy-resources"]).forEach( ([resourceKey, resource]) => { const fullDomain = resource["full-domain"]; if (fullDomain) { @@ -200,7 +218,7 @@ export const ConfigSchema = z // Extract duplicates for error message const fullDomainMap = new Map(); - Object.entries(config.resources).forEach( + Object.entries(config["proxy-resources"]).forEach( ([resourceKey, resource]) => { const fullDomain = resource["full-domain"]; if (fullDomain) { @@ -226,6 +244,114 @@ export const ConfigSchema = z path: ["resources"] }; } + ) + .refine( + // Enforce proxy-port uniqueness within proxy-resources + (config) => { + const proxyPortMap = new Map(); + + Object.entries(config["proxy-resources"]).forEach( + ([resourceKey, resource]) => { + const proxyPort = resource["proxy-port"]; + if (proxyPort !== undefined) { + if (!proxyPortMap.has(proxyPort)) { + proxyPortMap.set(proxyPort, []); + } + proxyPortMap.get(proxyPort)!.push(resourceKey); + } + } + ); + + // Find duplicates + const duplicates = Array.from(proxyPortMap.entries()).filter( + ([_, resourceKeys]) => resourceKeys.length > 1 + ); + + return duplicates.length === 0; + }, + (config) => { + // Extract duplicates for error message + const proxyPortMap = new Map(); + + Object.entries(config["proxy-resources"]).forEach( + ([resourceKey, resource]) => { + const proxyPort = resource["proxy-port"]; + if (proxyPort !== undefined) { + if (!proxyPortMap.has(proxyPort)) { + proxyPortMap.set(proxyPort, []); + } + proxyPortMap.get(proxyPort)!.push(resourceKey); + } + } + ); + + const duplicates = Array.from(proxyPortMap.entries()) + .filter(([_, resourceKeys]) => resourceKeys.length > 1) + .map( + ([proxyPort, resourceKeys]) => + `port ${proxyPort} used by proxy-resources: ${resourceKeys.join(", ")}` + ) + .join("; "); + + return { + message: `Duplicate 'proxy-port' values found in proxy-resources: ${duplicates}`, + path: ["proxy-resources"] + }; + } + ) + .refine( + // Enforce proxy-port uniqueness within client-resources + (config) => { + const proxyPortMap = new Map(); + + Object.entries(config["client-resources"]).forEach( + ([resourceKey, resource]) => { + const proxyPort = resource["proxy-port"]; + if (proxyPort !== undefined) { + if (!proxyPortMap.has(proxyPort)) { + proxyPortMap.set(proxyPort, []); + } + proxyPortMap.get(proxyPort)!.push(resourceKey); + } + } + ); + + // Find duplicates + const duplicates = Array.from(proxyPortMap.entries()).filter( + ([_, resourceKeys]) => resourceKeys.length > 1 + ); + + return duplicates.length === 0; + }, + (config) => { + // Extract duplicates for error message + const proxyPortMap = new Map(); + + Object.entries(config["client-resources"]).forEach( + ([resourceKey, resource]) => { + const proxyPort = resource["proxy-port"]; + if (proxyPort !== undefined) { + if (!proxyPortMap.has(proxyPort)) { + proxyPortMap.set(proxyPort, []); + } + proxyPortMap.get(proxyPort)!.push(resourceKey); + } + } + ); + + const duplicates = Array.from(proxyPortMap.entries()) + .filter(([_, resourceKeys]) => resourceKeys.length > 1) + .map( + ([proxyPort, resourceKeys]) => + `port ${proxyPort} used by client-resources: ${resourceKeys.join(", ")}` + ) + .join("; "); + + return { + message: `Duplicate 'proxy-port' values found in client-resources: ${duplicates}`, + path: ["client-resources"] + }; + } ); // Type inference from the schema diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index da41c19c..ca223b04 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import { addTargets } from "../client/targets"; +import { getUniqueSiteResourceName } from "@server/db/names"; const createSiteResourceParamsSchema = z .object({ @@ -121,11 +122,14 @@ export async function createSiteResource( ); } + const niceId = await getUniqueSiteResourceName(orgId); + // Create the site resource const [newSiteResource] = await db .insert(siteResources) .values({ siteId, + niceId, orgId, name, protocol, diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts index 914706cd..09c01eb0 100644 --- a/server/routers/siteResource/getSiteResource.ts +++ b/server/routers/siteResource/getSiteResource.ts @@ -12,21 +12,72 @@ import { OpenAPITags, registry } from "@server/openApi"; const getSiteResourceParamsSchema = z .object({ - siteResourceId: z.string().transform(Number).pipe(z.number().int().positive()), + siteResourceId: z + .string() + .optional() + .transform((val) => val ? Number(val) : undefined) + .pipe(z.number().int().positive().optional()) + .optional(), siteId: z.string().transform(Number).pipe(z.number().int().positive()), + niceId: z.string().optional(), orgId: z.string() }) .strict(); -export type GetSiteResourceResponse = SiteResource; +async function query(siteResourceId?: number, siteId?: number, niceId?: string, orgId?: string) { + if (siteResourceId && siteId && orgId) { + const [siteResource] = await db + .select() + .from(siteResources) + .where(and( + eq(siteResources.siteResourceId, siteResourceId), + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId) + )) + .limit(1); + return siteResource; + } else if (niceId && siteId && orgId) { + const [siteResource] = await db + .select() + .from(siteResources) + .where(and( + eq(siteResources.niceId, niceId), + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId) + )) + .limit(1); + return siteResource; + } +} + +export type GetSiteResourceResponse = NonNullable>>; registry.registerPath({ method: "get", path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}", - description: "Get a specific site resource.", + description: "Get a specific site resource by siteResourceId.", tags: [OpenAPITags.Client, OpenAPITags.Org], request: { - params: getSiteResourceParamsSchema + params: z.object({ + siteResourceId: z.number(), + siteId: z.number(), + orgId: z.string() + }) + }, + responses: {} +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/site/{siteId}/resource/nice/{niceId}", + description: "Get a specific site resource by niceId.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + params: z.object({ + niceId: z.string(), + siteId: z.number(), + orgId: z.string() + }) }, responses: {} }); @@ -47,18 +98,10 @@ export async function getSiteResource( ); } - const { siteResourceId, siteId, orgId } = parsedParams.data; + const { siteResourceId, siteId, niceId, orgId } = parsedParams.data; // Get the site resource - const [siteResource] = await db - .select() - .from(siteResources) - .where(and( - eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId) - )) - .limit(1); + const siteResource = await query(siteResourceId, siteId, niceId, orgId); if (!siteResource) { return next( diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index ccfddcd8..63dfc11d 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -352,7 +352,7 @@ export default function CreateInternalResourceDialog({ render={({ field }) => ( - {t("createInternalResourceDialogDestinationIP")} + {t("targetAddr")} ( - {t("createInternalResourceDialogDestinationPort")} + {t("targetPort")} ( - {t("editInternalResourceDialogDestinationIP")} + {t("targetAddr")} @@ -235,7 +235,7 @@ export default function EditInternalResourceDialog({ name="destinationPort" render={({ field }) => ( - {t("editInternalResourceDialogDestinationPort")} + {t("targetPort")}