mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-05 18:26:40 +00:00
Site resources for the blueprint
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
// }
|
// }
|
||||||
// ]
|
// ]
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
// });
|
// });
|
||||||
|
|||||||
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 = {};
|
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;
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user