Site resources for the blueprint

This commit is contained in:
Owen
2025-09-14 15:57:41 -07:00
parent 8929f389f4
commit 58c04fd196
13 changed files with 520 additions and 71 deletions

View File

@@ -1400,8 +1400,6 @@
"editInternalResourceDialogProtocol": "Protocol", "editInternalResourceDialogProtocol": "Protocol",
"editInternalResourceDialogSitePort": "Site Port", "editInternalResourceDialogSitePort": "Site Port",
"editInternalResourceDialogTargetConfiguration": "Target Configuration", "editInternalResourceDialogTargetConfiguration": "Target Configuration",
"editInternalResourceDialogDestinationIP": "Destination IP",
"editInternalResourceDialogDestinationPort": "Destination Port",
"editInternalResourceDialogCancel": "Cancel", "editInternalResourceDialogCancel": "Cancel",
"editInternalResourceDialogSaveResource": "Save Resource", "editInternalResourceDialogSaveResource": "Save Resource",
"editInternalResourceDialogSuccess": "Success", "editInternalResourceDialogSuccess": "Success",
@@ -1432,9 +1430,7 @@
"createInternalResourceDialogSitePort": "Site Port", "createInternalResourceDialogSitePort": "Site Port",
"createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.", "createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.",
"createInternalResourceDialogTargetConfiguration": "Target Configuration", "createInternalResourceDialogTargetConfiguration": "Target Configuration",
"createInternalResourceDialogDestinationIP": "Destination IP", "createInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
"createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.",
"createInternalResourceDialogDestinationPort": "Destination Port",
"createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.", "createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.",
"createInternalResourceDialogCancel": "Cancel", "createInternalResourceDialogCancel": "Cancel",
"createInternalResourceDialogCreateResource": "Create Resource", "createInternalResourceDialogCreateResource": "Create Resource",

View File

