mirror of
https://github.com/fosrl/pangolin.git
synced 2025-12-02 06:05:55 +00:00
Site resources for the blueprint
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<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> {
|
||||
let loops = 0;
|
||||
const count = await db
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
117
server/lib/blueprints/clientResources.ts
Normal file
117
server/lib/blueprints/clientResources.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ResourcesResults> {
|
||||
const results: ResourcesResults = [];
|
||||
): Promise<ProxyResourcesResults> {
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, string[]>();
|
||||
|
||||
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<string, string[]>();
|
||||
|
||||
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<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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof query>>>;
|
||||
|
||||
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(
|
||||
|
||||
@@ -352,7 +352,7 @@ export default function CreateInternalResourceDialog({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("createInternalResourceDialogDestinationIP")}
|
||||
{t("targetAddr")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -373,7 +373,7 @@ export default function CreateInternalResourceDialog({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("createInternalResourceDialogDestinationPort")}
|
||||
{t("targetPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
||||
@@ -221,7 +221,7 @@ export default function EditInternalResourceDialog({
|
||||
name="destinationIp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogDestinationIP")}</FormLabel>
|
||||
<FormLabel>{t("targetAddr")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -235,7 +235,7 @@ export default function EditInternalResourceDialog({
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogDestinationPort")}</FormLabel>
|
||||
<FormLabel>{t("targetPort")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
|
||||
Reference in New Issue
Block a user