@@ -1,6 +1,6 @@
import { join } from "path"; import { join } from "path";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { db, resources } from "@server/db"; import { db, resources, siteResources } from "@server/db";
import { exitNodes, sites } from "@server/db"; import { exitNodes, sites } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { __DIRNAME } from "@server/lib/consts"; import { __DIRNAME } from "@server/lib/consts";
@@ -53,6 +53,25 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
} }
} }
export async function getUniqueSiteResourceName(orgId: string): Promise<string> {
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<string> { export async function getUniqueExitNodeEndpointName(): Promise<string> {
let loops = 0; let loops = 0;
const count = await db const count = await db

View File

@@ -143,6 +143,7 @@ export const siteResources = pgTable("siteResources", { // this is for the clien
orgId: varchar("orgId") orgId: varchar("orgId")
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
protocol: varchar("protocol").notNull(), protocol: varchar("protocol").notNull(),
proxyPort: integer("proxyPort").notNull(), proxyPort: integer("proxyPort").notNull(),

View File

@@ -158,6 +158,7 @@ export const siteResources = sqliteTable("siteResources", {
orgId: text("orgId") orgId: text("orgId")
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
niceId: text("niceId").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
protocol: text("protocol").notNull(), protocol: text("protocol").notNull(),
proxyPort: integer("proxyPort").notNull(), proxyPort: integer("proxyPort").notNull(),

View File

@@ -1,11 +1,16 @@
import { db, newts, Target } from "@server/db"; import { db, newts, Target } from "@server/db";
import { Config, ConfigSchema } from "./types"; import { Config, ConfigSchema } from "./types";
import { ResourcesResults, updateResources } from "./resources"; import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { resources, targets, sites } from "@server/db"; import { resources, targets, sites } from "@server/db";
import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm"; 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( export async function applyBlueprint(
orgId: string, orgId: string,
@@ -21,17 +26,29 @@ export async function applyBlueprint(
const config: Config = validationResult.data; const config: Config = validationResult.data;
try { try {
let resourcesResults: ResourcesResults = []; let proxyResourcesResults: ProxyResourcesResults = [];
let clientResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => { 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( 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 // 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) { for (const target of result.targetsToUpdate) {
const [site] = await db const [site] = await db
.select() .select()
@@ -52,15 +69,50 @@ export async function applyBlueprint(
`Updating target ${target.targetId} on site ${site.sites.siteId}` `Updating target ${target.targetId} on site ${site.sites.siteId}`
); );
await addTargets( await addProxyTargets(
site.newt.newtId, site.newt.newtId,
[target], [target],
result.resource.protocol, result.proxyResource.protocol,
result.resource.proxyPort 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) { } catch (error) {
logger.error(`Failed to update database from config: ${error}`); logger.error(`Failed to update database from config: ${error}`);
throw error; throw error;
@@ -102,17 +154,17 @@ export async function applyBlueprint(
// } // }
// ] // ]
// }, // },
// "resource-nice-id2": { // "resource-nice-id2": {
// name: "http server", // name: "http server",
// protocol: "tcp", // protocol: "tcp",
// "proxy-port": 3000, // "proxy-port": 3000,
// targets: [ // targets: [
// { // {
// site: "glossy-plains-viscacha-rat", // site: "glossy-plains-viscacha-rat",
// hostname: "localhost", // hostname: "localhost",
// port: 3000, // port: 3000,
// } // }
// ] // ]
// } // }
// } // }
// }); // });

View File

@@ -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<ClientResourcesResults> {
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;
}

View File

@@ -66,9 +66,9 @@ export function processContainerLabels(containers: Container[]): {
const resourceLabels: DockerLabels = {}; 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]) => { 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 // remove the pangolin. prefix
const strippedKey = key.replace("pangolin.", ""); const strippedKey = key.replace("pangolin.", "");
resourceLabels[strippedKey] = value; resourceLabels[strippedKey] = value;

View File

@@ -3,6 +3,7 @@ import {
orgDomains, orgDomains,
Resource, Resource,
resourcePincode, resourcePincode,
resourceRules,
resourceWhitelist, resourceWhitelist,
roleResources, roleResources,
roles, roles,
@@ -14,27 +15,33 @@ import {
} from "@server/db"; } from "@server/db";
import { resources, targets, sites } from "@server/db"; import { resources, targets, sites } from "@server/db";
import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm"; 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 logger from "@server/logger";
import { pickPort } from "@server/routers/target/helpers"; import { pickPort } from "@server/routers/target/helpers";
import { resourcePassword } from "@server/db"; import { resourcePassword } from "@server/db";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
export type ResourcesResults = { export type ProxyResourcesResults = {
resource: Resource; proxyResource: Resource;
targetsToUpdate: Target[]; targetsToUpdate: Target[];
}[]; }[];
export async function updateResources( export async function updateProxyResources(
orgId: string, orgId: string,
config: Config, config: Config,
trx: Transaction, trx: Transaction,
siteId?: number siteId?: number
): Promise<ResourcesResults> { ): Promise<ProxyResourcesResults> {
const results: ResourcesResults = []; const results: ProxyResourcesResults = [];
for (const [resourceNiceId, resourceData] of Object.entries( for (const [resourceNiceId, resourceData] of Object.entries(
config.resources config["proxy-resources"]
)) { )) {
const targetsToUpdate: Target[] = []; const targetsToUpdate: Target[] = [];
let resource: Resource; let resource: Resource;
@@ -122,8 +129,14 @@ export async function updateResources(
const http = resourceData.protocol == "http"; const http = resourceData.protocol == "http";
const protocol = const protocol =
resourceData.protocol == "http" ? "tcp" : resourceData.protocol; resourceData.protocol == "http" ? "tcp" : resourceData.protocol;
const resourceEnabled = resourceData.enabled == undefined || resourceData.enabled == null ? true : resourceData.enabled; const resourceEnabled =
const resourceSsl = resourceData.ssl == undefined || resourceData.ssl == null ? true : resourceData.ssl; resourceData.enabled == undefined || resourceData.enabled == null
? true
: resourceData.enabled;
const resourceSsl =
resourceData.ssl == undefined || resourceData.ssl == null
? true
: resourceData.ssl;
let headers = ""; let headers = "";
for (const headerObj of resourceData.headers || []) { for (const headerObj of resourceData.headers || []) {
for (const [key, value] of Object.entries(headerObj)) { 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 // check if the only key in the resource is targets, if so, skip the update
if ( if (isTargetsOnlyResource(resourceData)) {
isTargetsOnlyResource(resourceData)
) {
logger.debug( logger.debug(
`Skipping update for resource ${existingResource.resourceId} as only targets are provided` `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 ? resourceData.auth["whitelist-users"].length > 0
: false, : false,
headers: headers || null, headers: headers || null,
applyRules:
resourceData.rules && resourceData.rules.length > 0
}) })
.where( .where(
eq(resources.resourceId, existingResource.resourceId) eq(resources.resourceId, existingResource.resourceId)
@@ -262,7 +275,11 @@ export async function updateResources(
// Create new targets // Create new targets
for (const [index, targetData] of resourceData.targets.entries()) { 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 // If targetData is null or an empty object, we can skip it
continue; continue;
} }
@@ -354,19 +371,65 @@ export async function updateResources(
const targetsToDelete = existingResourceTargets.slice( const targetsToDelete = existingResourceTargets.slice(
resourceData.targets.length resourceData.targets.length
); );
logger.debug(`Targets to delete: ${JSON.stringify(targetsToDelete)}`); logger.debug(
`Targets to delete: ${JSON.stringify(targetsToDelete)}`
);
for (const target of targetsToDelete) { for (const target of targetsToDelete) {
if (!target) { if (!target) {
continue; continue;
} }
if (siteId && target.siteId !== siteId) { 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 continue; // only delete targets for the specified siteId
} }
logger.debug(`Deleting target ${target.targetId}`); logger.debug(`Deleting target ${target.targetId}`);
await trx await trx
.delete(targets) .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, setHostHeader: resourceData["host-header"] || null,
tlsServerName: resourceData["tls-server-name"] || null, tlsServerName: resourceData["tls-server-name"] || null,
ssl: resourceSsl, ssl: resourceSsl,
headers: headers || null headers: headers || null,
applyRules:
resourceData.rules && resourceData.rules.length > 0
}) })
.returning(); .returning();
@@ -484,12 +549,37 @@ export async function updateResources(
await createTarget(newResource.resourceId, targetData); 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}`); logger.debug(`Created resource ${newResource.resourceId}`);
} }
results.push({ results.push({
resource: resource, proxyResource: resource,
targetsToUpdate, targetsToUpdate
}); });
} }

View File

@@ -31,7 +31,13 @@ export const AuthSchema = z.object({
message: "Admin role cannot be included in sso-roles" message: "Admin role cannot be included in sso-roles"
}), }),
"sso-users": z.array(z.string().email()).optional().default([]), "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 // Schema for individual resource
@@ -48,6 +54,7 @@ export const ResourceSchema = z
"host-header": z.string().optional(), "host-header": z.string().optional(),
"tls-server-name": z.string().optional(), "tls-server-name": z.string().optional(),
headers: z.array(z.record(z.string(), z.string())).optional().default([]), headers: z.array(z.record(z.string(), z.string())).optional().default([]),
rules: z.array(RuleSchema).optional().default([]),
}) })
.refine( .refine(
(resource) => { (resource) => {
@@ -164,10 +171,21 @@ export function isTargetsOnlyResource(resource: any): boolean {
return Object.keys(resource).length === 1 && resource.targets; 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 // Schema for the entire configuration object
export const ConfigSchema = z export const ConfigSchema = z
.object({ .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({}) sites: z.record(z.string(), SiteSchema).optional().default({})
}) })
.refine( .refine(
@@ -176,7 +194,7 @@ export const ConfigSchema = z
// Extract all full-domain values with their resource keys // Extract all full-domain values with their resource keys
const fullDomainMap = new Map<string, string[]>(); const fullDomainMap = new Map<string, string[]>();
Object.entries(config.resources).forEach( Object.entries(config["proxy-resources"]).forEach(
([resourceKey, resource]) => { ([resourceKey, resource]) => {
const fullDomain = resource["full-domain"]; const fullDomain = resource["full-domain"];
if (fullDomain) { if (fullDomain) {
@@ -200,7 +218,7 @@ export const ConfigSchema = z
// Extract duplicates for error message // Extract duplicates for error message
const fullDomainMap = new Map<string, string[]>(); const fullDomainMap = new Map<string, string[]>();
Object.entries(config.resources).forEach( Object.entries(config["proxy-resources"]).forEach(
([resourceKey, resource]) => { ([resourceKey, resource]) => {
const fullDomain = resource["full-domain"]; const fullDomain = resource["full-domain"];
if (fullDomain) { if (fullDomain) {
@@ -226,6 +244,114 @@ export const ConfigSchema = z
path: ["resources"] path: ["resources"]
}; };
} }
)
.refine(
// Enforce proxy-port uniqueness within proxy-resources
(config) => {
const proxyPortMap = new Map<number, string[]>();
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<number, string[]>();
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<number, string[]>();
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<number, string[]>();
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 // Type inference from the schema

View File

@@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { addTargets } from "../client/targets"; import { addTargets } from "../client/targets";
import { getUniqueSiteResourceName } from "@server/db/names";
const createSiteResourceParamsSchema = z const createSiteResourceParamsSchema = z
.object({ .object({
@@ -121,11 +122,14 @@ export async function createSiteResource(
); );
} }
const niceId = await getUniqueSiteResourceName(orgId);
// Create the site resource // Create the site resource
const [newSiteResource] = await db const [newSiteResource] = await db
.insert(siteResources) .insert(siteResources)
.values({ .values({
siteId, siteId,
niceId,
orgId, orgId,
name, name,
protocol, protocol,

View File

@@ -12,21 +12,72 @@ import { OpenAPITags, registry } from "@server/openApi";
const getSiteResourceParamsSchema = z const getSiteResourceParamsSchema = z
.object({ .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()), siteId: z.string().transform(Number).pipe(z.number().int().positive()),
niceId: z.string().optional(),
orgId: z.string() orgId: z.string()
}) })
.strict(); .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<Awaited<ReturnType<typeof query>>>;
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}", 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], tags: [OpenAPITags.Client, OpenAPITags.Org],
request: { 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: {} 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 // Get the site resource
const [siteResource] = await db const siteResource = await query(siteResourceId, siteId, niceId, orgId);
.select()
.from(siteResources)
.where(and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
))
.limit(1);
if (!siteResource) { if (!siteResource) {
return next( return next(

View File

@@ -352,7 +352,7 @@ export default function CreateInternalResourceDialog({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t("createInternalResourceDialogDestinationIP")} {t("targetAddr")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
@@ -373,7 +373,7 @@ export default function CreateInternalResourceDialog({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t("createInternalResourceDialogDestinationPort")} {t("targetPort")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input

View File

@@ -221,7 +221,7 @@ export default function EditInternalResourceDialog({
name="destinationIp" name="destinationIp"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("editInternalResourceDialogDestinationIP")}</FormLabel> <FormLabel>{t("targetAddr")}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -235,7 +235,7 @@ export default function EditInternalResourceDialog({
name="destinationPort" name="destinationPort"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("editInternalResourceDialogDestinationPort")}</FormLabel> <FormLabel>{t("targetPort")}</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"