Compare commits

...

11 Commits

Author SHA1 Message Date
Owen b6d688f15e Support pin,pass,whitelist correctly on login 2026-06-01 21:34:39 -07:00
Owen 8a57d8dd9c Resource rules go first 2026-06-01 21:02:59 -07:00
Owen 8e0e32c2be Overriding is working 2026-06-01 20:54:37 -07:00
Owen 6b3a0a2113 Remove the admin from the picker 2026-06-01 20:33:37 -07:00
Owen 4d6ed7eec5 Pull from the policies to show to users 2026-06-01 17:49:09 -07:00
Owen 1625dd1add Include the new policy tables in the data 2026-06-01 17:04:33 -07:00
Owen 605dd2f3c9 Add tcp and udp specific pages 2026-06-01 16:05:20 -07:00
Owen 51bb149fd5 Update to latest badger 2026-06-01 15:27:34 -07:00
Owen 2ae4c29418 Add missing set 2026-06-01 15:27:30 -07:00
Owen ba71016f87 Add inline policy migration 2026-06-01 15:18:40 -07:00
Owen 85c2bd807e Handle the new added mode column 2026-06-01 14:49:41 -07:00
24 changed files with 1822 additions and 2346 deletions
+15 -22
View File
@@ -1,54 +1,47 @@
api:
insecure: true
dashboard: true
providers:
http:
endpoint: "http://pangolin:3001/api/v1/traefik-config"
pollInterval: "5s"
endpoint: http://pangolin:3001/api/v1/traefik-config
pollInterval: 5s
file:
filename: "/etc/traefik/dynamic_config.yml"
filename: /etc/traefik/dynamic_config.yml
experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "{{.BadgerVersion}}"
moduleName: github.com/fosrl/badger
version: v1.4.1
log:
level: "INFO"
format: "common"
level: INFO
format: common
maxSize: 100
maxBackups: 3
maxAge: 3
compress: true
certificatesResolvers:
letsencrypt:
acme:
httpChallenge:
entryPoint: web
email: "{{.LetsEncryptEmail}}"
storage: "/letsencrypt/acme.json"
caServer: "https://acme-v02.api.letsencrypt.org/directory"
email: '{{.LetsEncryptEmail}}'
storage: /letsencrypt/acme.json
caServer: https://acme-v02.api.letsencrypt.org/directory
entryPoints:
web:
address: ":80"
address: ':80'
websecure:
address: ":443"
address: ':443'
transport:
respondingTimeouts:
readTimeout: "30m"
readTimeout: 30m
http:
tls:
certResolver: "letsencrypt"
certResolver: letsencrypt
encodedCharacters:
allowEncodedSlash: true
allowEncodedQuestionMark: true
serversTransport:
insecureSkipVerify: true
ping:
entryPoint: "web"
entryPoint: web
+5 -2
View File
@@ -872,6 +872,7 @@
"resourcePolicyOtpEmpty": "No one time password",
"resourcePolicyReadOnly": "This policy is Read only",
"resourcePolicyReadOnlyDescription": "This resource policy is shared accross multiple resources, you cannot edit it on this page.",
"editSharedPolicy": "Edit Shared Policy",
"resourcePolicyTypeSave": "Save Resource type",
"resourcePolicySelect": "Select resource policy",
"resourcePolicySelectError": "Select a resource policy",
@@ -918,7 +919,7 @@
"resourcePolicyInline": "Inline Resource Policy",
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
"resourcePolicyShared": "Shared Resource Policy",
"resourcePolicySharedDescription": "Access Policy shared accross multiple resources",
"resourcePolicySharedDescription": "This resource uses a shared policy. Policy-level settings (auth methods, email whitelist) are locked. You can add resource-specific rules, roles, and users below.",
"resourceUsersRoles": "Access Controls",
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
"resourceUsersRolesSubmit": "Save Access Controls",
@@ -3430,5 +3431,7 @@
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
"memberPortalPrevious": "Previous",
"memberPortalNext": "Next",
"httpSettings": "HTTP Settings"
"httpSettings": "HTTP Settings",
"tcpSettings": "TCP Settings",
"udpSettings": "UDP Settings"
}
+10 -1
View File
@@ -19,6 +19,9 @@ export async function createResourceSession(opts: {
userSessionId?: string | null;
whitelistId?: number | null;
accessTokenId?: string | null;
policyPasswordId?: number | null;
policyPincodeId?: number | null;
policyWhitelistId?: number | null;
doNotExtend?: boolean;
expiresAt?: number | null;
sessionLength?: number | null;
@@ -28,7 +31,10 @@ export async function createResourceSession(opts: {
!opts.pincodeId &&
!opts.whitelistId &&
!opts.accessTokenId &&
!opts.userSessionId
!opts.userSessionId &&
!opts.policyPasswordId &&
!opts.policyPincodeId &&
!opts.policyWhitelistId
) {
throw new Error("Auth method must be provided");
}
@@ -49,6 +55,9 @@ export async function createResourceSession(opts: {
whitelistId: opts.whitelistId || null,
doNotExtend: opts.doNotExtend || false,
accessTokenId: opts.accessTokenId || null,
policyPasswordId: opts.policyPasswordId || null,
policyPincodeId: opts.policyPincodeId || null,
policyWhitelistId: opts.policyWhitelistId || null,
isRequestToken: opts.isRequestToken || false,
userSessionId: opts.userSessionId || null,
issuedAt: new Date().getTime()
+18
View File
@@ -820,6 +820,24 @@ export const resourceSessions = pgTable("resourceSessions", {
onDelete: "cascade"
}
),
policyPasswordId: integer("policyPasswordId").references(
() => resourcePolicyPassword.passwordId,
{
onDelete: "cascade"
}
),
policyPincodeId: integer("policyPincodeId").references(
() => resourcePolicyPincode.pincodeId,
{
onDelete: "cascade"
}
),
policyWhitelistId: integer("policyWhitelistId").references(
() => resourcePolicyWhiteList.whitelistId,
{
onDelete: "cascade"
}
),
issuedAt: bigint("issuedAt", { mode: "number" })
});
+18
View File
@@ -1148,6 +1148,24 @@ export const resourceSessions = sqliteTable("resourceSessions", {
onDelete: "cascade"
}
),
policyPasswordId: integer("policyPasswordId").references(
() => resourcePolicyPassword.passwordId,
{
onDelete: "cascade"
}
),
policyPincodeId: integer("policyPincodeId").references(
() => resourcePolicyPincode.pincodeId,
{
onDelete: "cascade"
}
),
policyWhitelistId: integer("policyWhitelistId").references(
() => resourcePolicyWhiteList.whitelistId,
{
onDelete: "cascade"
}
),
issuedAt: integer("issuedAt")
});
+2 -1
View File
@@ -520,7 +520,8 @@ export class TraefikConfigManager {
build != "oss", // generate the login pages on the cloud and hybrid,
build == "saas"
? false
: config.getRawConfig().traefik.allow_raw_resources // dont allow raw resources on saas otherwise use config
: config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config
build != "oss" // generate browser gateway targets on cloud and enterprise
);
const domains = new Set<string>();
+321 -304
View File
@@ -85,7 +85,8 @@ export async function getTraefikConfig(
filterOutNamespaceDomains = false,
generateLoginPageRouters = false,
allowRawResources = true,
allowMaintenancePage = true
allowMaintenancePage = true,
allowBrowserGatewayResources = true
): Promise<any> {
// Get resources with their targets and sites in a single optimized query
// Start from sites on this exit node, then join to targets and resources
@@ -276,64 +277,6 @@ export async function getTraefikConfig(
});
});
// Query browser gateway targets for this exit node
const browserGatewayRows = await db
.select({
// Resource fields
resourceId: resources.resourceId,
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
wildcard: resources.wildcard,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert,
domainNamespaceId: domainNamespaces.domainNamespaceId,
// Maintenance fields
maintenanceModeEnabled: resources.maintenanceModeEnabled,
maintenanceModeType: resources.maintenanceModeType,
maintenanceTitle: resources.maintenanceTitle,
maintenanceMessage: resources.maintenanceMessage,
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
// Browser gateway target fields
browserGatewayTargetId: browserGatewayTarget.browserGatewayTargetId,
bgType: browserGatewayTarget.type,
// Site fields
siteId: sites.siteId,
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
siteExitNodeId: sites.exitNodeId
})
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.innerJoin(
resources,
eq(resources.resourceId, browserGatewayTarget.resourceId)
)
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.leftJoin(
domainNamespaces,
eq(domainNamespaces.domainId, resources.domainId)
)
.where(
and(
eq(resources.enabled, true),
or(
eq(sites.exitNodeId, exitNodeId),
and(
isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
eq(sites.type, "local"),
sql`(${build != "saas" ? 1 : 0} = 1)`
)
),
inArray(sites.type, siteTypes)
)
);
// Group browser gateway targets by resource
type BrowserGatewayResourceEntry = {
resourceId: number;
@@ -366,39 +309,100 @@ export async function getTraefikConfig(
BrowserGatewayResourceEntry
>();
for (const row of browserGatewayRows) {
if (filterOutNamespaceDomains && row.domainNamespaceId) {
continue;
}
if (!browserGatewayResourcesMap.has(row.resourceId)) {
browserGatewayResourcesMap.set(row.resourceId, {
resourceId: row.resourceId,
name: sanitize(row.resourceName) || "",
fullDomain: row.fullDomain,
ssl: row.ssl,
subdomain: row.subdomain,
domainId: row.domainId,
enabled: row.enabled,
wildcard: row.wildcard,
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert,
maintenanceModeEnabled: row.maintenanceModeEnabled,
maintenanceModeType: row.maintenanceModeType,
maintenanceTitle: row.maintenanceTitle,
maintenanceMessage: row.maintenanceMessage,
maintenanceEstimatedTime: row.maintenanceEstimatedTime,
targets: []
if (allowBrowserGatewayResources) {
// Query browser gateway targets for this exit node
const browserGatewayRows = await db
.select({
// Resource fields
resourceId: resources.resourceId,
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
wildcard: resources.wildcard,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert,
domainNamespaceId: domainNamespaces.domainNamespaceId,
// Maintenance fields
maintenanceModeEnabled: resources.maintenanceModeEnabled,
maintenanceModeType: resources.maintenanceModeType,
maintenanceTitle: resources.maintenanceTitle,
maintenanceMessage: resources.maintenanceMessage,
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
// Browser gateway target fields
browserGatewayTargetId:
browserGatewayTarget.browserGatewayTargetId,
bgType: browserGatewayTarget.type,
// Site fields
siteId: sites.siteId,
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
siteExitNodeId: sites.exitNodeId
})
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.innerJoin(
resources,
eq(resources.resourceId, browserGatewayTarget.resourceId)
)
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.leftJoin(
domainNamespaces,
eq(domainNamespaces.domainId, resources.domainId)
)
.where(
and(
eq(resources.enabled, true),
or(
eq(sites.exitNodeId, exitNodeId),
and(
isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
eq(sites.type, "local"),
sql`(${build != "saas" ? 1 : 0} = 1)`
)
),
inArray(sites.type, siteTypes)
)
);
for (const row of browserGatewayRows) {
if (filterOutNamespaceDomains && row.domainNamespaceId) {
continue;
}
if (!browserGatewayResourcesMap.has(row.resourceId)) {
browserGatewayResourcesMap.set(row.resourceId, {
resourceId: row.resourceId,
name: sanitize(row.resourceName) || "",
fullDomain: row.fullDomain,
ssl: row.ssl,
subdomain: row.subdomain,
domainId: row.domainId,
enabled: row.enabled,
wildcard: row.wildcard,
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert,
maintenanceModeEnabled: row.maintenanceModeEnabled,
maintenanceModeType: row.maintenanceModeType,
maintenanceTitle: row.maintenanceTitle,
maintenanceMessage: row.maintenanceMessage,
maintenanceEstimatedTime: row.maintenanceEstimatedTime,
targets: []
});
}
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
browserGatewayTargetId: row.browserGatewayTargetId,
bgType: row.bgType,
siteId: row.siteId,
siteType: row.siteType,
siteOnline: row.siteOnline,
subnet: row.subnet,
siteExitNodeId: row.siteExitNodeId
});
}
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
browserGatewayTargetId: row.browserGatewayTargetId,
bgType: row.bgType,
siteId: row.siteId,
siteType: row.siteType,
siteOnline: row.siteOnline,
subnet: row.subnet,
siteExitNodeId: row.siteExitNodeId
});
}
let siteResourcesWithFullDomain: {
@@ -1055,245 +1059,257 @@ export async function getTraefikConfig(
}
}
// Generate Traefik config for browser gateway resources
const browserGatewayPort = 39999;
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
if (!bgResource.enabled) continue;
if (!bgResource.domainId) continue;
if (!bgResource.fullDomain) continue;
if (allowBrowserGatewayResources) {
// Generate Traefik config for browser gateway resources
const browserGatewayPort = 39999;
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
if (!bgResource.enabled) continue;
if (!bgResource.domainId) continue;
if (!bgResource.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = bgResource.fullDomain;
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
...additionalMiddlewares
];
const fullDomain = bgResource.fullDomain;
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
...additionalMiddlewares
];
const hostRule = `Host(\`${fullDomain}\`)`;
const hostRule = `Host(\`${fullDomain}\`)`;
// Build TLS config
let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
let wildCard: string;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
// Build TLS config
let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
let wildCard: string;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!bgResource.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = bgResource.domainCertResolver
? bgResource.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
bgResource.preferWildcardCert !== undefined &&
bgResource.preferWildcardCert !== null
? bgResource.preferWildcardCert
: globalDefaultPreferWildcard;
tls = {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!bgResource.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = bgResource.domainCertResolver
? bgResource.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
bgResource.preferWildcardCert !== undefined &&
bgResource.preferWildcardCert !== null
? bgResource.preferWildcardCert
: globalDefaultPreferWildcard;
tls = {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
} else {
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for browser gateway domain: ${fullDomain}`
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for browser gateway domain: ${fullDomain}`
);
continue;
}
}
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
if (bgResource.ssl) {
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
config_output.http.routers![redirectRouterName] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: bgUiServiceName,
rule: hostRule,
priority: 100
};
}
// Collect online sites for this resource (for any type)
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
// Maintenance page logic for browser gateway resources
let showBgMaintenancePage = false;
if (bgResource.maintenanceModeEnabled) {
if (bgResource.maintenanceModeType === "forced") {
showBgMaintenancePage = true;
} else if (bgResource.maintenanceModeType === "automatic") {
showBgMaintenancePage = !anySiteOnline;
}
}
if (showBgMaintenancePage && allowMaintenancePage) {
const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`;
const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`;
const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`;
const entrypointHttp =
config.getRawConfig().traefik.http_entrypoint;
const entrypointHttps =
config.getRawConfig().traefik.https_entrypoint;
const maintenancePort = config.getRawConfig().server.next_port;
const maintenanceHost =
config.getRawConfig().server.internal_hostname;
if (!config_output.http.services)
config_output.http.services = {};
if (!config_output.http.middlewares)
config_output.http.middlewares = {};
if (!config_output.http.routers)
config_output.http.routers = {};
config_output.http.services![bgMaintenanceServiceName] = {
loadBalancer: {
servers: [
{
url: `http://${maintenanceHost}:${maintenancePort}`
}
],
passHostHeader: true
}
};
config_output.http.middlewares![bgRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: "/maintenance-screen"
}
};
config_output.http.routers![bgMaintenanceRouterName] = {
entryPoints: [
bgResource.ssl ? entrypointHttps : entrypointHttp
],
service: bgMaintenanceServiceName,
middlewares: [bgRewriteMiddlewareName],
rule: hostRule,
priority: 2000,
...(bgResource.ssl ? { tls } : {})
};
config_output.http.routers![
`${bgMaintenanceRouterName}-assets`
] = {
entryPoints: [
bgResource.ssl ? entrypointHttps : entrypointHttp
],
service: bgMaintenanceServiceName,
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 2001,
...(bgResource.ssl ? { tls } : {})
};
continue;
}
}
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
if (bgResource.ssl) {
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
config_output.http.routers![redirectRouterName] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: bgUiServiceName,
rule: hostRule,
priority: 100
};
}
// Collect online sites for this resource (for any type)
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
// Maintenance page logic for browser gateway resources
let showBgMaintenancePage = false;
if (bgResource.maintenanceModeEnabled) {
if (bgResource.maintenanceModeType === "forced") {
showBgMaintenancePage = true;
} else if (bgResource.maintenanceModeType === "automatic") {
showBgMaintenancePage = !anySiteOnline;
// Group targets by type and generate per-type websocket routers and services
const typeMap = new Map<string, typeof bgResource.targets>();
for (const t of bgResource.targets) {
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
typeMap.get(t.bgType)!.push(t);
}
}
if (showBgMaintenancePage && allowMaintenancePage) {
const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`;
const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`;
const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`;
for (const [bgType, typedTargets] of typeMap.entries()) {
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
const bgRouterName = `${bgKey}-router`;
const bgServiceName = `${bgKey}-service`;
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
const entrypointHttp =
config.getRawConfig().traefik.http_entrypoint;
const entrypointHttps =
config.getRawConfig().traefik.https_entrypoint;
const servers = typedTargets
.filter((t) => {
if (!t.siteOnline && anySiteOnline) return false;
if (t.siteType === "newt") return !!t.subnet;
return false; // browser gateway only supported on newt sites
})
.map((t) => ({
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
}))
.filter(
(v, i, a) => a.findIndex((u) => u.url === v.url) === i
);
const maintenancePort = config.getRawConfig().server.next_port;
const maintenanceHost =
config.getRawConfig().server.internal_hostname;
if (!config_output.http.services) config_output.http.services = {};
if (!config_output.http.middlewares)
config_output.http.middlewares = {};
if (!config_output.http.routers) config_output.http.routers = {};
config_output.http.services![bgMaintenanceServiceName] = {
loadBalancer: {
servers: [
{ url: `http://${maintenanceHost}:${maintenancePort}` }
config_output.http.routers![bgRouterName] = {
entryPoints: [
bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
passHostHeader: true
}
};
middlewares: routerMiddlewares,
service: bgServiceName,
rule: bgRule,
priority: 110, // highest - websocket path takes precedence
...(bgResource.ssl ? { tls } : {})
};
config_output.http.middlewares![bgRewriteMiddlewareName] = {
config_output.http.services![bgServiceName] = {
loadBalancer: {
servers
}
};
}
// UI: serve the browser gateway page from the internal pangolin instance.
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
// how the maintenance page rewrites everything to /maintenance-screen.
const primaryType = typeMap.keys().next().value as string;
const internalHost = config.getRawConfig().server.internal_hostname;
const internalPort = config.getRawConfig().server.next_port;
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
const entrypoint = bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint;
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares![uiRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: "/maintenance-screen"
replacement: `/${primaryType}`
}
};
config_output.http.routers![bgMaintenanceRouterName] = {
entryPoints: [
bgResource.ssl ? entrypointHttps : entrypointHttp
],
service: bgMaintenanceServiceName,
middlewares: [bgRewriteMiddlewareName],
rule: hostRule,
priority: 2000,
...(bgResource.ssl ? { tls } : {})
};
config_output.http.routers![`${bgMaintenanceRouterName}-assets`] = {
entryPoints: [
bgResource.ssl ? entrypointHttps : entrypointHttp
],
service: bgMaintenanceServiceName,
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 2001,
...(bgResource.ssl ? { tls } : {})
};
continue;
}
// Group targets by type and generate per-type websocket routers and services
const typeMap = new Map<string, typeof bgResource.targets>();
for (const t of bgResource.targets) {
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
typeMap.get(t.bgType)!.push(t);
}
for (const [bgType, typedTargets] of typeMap.entries()) {
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
const bgRouterName = `${bgKey}-router`;
const bgServiceName = `${bgKey}-service`;
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
const servers = typedTargets
.filter((t) => {
if (!t.siteOnline && anySiteOnline) return false;
if (t.siteType === "newt") return !!t.subnet;
return false; // browser gateway only supported on newt sites
})
.map((t) => ({
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
}))
.filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i);
config_output.http.routers![bgRouterName] = {
entryPoints: [
bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: bgServiceName,
rule: bgRule,
priority: 110, // highest - websocket path takes precedence
...(bgResource.ssl ? { tls } : {})
};
config_output.http.services![bgServiceName] = {
config_output.http.services![bgUiServiceName] = {
loadBalancer: {
servers
servers: [
{
url: `http://${internalHost}:${internalPort}`
}
]
}
};
}
// UI: serve the browser gateway page from the internal pangolin instance.
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
// how the maintenance page rewrites everything to /maintenance-screen.
const primaryType = typeMap.keys().next().value as string;
const internalHost = config.getRawConfig().server.internal_hostname;
const internalPort = config.getRawConfig().server.next_port;
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
const entrypoint = bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint;
// Assets router at higher priority so /_next files load without rewrite
config_output.http.routers![
`bg-r${bgResource.resourceId}-assets-router`
] = {
entryPoints: [entrypoint],
middlewares: routerMiddlewares,
service: bgUiServiceName,
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
...(bgResource.ssl ? { tls } : {})
};
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares![uiRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: `/${primaryType}`
}
};
config_output.http.services![bgUiServiceName] = {
loadBalancer: {
servers: [
{
url: `http://${internalHost}:${internalPort}`
}
]
}
};
// Assets router at higher priority so /_next files load without rewrite
config_output.http.routers![
`bg-r${bgResource.resourceId}-assets-router`
] = {
entryPoints: [entrypoint],
middlewares: routerMiddlewares,
service: bgUiServiceName,
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
...(bgResource.ssl ? { tls } : {})
};
// Catch-all router rewrites everything on the domain to /{primaryType}
config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] =
{
// Catch-all router rewrites everything on the domain to /{primaryType}
config_output.http.routers![
`bg-r${bgResource.resourceId}-ui-router`
] = {
entryPoints: [entrypoint],
middlewares: [...routerMiddlewares, uiRewriteMiddlewareName],
service: bgUiServiceName,
@@ -1301,6 +1317,7 @@ export async function getTraefikConfig(
priority: 100,
...(bgResource.ssl ? { tls } : {})
};
}
}
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
+2 -1
View File
@@ -270,7 +270,8 @@ hybridRouter.get(
true, // But don't allow domain namespace resources
false, // Dont include login pages,
true, // allow raw resources
false // dont generate maintenance page
false, // dont generate maintenance page
false // dont generate browser gateway targets
);
return response(res, {
+18 -8
View File
@@ -1,7 +1,7 @@
import { verify } from "@node-rs/argon2";
import { generateSessionToken } from "@server/auth/sessions/app";
import { db } from "@server/db";
import { orgs, resourcePassword, resources } from "@server/db";
import { orgs, resourcePassword, resourcePolicies, resourcePolicyPassword, resources } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq } from "drizzle-orm";
@@ -61,17 +61,29 @@ export async function authWithPassword(
const [result] = await db
.select()
.from(resources)
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
.leftJoin(
resourcePolicies,
eq(resourcePolicies.resourcePolicyId, resources.resourcePolicyId)
)
.leftJoin(
resourcePolicyPassword,
eq(resourcePolicyPassword.resourcePolicyId, resourcePolicies.resourcePolicyId)
)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
.where(eq(resources.resourceId, resourceId))
.limit(1);
const resource = result?.resources;
const org = result?.orgs;
const definedPassword = result?.resourcePassword;
// Policy password takes precedence over resource-level password
const policyPassword = result?.resourcePolicyPassword ?? null;
const definedPassword = policyPassword ?? result?.resourcePassword ?? null;
const isPolicyPassword = !!policyPassword;
if (!org) {
return next(
@@ -89,10 +101,7 @@ export async function authWithPassword(
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
createHttpError(
HttpCode.BAD_REQUEST,
"Resource has no password protection"
)
"Resource has no password protection"
)
);
}
@@ -126,7 +135,8 @@ export async function authWithPassword(
await createResourceSession({
resourceId,
token,
passwordId: definedPassword.passwordId,
passwordId: isPolicyPassword ? null : definedPassword.passwordId,
policyPasswordId: isPolicyPassword ? definedPassword.passwordId : null,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
+17 -4
View File
@@ -1,6 +1,6 @@
import { generateSessionToken } from "@server/auth/sessions/app";
import { db } from "@server/db";
import { orgs, resourcePincode, resources } from "@server/db";
import { orgs, resourcePincode, resourcePolicies, resourcePolicyPincode, resources } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq } from "drizzle-orm";
@@ -60,17 +60,29 @@ export async function authWithPincode(
const [result] = await db
.select()
.from(resources)
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
.leftJoin(
resourcePolicies,
eq(resourcePolicies.resourcePolicyId, resources.resourcePolicyId)
)
.leftJoin(
resourcePolicyPincode,
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicies.resourcePolicyId)
)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
.where(eq(resources.resourceId, resourceId))
.limit(1);
const resource = result?.resources;
const org = result?.orgs;
const definedPincode = result?.resourcePincode;
// Policy pincode takes precedence over resource-level pincode
const policyPincode = result?.resourcePolicyPincode ?? null;
const definedPincode = policyPincode ?? result?.resourcePincode ?? null;
const isPolicyPincode = !!policyPincode;
if (!org) {
return next(
@@ -125,7 +137,8 @@ export async function authWithPincode(
await createResourceSession({
resourceId,
token,
pincodeId: definedPincode.pincodeId,
pincodeId: isPolicyPincode ? null : definedPincode.pincodeId,
policyPincodeId: isPolicyPincode ? definedPincode.pincodeId : null,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
+106 -75
View File
@@ -1,6 +1,6 @@
import { generateSessionToken } from "@server/auth/sessions/app";
import { db } from "@server/db";
import { orgs, resourceOtp, resources, resourceWhitelist } from "@server/db";
import { orgs, resourceOtp, resources, resourceWhitelist, resourcePolicyWhiteList } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq, and } from "drizzle-orm";
@@ -59,82 +59,21 @@ export async function authWithWhitelist(
const { email, otp } = parsedBody.data;
try {
const [result] = await db
// Fetch resource and org first
const [resourceResult] = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
)
.leftJoin(
resources,
eq(resources.resourceId, resourceWhitelist.resourceId)
)
.from(resources)
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
.where(eq(resources.resourceId, resourceId))
.limit(1);
let resource = result?.resources;
let org = result?.orgs;
let whitelistedEmail = result?.resourceWhitelist;
const resource = resourceResult?.resources;
const org = resourceResult?.orgs;
if (!whitelistedEmail) {
// if email is not found, check for wildcard email
const wildcard = "*@" + email.split("@")[1];
logger.debug("Checking for wildcard email: " + wildcard);
const [result] = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, wildcard)
)
)
.leftJoin(
resources,
eq(resources.resourceId, resourceWhitelist.resourceId)
)
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
.limit(1);
resource = result?.resources;
org = result?.orgs;
whitelistedEmail = result?.resourceWhitelist;
// if wildcard is still not found, return unauthorized
if (!whitelistedEmail) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Email is not whitelisted. Email: ${email}. IP: ${req.ip}.`
);
}
if (org && resource) {
logAccessAudit({
orgId: org.orgId,
resourceId: resource.resourceId,
action: false,
type: "whitelistedEmail",
metadata: { email },
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
}
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
createHttpError(
HttpCode.BAD_REQUEST,
"Email is not whitelisted"
)
)
);
}
if (!resource) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
);
}
if (!org) {
@@ -143,9 +82,100 @@ export async function authWithWhitelist(
);
}
if (!resource) {
const wildcard = "*@" + email.split("@")[1];
// Check policy whitelist first (policy takes precedence over resource whitelist)
let policyWhitelistEntry: { whitelistId: number; email: string } | null = null;
if (resource.resourcePolicyId) {
const [exact] = await db
.select()
.from(resourcePolicyWhiteList)
.where(
and(
eq(resourcePolicyWhiteList.resourcePolicyId, resource.resourcePolicyId),
eq(resourcePolicyWhiteList.email, email)
)
)
.limit(1);
if (exact) {
policyWhitelistEntry = exact;
} else {
logger.debug("Checking for wildcard email in policy: " + wildcard);
const [wildcardMatch] = await db
.select()
.from(resourcePolicyWhiteList)
.where(
and(
eq(resourcePolicyWhiteList.resourcePolicyId, resource.resourcePolicyId),
eq(resourcePolicyWhiteList.email, wildcard)
)
)
.limit(1);
if (wildcardMatch) policyWhitelistEntry = wildcardMatch;
}
}
// Fall back to resource whitelist if not found in policy
let resourceWhitelistEntry: { whitelistId: number; email: string } | null = null;
if (!policyWhitelistEntry) {
const [exact] = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
)
.limit(1);
if (exact) {
resourceWhitelistEntry = exact;
} else {
logger.debug("Checking for wildcard email: " + wildcard);
const [wildcardMatch] = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, wildcard)
)
)
.limit(1);
if (wildcardMatch) resourceWhitelistEntry = wildcardMatch;
}
}
const isPolicyWhitelist = !!policyWhitelistEntry;
const whitelistedEmail = policyWhitelistEntry ?? resourceWhitelistEntry;
if (!whitelistedEmail) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Email is not whitelisted. Email: ${email}. IP: ${req.ip}.`
);
}
logAccessAudit({
orgId: org.orgId,
resourceId: resource.resourceId,
action: false,
type: "whitelistedEmail",
metadata: { email },
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
return next(
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
createHttpError(
HttpCode.UNAUTHORIZED,
createHttpError(
HttpCode.BAD_REQUEST,
"Email is not whitelisted"
)
)
);
}
@@ -211,7 +241,8 @@ export async function authWithWhitelist(
await createResourceSession({
resourceId,
token,
whitelistId: whitelistedEmail.whitelistId,
whitelistId: isPolicyWhitelist ? null : whitelistedEmail.whitelistId,
policyWhitelistId: isPolicyWhitelist ? whitelistedEmail.whitelistId : null,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
+144 -43
View File
@@ -5,11 +5,17 @@ import {
resources,
userResources,
roleResources,
userPolicies,
rolePolicies,
resourcePolicies,
userOrgRoles,
userOrgs,
resourcePassword,
resourcePincode,
resourceWhitelist,
resourcePolicyPassword,
resourcePolicyPincode,
resourcePolicyWhiteList,
siteResources,
userSiteResources,
roleSiteResources,
@@ -27,6 +33,10 @@ export async function getUserResources(
next: NextFunction
): Promise<any> {
try {
const effectiveResourcePolicyId = sql<
number | null
>`coalesce(${resources.resourcePolicyId}, ${resources.defaultResourcePolicyId})`;
const orgId = getFirstString(req.params.orgId);
const userId = req.user?.userId;
@@ -80,6 +90,30 @@ export async function getUserResources(
.where(inArray(roleResources.roleId, userRoleIds))
: Promise.resolve([]);
const directPolicyResourcesQuery = db
.select({ resourceId: resources.resourceId })
.from(resources)
.innerJoin(
userPolicies,
eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId)
)
.where(eq(userPolicies.userId, userId));
const rolePolicyResourcesQuery =
userRoleIds.length > 0
? db
.select({ resourceId: resources.resourceId })
.from(resources)
.innerJoin(
rolePolicies,
eq(
effectiveResourcePolicyId,
rolePolicies.resourcePolicyId
)
)
.where(inArray(rolePolicies.roleId, userRoleIds))
: Promise.resolve([]);
const directSiteResourcesQuery = db
.select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources)
@@ -98,11 +132,15 @@ export async function getUserResources(
const [
directResources,
roleResourceResults,
directPolicyResourceResults,
rolePolicyResourceResults,
directSiteResourceResults,
roleSiteResourceResults
] = await Promise.all([
directResourcesQuery,
roleResourcesQuery,
directPolicyResourcesQuery,
rolePolicyResourcesQuery,
directSiteResourcesQuery,
roleSiteResourcesQuery
]);
@@ -110,18 +148,27 @@ export async function getUserResources(
// Combine all accessible resource IDs
const accessibleResourceIds = [
...directResources.map((r) => r.resourceId),
...roleResourceResults.map((r) => r.resourceId)
...roleResourceResults.map((r) => r.resourceId),
...directPolicyResourceResults.map((r) => r.resourceId),
...rolePolicyResourceResults.map((r) => r.resourceId)
];
// remove duplicates
const uniqueResourceIds = Array.from(new Set(accessibleResourceIds));
// Combine all accessible site resource IDs
const accessibleSiteResourceIds = [
...directSiteResourceResults.map((r) => r.siteResourceId),
...roleSiteResourceResults.map((r) => r.siteResourceId)
];
const uniqueSiteResourceIds = Array.from(
new Set(accessibleSiteResourceIds)
);
// Get resource details for accessible resources
let resourcesData: Array<{
resourceId: number;
effectiveResourcePolicyId: number | null;
name: string;
fullDomain: string | null;
ssl: boolean;
@@ -129,23 +176,34 @@ export async function getUserResources(
sso: boolean;
mode: string;
emailWhitelistEnabled: boolean;
policyEmailWhitelistEnabled: boolean | null;
}> = [];
if (accessibleResourceIds.length > 0) {
if (uniqueResourceIds.length > 0) {
resourcesData = await db
.select({
resourceId: resources.resourceId,
effectiveResourcePolicyId,
name: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
enabled: resources.enabled,
sso: resources.sso,
mode: resources.mode,
emailWhitelistEnabled: resources.emailWhitelistEnabled
emailWhitelistEnabled: resources.emailWhitelistEnabled,
policyEmailWhitelistEnabled:
resourcePolicies.emailWhitelistEnabled
})
.from(resources)
.leftJoin(
resourcePolicies,
eq(
effectiveResourcePolicyId,
resourcePolicies.resourcePolicyId
)
)
.where(
and(
inArray(resources.resourceId, accessibleResourceIds),
inArray(resources.resourceId, uniqueResourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true)
)
@@ -174,7 +232,7 @@ export async function getUserResources(
siteAddresses: (string | null)[];
siteOnlines: boolean[];
}> = [];
if (accessibleSiteResourceIds.length > 0) {
if (uniqueSiteResourceIds.length > 0) {
const aggCol = <T>(column: any) => {
if (DB_TYPE === "sqlite") {
return sql<T>`json_group_array(${column})`;
@@ -214,7 +272,7 @@ export async function getUserResources(
and(
inArray(
siteResources.siteResourceId,
accessibleSiteResourceIds
uniqueSiteResourceIds
),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true)
@@ -273,44 +331,87 @@ export async function getUserResources(
// Check for password, pincode, and whitelist protection for each resource
const resourcesWithAuth = await Promise.all(
resourcesData.map(async (resource) => {
const [passwordCheck, pincodeCheck, whitelistCheck] =
await Promise.all([
db
.select()
.from(resourcePassword)
.where(
eq(
resourcePassword.resourceId,
resource.resourceId
)
)
.limit(1),
db
.select()
.from(resourcePincode)
.where(
eq(
resourcePincode.resourceId,
resource.resourceId
)
)
.limit(1),
db
.select()
.from(resourceWhitelist)
.where(
eq(
resourceWhitelist.resourceId,
resource.resourceId
)
)
.limit(1)
]);
const policyId = resource.effectiveResourcePolicyId;
const hasPassword = passwordCheck.length > 0;
const hasPincode = pincodeCheck.length > 0;
const [
passwordCheck,
pincodeCheck,
whitelistCheck,
policyPasswordCheck,
policyPincodeCheck,
policyWhitelistCheck
] = await Promise.all([
db
.select()
.from(resourcePassword)
.where(
eq(resourcePassword.resourceId, resource.resourceId)
)
.limit(1),
db
.select()
.from(resourcePincode)
.where(
eq(resourcePincode.resourceId, resource.resourceId)
)
.limit(1),
db
.select()
.from(resourceWhitelist)
.where(
eq(
resourceWhitelist.resourceId,
resource.resourceId
)
)
.limit(1),
policyId
? db
.select()
.from(resourcePolicyPassword)
.where(
eq(
resourcePolicyPassword.resourcePolicyId,
policyId
)
)
.limit(1)
: Promise.resolve([]),
policyId
? db
.select()
.from(resourcePolicyPincode)
.where(
eq(
resourcePolicyPincode.resourcePolicyId,
policyId
)
)
.limit(1)
: Promise.resolve([]),
policyId
? db
.select()
.from(resourcePolicyWhiteList)
.where(
eq(
resourcePolicyWhiteList.resourcePolicyId,
policyId
)
)
.limit(1)
: Promise.resolve([])
]);
const hasPassword =
passwordCheck.length > 0 || policyPasswordCheck.length > 0;
const hasPincode =
pincodeCheck.length > 0 || policyPincodeCheck.length > 0;
const hasWhitelist =
whitelistCheck.length > 0 || resource.emailWhitelistEnabled;
whitelistCheck.length > 0 ||
policyWhitelistCheck.length > 0 ||
resource.emailWhitelistEnabled ||
!!resource.policyEmailWhitelistEnabled;
return {
resourceId: resource.resourceId,
@@ -386,7 +487,7 @@ export type GetUserResourcesResponse = {
domain: string;
enabled: boolean;
protected: boolean;
ode: string;
mode: string;
}>;
siteResources: Array<{
siteResourceId: number;
@@ -22,7 +22,8 @@ export async function traefikConfigProvider(
config.getRawConfig().traefik.site_types,
build == "oss", // filter out the namespace domains in open source
build != "oss", // generate the login pages on the cloud and and enterprise,
config.getRawConfig().traefik.allow_raw_resources
config.getRawConfig().traefik.allow_raw_resources,
build != "oss" // generate browser gateway resources on cloud and enterprise
);
if (traefikConfig?.http?.middlewares) {
+354 -2
View File
@@ -1,14 +1,38 @@
import { db } from "@server/db/pg/driver";
import { APP_PATH } from "@server/lib/consts";
import { APP_PATH, __DIRNAME } from "@server/lib/consts";
import { sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
import path from "path";
import path, { join } from "path";
import z from "zod";
import { fromZodError } from "zod-validation-error";
const version = "1.19.0";
const dev = process.env.ENVIRONMENT !== "prod";
let namesFile;
if (!dev) {
namesFile = join(__DIRNAME, "names.json");
} else {
namesFile = join("server/db/names.json");
}
export const names = JSON.parse(fs.readFileSync(namesFile, "utf-8"));
export function generateName(): string {
const name = (
names.descriptors[
Math.floor(Math.random() * names.descriptors.length)
] +
"-" +
names.animals[Math.floor(Math.random() * names.animals.length)]
)
.toLowerCase()
.replace(/\s/g, "-");
// Clean out non-alphanumeric characters except dashes.
return name.replace(/[^a-z0-9-]/g, "");
}
export default async function migration() {
console.log(`Running setup script ${version}...`);
@@ -164,6 +188,15 @@ export default async function migration() {
await db.execute(
sql`ALTER TABLE "resources" ADD COLUMN "mode" text DEFAULT 'http' NOT NULL;`
);
await db.execute(sql`
UPDATE "resources"
SET "mode" = CASE
WHEN COALESCE("http", true) = true THEN 'http'
WHEN COALESCE("http", false) = false AND LOWER(COALESCE("protocol", '')) = 'tcp' THEN 'tcp'
WHEN COALESCE("http", false) = false AND LOWER(COALESCE("protocol", '')) = 'udp' THEN 'udp'
ELSE 'http'
END;
`);
await db.execute(
sql`ALTER TABLE "resources" ADD COLUMN "pamMode" varchar(32) DEFAULT 'passthrough';`
);
@@ -266,6 +299,325 @@ export default async function migration() {
throw e;
}
try {
const existingResourcesQuery = await db.execute(sql`
SELECT
"resourceId",
"orgId",
"niceId",
COALESCE("sso", true) AS "sso",
COALESCE("applyRules", false) AS "applyRules",
COALESCE("emailWhitelistEnabled", false) AS "emailWhitelistEnabled",
"skipToIdpId"
FROM "resources"
`);
const existingResources = existingResourcesQuery.rows as {
resourceId: number;
orgId: string;
niceId: string;
sso: boolean;
applyRules: boolean;
emailWhitelistEnabled: boolean;
skipToIdpId: number | null;
}[];
if (existingResources.length > 0) {
const usedPolicyNiceIds = new Set<string>();
await db.execute(sql`BEGIN`);
try {
for (const resource of existingResources) {
let policyNiceId = "";
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error(
`Could not generate a unique policy name for resource ${resource.resourceId}`
);
}
const candidate = generateName();
const existingPolicy = await db.execute(sql`
SELECT 1
FROM "resourcePolicies"
WHERE "orgId" = ${resource.orgId}
AND "niceId" = ${candidate}
LIMIT 1
`);
if (
!usedPolicyNiceIds.has(candidate) &&
existingPolicy.rows.length === 0
) {
usedPolicyNiceIds.add(candidate);
policyNiceId = candidate;
break;
}
loops++;
}
const policyName = `default policy for ${resource.niceId}`;
const insertedPolicy = await db.execute(sql`
INSERT INTO "resourcePolicies" (
"sso",
"applyRules",
"scope",
"emailWhitelistEnabled",
"niceId",
"idpId",
"name",
"orgId"
) VALUES (
${resource.sso},
${resource.applyRules},
'resource',
${resource.emailWhitelistEnabled},
${policyNiceId},
${resource.skipToIdpId},
${policyName},
${resource.orgId}
)
RETURNING "resourcePolicyId"
`);
const resourcePolicyId = (
insertedPolicy.rows[0] as { resourcePolicyId: number }
).resourcePolicyId;
await db.execute(sql`
UPDATE "resources"
SET
"defaultResourcePolicyId" = ${resourcePolicyId}
WHERE "resourceId" = ${resource.resourceId}
`);
const existingPincodes = await db.execute(sql`
SELECT "pincodeHash", "digitLength"
FROM "resourcePincode"
WHERE "resourceId" = ${resource.resourceId}
`);
for (const pincode of existingPincodes.rows as {
pincodeHash: string;
digitLength: number;
}[]) {
await db.execute(sql`
INSERT INTO "resourcePolicyPincode" (
"pincodeHash",
"digitLength",
"resourcePolicyId"
) VALUES (
${pincode.pincodeHash},
${pincode.digitLength},
${resourcePolicyId}
)
`);
}
const existingPasswords = await db.execute(sql`
SELECT "passwordHash"
FROM "resourcePassword"
WHERE "resourceId" = ${resource.resourceId}
`);
for (const password of existingPasswords.rows as {
passwordHash: string;
}[]) {
await db.execute(sql`
INSERT INTO "resourcePolicyPassword" (
"passwordHash",
"resourcePolicyId"
) VALUES (
${password.passwordHash},
${resourcePolicyId}
)
`);
}
const headerCompatibilityQuery = await db.execute(sql`
SELECT COALESCE("extendedCompatibilityIsActivated", true) AS "extendedCompatibility"
FROM "resourceHeaderAuthExtendedCompatibility"
WHERE "resourceId" = ${resource.resourceId}
LIMIT 1
`);
const extendedCompatibility =
headerCompatibilityQuery.rows.length > 0
? (
headerCompatibilityQuery.rows[0] as {
extendedCompatibility: boolean;
}
).extendedCompatibility
: true;
const existingHeaderAuth = await db.execute(sql`
SELECT "headerAuthHash"
FROM "resourceHeaderAuth"
WHERE "resourceId" = ${resource.resourceId}
`);
for (const headerAuth of existingHeaderAuth.rows as {
headerAuthHash: string;
}[]) {
await db.execute(sql`
INSERT INTO "resourcePolicyHeaderAuth" (
"headerAuthHash",
"extendedCompatibility",
"resourcePolicyId"
) VALUES (
${headerAuth.headerAuthHash},
${extendedCompatibility},
${resourcePolicyId}
)
`);
}
const existingRules = await db.execute(sql`
SELECT "enabled", "priority", "action", "match", "value"
FROM "resourceRules"
WHERE "resourceId" = ${resource.resourceId}
`);
for (const rule of existingRules.rows as {
enabled: boolean;
priority: number;
action: string;
match: string;
value: string;
}[]) {
await db.execute(sql`
INSERT INTO "resourcePolicyRules" (
"resourcePolicyId",
"enabled",
"priority",
"action",
"match",
"value"
) VALUES (
${resourcePolicyId},
${rule.enabled},
${rule.priority},
${rule.action},
${rule.match},
${rule.value}
)
`);
}
const existingWhitelist = await db.execute(sql`
SELECT "email"
FROM "resourceWhitelist"
WHERE "resourceId" = ${resource.resourceId}
`);
for (const whitelistRow of existingWhitelist.rows as {
email: string;
}[]) {
await db.execute(sql`
INSERT INTO "resourcePolicyWhitelist" (
"email",
"resourcePolicyId"
) VALUES (
${whitelistRow.email},
${resourcePolicyId}
)
`);
}
const existingRoleResources = await db.execute(sql`
SELECT "roleId"
FROM "roleResources"
WHERE "resourceId" = ${resource.resourceId}
`);
for (const roleRow of existingRoleResources.rows as {
roleId: number;
}[]) {
await db.execute(sql`
INSERT INTO "rolePolicies" ("roleId", "resourcePolicyId")
SELECT ${roleRow.roleId}, ${resourcePolicyId}
WHERE NOT EXISTS (
SELECT 1
FROM "rolePolicies"
WHERE "roleId" = ${roleRow.roleId}
AND "resourcePolicyId" = ${resourcePolicyId}
)
`);
}
const existingUserResources = await db.execute(sql`
SELECT "userId"
FROM "userResources"
WHERE "resourceId" = ${resource.resourceId}
`);
for (const userRow of existingUserResources.rows as {
userId: string;
}[]) {
await db.execute(sql`
INSERT INTO "userPolicies" ("userId", "resourcePolicyId")
SELECT ${userRow.userId}, ${resourcePolicyId}
WHERE NOT EXISTS (
SELECT 1
FROM "userPolicies"
WHERE "userId" = ${userRow.userId}
AND "resourcePolicyId" = ${resourcePolicyId}
)
`);
}
await db.execute(sql`
DELETE FROM "resourcePincode"
WHERE "resourceId" = ${resource.resourceId}
`);
await db.execute(sql`
DELETE FROM "resourcePassword"
WHERE "resourceId" = ${resource.resourceId}
`);
await db.execute(sql`
DELETE FROM "resourceHeaderAuth"
WHERE "resourceId" = ${resource.resourceId}
`);
await db.execute(sql`
DELETE FROM "resourceHeaderAuthExtendedCompatibility"
WHERE "resourceId" = ${resource.resourceId}
`);
await db.execute(sql`
DELETE FROM "resourceRules"
WHERE "resourceId" = ${resource.resourceId}
`);
await db.execute(sql`
DELETE FROM "resourceWhitelist"
WHERE "resourceId" = ${resource.resourceId}
`);
await db.execute(sql`
ALTER TABLE "resourceSessions" ADD COLUMN "policyPasswordId" integer;
`);
await db.execute(sql`
ALTER TABLE "resourceSessions" ADD COLUMN "policyPincodeId" integer;
`);
await db.execute(sql`
ALTER TABLE "resourceSessions" ADD COLUMN "policyWhitelistId" integer;
`);
await db.execute(sql`
ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyPasswordId_resourcePolicyPassword_passwordId_fk" FOREIGN KEY ("policyPasswordId") REFERENCES "public"."resourcePolicyPassword"("passwordId") ON DELETE cascade ON UPDATE no action;
`);
await db.execute(sql`
ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyPincodeId_resourcePolicyPincode_pincodeId_fk" FOREIGN KEY ("policyPincodeId") REFERENCES "public"."resourcePolicyPincode"("pincodeId") ON DELETE cascade ON UPDATE no action;
`);
await db.execute(sql`
ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyWhitelistId_resourcePolicyWhitelist_id_fk" FOREIGN KEY ("policyWhitelistId") REFERENCES "public"."resourcePolicyWhitelist"("id") ON DELETE cascade ON UPDATE no action;
`);
}
await db.execute(sql`COMMIT`);
console.log(
`Migrated inline resource policies for ${existingResources.length} resource(s)`
);
} catch (e) {
await db.execute(sql`ROLLBACK`);
throw e;
}
}
} catch (e) {
console.log("Unable to migrate inline resource policies");
console.log(e);
throw e;
}
try {
const traefikPath = path.join(
APP_PATH,
+380 -2
View File
@@ -1,13 +1,39 @@
import { APP_PATH } from "@server/lib/consts";
import { APP_PATH, __DIRNAME } from "@server/lib/consts";
import Database from "better-sqlite3";
import z from "zod";
import { fromZodError } from "zod-validation-error";
import fs from "fs";
import yaml from "js-yaml";
import path from "path";
import path, { join } from "path";
const version = "1.19.0";
const dev = process.env.ENVIRONMENT !== "prod";
let namesFile;
if (!dev) {
namesFile = join(__DIRNAME, "names.json");
} else {
namesFile = join("server/db/names.json");
}
export const names = JSON.parse(fs.readFileSync(namesFile, "utf-8"));
export function generateName(): string {
const name = (
names.descriptors[
Math.floor(Math.random() * names.descriptors.length)
] +
"-" +
names.animals[Math.floor(Math.random() * names.animals.length)]
)
.toLowerCase()
.replace(/\s/g, "-");
// Clean out non-alphanumeric characters except dashes.
return name.replace(/[^a-z0-9-]/g, "");
}
await migration();
export default async function migration() {
console.log(`Running setup script ${version}...`);
@@ -265,6 +291,17 @@ export default async function migration() {
ALTER TABLE 'resources' ADD 'mode' text DEFAULT 'http' NOT NULL;
`
).run();
db.prepare(
`
UPDATE 'resources'
SET "mode" = CASE
WHEN COALESCE("http", 1) = 1 THEN 'http'
WHEN COALESCE("http", 0) = 0 AND LOWER(COALESCE("protocol", '')) = 'tcp' THEN 'tcp'
WHEN COALESCE("http", 0) = 0 AND LOWER(COALESCE("protocol", '')) = 'udp' THEN 'udp'
ELSE 'http'
END;
`
).run();
db.prepare(
`
ALTER TABLE 'resources' ADD 'pamMode' text DEFAULT 'passthrough';
@@ -300,8 +337,349 @@ export default async function migration() {
ALTER TABLE 'sites' ADD 'autoUpdateOverrideOrg' integer DEFAULT false NOT NULL;
`
).run();
db.prepare(
`
ALTER TABLE 'resourceSessions' ADD 'policyPasswordId' integer REFERENCES resourcePolicyPassword(passwordId);
`
).run();
db.prepare(
`
ALTER TABLE 'resourceSessions' ADD 'policyPincodeId' integer REFERENCES resourcePolicyPincode(pincodeId);
`
).run();
db.prepare(
`
ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id);
`
).run();
})();
const existingResources = db
.prepare(
`SELECT
"resourceId",
"orgId",
"niceId",
COALESCE("sso", 1) AS "sso",
COALESCE("applyRules", 0) AS "applyRules",
COALESCE("emailWhitelistEnabled", 0) AS "emailWhitelistEnabled",
"skipToIdpId"
FROM 'resources'`
)
.all() as {
resourceId: number;
orgId: string;
niceId: string;
sso: number;
applyRules: number;
emailWhitelistEnabled: number;
skipToIdpId: number | null;
}[];
if (existingResources.length > 0) {
const insertResourcePolicy = db.prepare(
`INSERT INTO 'resourcePolicies' (
"sso",
"applyRules",
"scope",
"emailWhitelistEnabled",
"niceId",
"idpId",
"name",
"orgId"
) VALUES (?, ?, 'resource', ?, ?, ?, ?, ?)`
);
const updateResourcePolicyRefs = db.prepare(
`UPDATE 'resources'
SET "defaultResourcePolicyId" = ?
WHERE "resourceId" = ?`
);
const policyNiceIdExists = db.prepare(
`SELECT 1
FROM 'resourcePolicies'
WHERE "niceId" = ? AND "orgId" = ?
LIMIT 1`
);
const selectResourcePincodes = db.prepare(
`SELECT "pincodeHash", "digitLength"
FROM 'resourcePincode'
WHERE "resourceId" = ?`
);
const insertResourcePolicyPincode = db.prepare(
`INSERT INTO 'resourcePolicyPincode' (
"pincodeHash",
"digitLength",
"resourcePolicyId"
) VALUES (?, ?, ?)`
);
const selectResourcePasswords = db.prepare(
`SELECT "passwordHash"
FROM 'resourcePassword'
WHERE "resourceId" = ?`
);
const insertResourcePolicyPassword = db.prepare(
`INSERT INTO 'resourcePolicyPassword' (
"passwordHash",
"resourcePolicyId"
) VALUES (?, ?)`
);
const selectResourceHeaderAuth = db.prepare(
`SELECT "headerAuthHash"
FROM 'resourceHeaderAuth'
WHERE "resourceId" = ?`
);
const selectResourceHeaderCompatibility = db.prepare(
`SELECT COALESCE("extendedCompatibilityIsActivated", 1) AS "extendedCompatibility"
FROM 'resourceHeaderAuthExtendedCompatibility'
WHERE "resourceId" = ?
LIMIT 1`
);
const insertResourcePolicyHeaderAuth = db.prepare(
`INSERT INTO 'resourcePolicyHeaderAuth' (
"headerAuthHash",
"extendedCompatibility",
"resourcePolicyId"
) VALUES (?, ?, ?)`
);
const selectResourceRules = db.prepare(
`SELECT "enabled", "priority", "action", "match", "value"
FROM 'resourceRules'
WHERE "resourceId" = ?`
);
const insertResourcePolicyRule = db.prepare(
`INSERT INTO 'resourcePolicyRules' (
"resourcePolicyId",
"enabled",
"priority",
"action",
"match",
"value"
) VALUES (?, ?, ?, ?, ?, ?)`
);
const selectResourceWhitelist = db.prepare(
`SELECT "email"
FROM 'resourceWhitelist'
WHERE "resourceId" = ?`
);
const insertResourcePolicyWhitelist = db.prepare(
`INSERT INTO 'resourcePolicyWhitelist' (
"email",
"resourcePolicyId"
) VALUES (?, ?)`
);
const selectRoleResources = db.prepare(
`SELECT "roleId"
FROM 'roleResources'
WHERE "resourceId" = ?`
);
const rolePolicyExists = db.prepare(
`SELECT 1
FROM 'rolePolicies'
WHERE "roleId" = ? AND "resourcePolicyId" = ?
LIMIT 1`
);
const insertRolePolicy = db.prepare(
`INSERT INTO 'rolePolicies' (
"roleId",
"resourcePolicyId"
) VALUES (?, ?)`
);
const selectUserResources = db.prepare(
`SELECT "userId"
FROM 'userResources'
WHERE "resourceId" = ?`
);
const userPolicyExists = db.prepare(
`SELECT 1
FROM 'userPolicies'
WHERE "userId" = ? AND "resourcePolicyId" = ?
LIMIT 1`
);
const insertUserPolicy = db.prepare(
`INSERT INTO 'userPolicies' (
"userId",
"resourcePolicyId"
) VALUES (?, ?)`
);
const deleteResourcePincodes = db.prepare(
`DELETE FROM 'resourcePincode' WHERE "resourceId" = ?`
);
const deleteResourcePasswords = db.prepare(
`DELETE FROM 'resourcePassword' WHERE "resourceId" = ?`
);
const deleteResourceHeaderAuth = db.prepare(
`DELETE FROM 'resourceHeaderAuth' WHERE "resourceId" = ?`
);
const deleteResourceHeaderCompatibility = db.prepare(
`DELETE FROM 'resourceHeaderAuthExtendedCompatibility' WHERE "resourceId" = ?`
);
const deleteResourceRules = db.prepare(
`DELETE FROM 'resourceRules' WHERE "resourceId" = ?`
);
const deleteResourceWhitelist = db.prepare(
`DELETE FROM 'resourceWhitelist' WHERE "resourceId" = ?`
);
const usedPolicyNiceIds = new Set<string>();
const migrateInlinePolicies = db.transaction(() => {
for (const resource of existingResources) {
let policyNiceId = "";
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error(
`Could not generate a unique policy name for resource ${resource.resourceId}`
);
}
const candidate = generateName();
const exists = policyNiceIdExists.get(
candidate,
resource.orgId
) as { 1: number } | undefined;
if (!usedPolicyNiceIds.has(candidate) && !exists) {
usedPolicyNiceIds.add(candidate);
policyNiceId = candidate;
break;
}
loops++;
}
const policyName = `default policy for ${resource.niceId}`;
const inserted = insertResourcePolicy.run(
resource.sso,
resource.applyRules,
resource.emailWhitelistEnabled,
policyNiceId,
resource.skipToIdpId,
policyName,
resource.orgId
);
const policyId = inserted.lastInsertRowid as number;
updateResourcePolicyRefs.run(policyId, resource.resourceId);
const resourcePincodes = selectResourcePincodes.all(
resource.resourceId
) as { pincodeHash: string; digitLength: number }[];
for (const pincode of resourcePincodes) {
insertResourcePolicyPincode.run(
pincode.pincodeHash,
pincode.digitLength,
policyId
);
}
const resourcePasswords = selectResourcePasswords.all(
resource.resourceId
) as { passwordHash: string }[];
for (const password of resourcePasswords) {
insertResourcePolicyPassword.run(
password.passwordHash,
policyId
);
}
const compatibilityRow =
selectResourceHeaderCompatibility.get(
resource.resourceId
) as { extendedCompatibility: number } | undefined;
const extendedCompatibility =
compatibilityRow?.extendedCompatibility ?? 1;
const resourceHeaderAuthRows = selectResourceHeaderAuth.all(
resource.resourceId
) as { headerAuthHash: string }[];
for (const headerAuth of resourceHeaderAuthRows) {
insertResourcePolicyHeaderAuth.run(
headerAuth.headerAuthHash,
extendedCompatibility,
policyId
);
}
const resourceRules = selectResourceRules.all(
resource.resourceId
) as {
enabled: number;
priority: number;
action: string;
match: string;
value: string;
}[];
for (const rule of resourceRules) {
insertResourcePolicyRule.run(
policyId,
rule.enabled,
rule.priority,
rule.action,
rule.match,
rule.value
);
}
const resourceWhitelist = selectResourceWhitelist.all(
resource.resourceId
) as { email: string }[];
for (const whitelistRow of resourceWhitelist) {
insertResourcePolicyWhitelist.run(
whitelistRow.email,
policyId
);
}
const resourceRoles = selectRoleResources.all(
resource.resourceId
) as { roleId: number }[];
for (const role of resourceRoles) {
const exists = rolePolicyExists.get(
role.roleId,
policyId
) as { 1: number } | undefined;
if (!exists) {
insertRolePolicy.run(role.roleId, policyId);
}
}
const resourceUsers = selectUserResources.all(
resource.resourceId
) as { userId: string }[];
for (const user of resourceUsers) {
const exists = userPolicyExists.get(
user.userId,
policyId
) as { 1: number } | undefined;
if (!exists) {
insertUserPolicy.run(user.userId, policyId);
}
}
deleteResourcePincodes.run(resource.resourceId);
deleteResourcePasswords.run(resource.resourceId);
deleteResourceHeaderAuth.run(resource.resourceId);
deleteResourceHeaderCompatibility.run(resource.resourceId);
deleteResourceRules.run(resource.resourceId);
deleteResourceWhitelist.run(resource.resourceId);
}
});
migrateInlinePolicies();
console.log(
`Migrated inline resource policies for ${existingResources.length} resource(s)`
);
}
console.log("Migrated database");
} catch (e) {
console.log("Failed to migrate db:", e);
@@ -315,13 +315,13 @@ export default function ResourceAuthenticationPage() {
key={policies.sharedPolicy.resourcePolicyId}
>
<ActionBanner
variant="warning"
title={t("resourcePolicyReadOnly")}
variant="info"
title={t("resourcePolicyShared")}
titleIcon={
<ShieldAlertIcon className="w-5 h-5" />
}
description={t(
"resourcePolicyReadOnlyDescription"
"resourcePolicySharedDescription"
)}
actions={
<Button
@@ -332,14 +332,13 @@ export default function ResourceAuthenticationPage() {
<Link
href={`/${org.org.orgId}/settings/policies/resource/${policies.sharedPolicy.niceId}`}
>
{t("edit")}
{t("editSharedPolicy")}
<ArrowRightIcon className="size-4" />
</Link>
</Button>
}
/>
<EditPolicyForm
readonly
resourceId={resource.resourceId}
/>
</ResourcePolicyProvider>
@@ -101,12 +101,6 @@ export default function ReverseProxyTargetsPage(props: {
/>
)}
{resource.mode == "tcp" && (
<ProxyResourceProtocolForm
resource={resource}
updateResource={updateResource}
/>
)}
</SettingsContainer>
);
}
@@ -405,205 +399,4 @@ function ProxyResourceHttpForm({
</SettingsSectionBody>
</SettingsSection>
);
}
function ProxyResourceProtocolForm({
resource,
updateResource
}: Pick<ResourceContextType, "resource" | "updateResource">) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const proxySettingsSchema = z.object({
setHostHeader: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message: t("proxyErrorInvalidHeader")
}
),
headers: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable(),
proxyProtocol: z.boolean().optional(),
proxyProtocolVersion: z.int().min(1).max(2).optional()
});
const proxySettingsForm = useForm({
resolver: zodResolver(proxySettingsSchema),
defaultValues: {
setHostHeader: resource.setHostHeader || "",
headers: resource.headers,
proxyProtocol: resource.proxyProtocol || false,
proxyProtocolVersion: resource.proxyProtocolVersion || 1
}
});
const router = useRouter();
const [, formAction, isSubmitting] = useActionState(
saveProtocolSettings,
null
);
async function saveProtocolSettings() {
const isValid = proxySettingsForm.trigger();
if (!isValid) return;
try {
// For TCP/UDP resources, save proxy protocol settings
const proxyData = proxySettingsForm.getValues();
const payload = {
proxyProtocol: proxyData.proxyProtocol || false,
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
};
await api.post(`/resource/${resource.resourceId}`, payload);
updateResource({
...resource,
proxyProtocol: proxyData.proxyProtocol || false,
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
});
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("proxyProtocol")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("proxyProtocolDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...proxySettingsForm}>
<form
action={formAction}
className="space-y-4"
id="proxy-protocol-settings-form"
>
<FormField
control={proxySettingsForm.control}
name="proxyProtocol"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="proxy-protocol-toggle"
label={t("enableProxyProtocol")}
description={t(
"proxyProtocolInfo"
)}
defaultChecked={
field.value || false
}
onCheckedChange={(val) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
{proxySettingsForm.watch("proxyProtocol") && (
<>
<FormField
control={proxySettingsForm.control}
name="proxyProtocolVersion"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxyProtocolVersion")}
</FormLabel>
<FormControl>
<Select
value={String(
field.value || 1
)}
onValueChange={(
value
) =>
field.onChange(
parseInt(
value,
10
)
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select version" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
{t("version1")}
</SelectItem>
<SelectItem value="2">
{t("version2")}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t("versionDescription")}
</FormDescription>
</FormItem>
)}
/>
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>{t("warning")}:</strong>{" "}
{t("proxyProtocolWarning")}
</AlertDescription>
</Alert>
</>
)}
</form>
</Form>
</SettingsSectionForm>
<form action={formAction} className="flex justify-end">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveProxyProtocol")}
</Button>
</form>
</SettingsSectionBody>
</SettingsSection>
);
}
}
@@ -96,10 +96,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
title: t("authentication"),
href: `/{orgId}/settings/resources/public/{niceId}/authentication`
});
// navItems.push({
// title: t("rules"),
// href: `/{orgId}/settings/resources/public/{niceId}/rules`
// });
}
return (
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,291 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import type { ResourceContextType } from "@app/contexts/resourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { resourceQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { tlsNameSchema } from "@server/lib/schemas";
import { useQuery } from "@tanstack/react-query";
import {
ProxyResourceTargetsForm
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
import {
AlertTriangle,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import {
use,
useActionState,
} from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
export default function ReverseProxyTargetsPage(props: {
params: Promise<{ resourceId: number; orgId: string }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
resourceQueries.resourceTargets({
resourceId: resource.resourceId
})
);
if (isLoadingTargets) {
return null;
}
return (
<SettingsContainer>
<ProxyResourceTargetsForm
orgId={params.orgId}
isHttp={["http", "ssh", "rdp", "vnc"].includes(resource.mode)}
initialTargets={remoteTargets}
resource={resource}
updateResource={updateResource}
/>
{resource.mode == "tcp" && (
<ProxyResourceProtocolForm
resource={resource}
updateResource={updateResource}
/>
)}
</SettingsContainer>
);
}
function ProxyResourceProtocolForm({
resource,
updateResource
}: Pick<ResourceContextType, "resource" | "updateResource">) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const proxySettingsSchema = z.object({
setHostHeader: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message: t("proxyErrorInvalidHeader")
}
),
headers: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable(),
proxyProtocol: z.boolean().optional(),
proxyProtocolVersion: z.int().min(1).max(2).optional()
});
const proxySettingsForm = useForm({
resolver: zodResolver(proxySettingsSchema),
defaultValues: {
setHostHeader: resource.setHostHeader || "",
headers: resource.headers,
proxyProtocol: resource.proxyProtocol || false,
proxyProtocolVersion: resource.proxyProtocolVersion || 1
}
});
const router = useRouter();
const [, formAction, isSubmitting] = useActionState(
saveProtocolSettings,
null
);
async function saveProtocolSettings() {
const isValid = proxySettingsForm.trigger();
if (!isValid) return;
try {
// For TCP/UDP resources, save proxy protocol settings
const proxyData = proxySettingsForm.getValues();
const payload = {
proxyProtocol: proxyData.proxyProtocol || false,
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
};
await api.post(`/resource/${resource.resourceId}`, payload);
updateResource({
...resource,
proxyProtocol: proxyData.proxyProtocol || false,
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
});
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("proxyProtocol")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("proxyProtocolDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...proxySettingsForm}>
<form
action={formAction}
className="space-y-4"
id="proxy-protocol-settings-form"
>
<FormField
control={proxySettingsForm.control}
name="proxyProtocol"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="proxy-protocol-toggle"
label={t("enableProxyProtocol")}
description={t(
"proxyProtocolInfo"
)}
defaultChecked={
field.value || false
}
onCheckedChange={(val) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
{proxySettingsForm.watch("proxyProtocol") && (
<>
<FormField
control={proxySettingsForm.control}
name="proxyProtocolVersion"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxyProtocolVersion")}
</FormLabel>
<FormControl>
<Select
value={String(
field.value || 1
)}
onValueChange={(
value
) =>
field.onChange(
parseInt(
value,
10
)
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select version" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
{t("version1")}
</SelectItem>
<SelectItem value="2">
{t("version2")}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t("versionDescription")}
</FormDescription>
</FormItem>
)}
/>
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>{t("warning")}:</strong>{" "}
{t("proxyProtocolWarning")}
</AlertDescription>
</Alert>
</>
)}
</form>
</Form>
</SettingsSectionForm>
<form action={formAction} className="flex justify-end">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveProxyProtocol")}
</Button>
</form>
</SettingsSectionBody>
</SettingsSection>
);
}
@@ -0,0 +1,43 @@
"use client";
import {
SettingsContainer,
} from "@app/components/Settings";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { resourceQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import {
ProxyResourceTargetsForm
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
import {
use,
} from "react";
export default function ReverseProxyTargetsPage(props: {
params: Promise<{ resourceId: number; orgId: string }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
resourceQueries.resourceTargets({
resourceId: resource.resourceId
})
);
if (isLoadingTargets) {
return null;
}
return (
<SettingsContainer>
<ProxyResourceTargetsForm
orgId={params.orgId}
isHttp={["http", "ssh", "rdp", "vnc"].includes(resource.mode)}
initialTargets={remoteTargets}
resource={resource}
updateResource={updateResource}
/>
</SettingsContainer>
);
}
@@ -44,6 +44,11 @@ export function EditPolicyForm({
const router = useRouter();
// In overlay mode (resourceId provided), policy-level sections are locked.
// Rules and users/roles sections handle their own hybrid logic via resourceId.
const isOverlay = resourceId !== undefined;
const policyLevelReadonly = readonly || isOverlay;
const isMaxmindAvailable = !!(
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0
);
@@ -79,7 +84,7 @@ export function EditPolicyForm({
return (
<SettingsContainer>
{!hidePolicyNameForm && (
<EditPolicyNameSectionForm readonly={readonly} />
<EditPolicyNameSectionForm readonly={policyLevelReadonly} />
)}
<EditPolicyUsersRolesSectionForm
@@ -89,11 +94,11 @@ export function EditPolicyForm({
resourceId={resourceId}
/>
<EditPolicyAuthMethodsSectionForm readonly={readonly} />
<EditPolicyAuthMethodsSectionForm readonly={policyLevelReadonly} />
<EditPolicyOtpEmailSectionForm
emailEnabled={env.email.emailEnabled}
readonly={readonly}
readonly={policyLevelReadonly}
/>
<EditPolicyRulesSectionForm
@@ -171,7 +171,7 @@ export function EditPolicyRulesSectionForm({
});
const [rules, setRules] = useState<LocalRule[]>(
policy.rules.map((r) => ({ ...r, fromPolicy: !isResourceOverlay }))
policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay }))
);
const [isExpanded, setIsExpanded] = useState(
rulesEnabled || isResourceOverlay
@@ -196,8 +196,8 @@ export function EditPolicyRulesSectionForm({
}));
setRules([
...policy.rules.map((r) => ({ ...r, fromPolicy: true })),
...resourceSpecific
...resourceSpecific,
...policy.rules.map((r) => ({ ...r, fromPolicy: true }))
]);
setResourceRulesInitialized(true);
}, [
@@ -23,7 +23,10 @@ import type { AxiosResponse } from "axios";
import { useRouter } from "next/navigation";
import { createPolicySchema } from ".";
import { RolesSelector } from "@app/components/roles-selector";
import {
RolesSelector,
type SelectedRole
} from "@app/components/roles-selector";
import { UsersSelector } from "@app/components/users-selector";
import { SwitchInput } from "@app/components/SwitchInput";
import { Button } from "@app/components/ui/button";
@@ -59,6 +62,8 @@ type PolicyUsersRolesSectionProps = {
resourceId?: number;
};
type OverlaySelectedRole = SelectedRole & { isAdmin: boolean };
export function EditPolicyUsersRolesSectionForm({
orgId,
allIdps,
@@ -97,11 +102,12 @@ export function EditPolicyUsersRolesSectionForm({
);
// Policy entries mapped to selector format
const policyRoleItems = useMemo(
const policyRoleItems = useMemo<OverlaySelectedRole[]>(
() =>
policy.roles.map((r) => ({
id: r.roleId.toString(),
text: r.name
text: r.name,
isAdmin: false
})),
[policy.roles]
);
@@ -119,7 +125,8 @@ export function EditPolicyUsersRolesSectionForm({
const initialResourceUserIdsRef = useRef<Set<string>>(new Set());
// Combined selected roles/users (policy + resource-specific)
const [combinedRoles, setCombinedRoles] = useState(policyRoleItems);
const [combinedRoles, setCombinedRoles] =
useState<OverlaySelectedRole[]>(policyRoleItems);
const [combinedUsers, setCombinedUsers] = useState(policyUserItems);
const [resourceRolesInitialized, setResourceRolesInitialized] =
useState(false);
@@ -132,12 +139,20 @@ export function EditPolicyUsersRolesSectionForm({
const resourceSpecific = resourceRolesData
.filter((r) => !policyRoleLockedIds.has(r.roleId.toString()))
.map((r) => ({ id: r.roleId.toString(), text: r.name }));
.map((r) => ({
id: r.roleId.toString(),
text: r.name,
isAdmin: Boolean(r.isAdmin)
}));
initialResourceRoleIdsRef.current = new Set(
resourceSpecific.map((r) => r.id)
);
setCombinedRoles([...policyRoleItems, ...resourceSpecific]);
setCombinedRoles(
[...policyRoleItems, ...resourceSpecific].filter(
(role) => !role.isAdmin
)
);
setResourceRolesInitialized(true);
}, [
isResourceOverlay,
@@ -253,59 +268,29 @@ export function EditPolicyUsersRolesSectionForm({
setIsSavingOverlay(true);
try {
// Compute which roles/users are resource-specific (non-locked)
const currentResourceRoleIds = new Set(
combinedRoles
.filter((r) => !policyRoleLockedIds.has(r.id))
.map((r) => r.id)
);
const currentResourceUserIds = new Set(
combinedUsers
.filter((u) => !policyUserLockedIds.has(u.id))
.map((u) => u.id)
);
const initialRoleIds = initialResourceRoleIdsRef.current;
const initialUserIds = initialResourceUserIdsRef.current;
const addedRoleIds = [...currentResourceRoleIds].filter(
(id) => !initialRoleIds.has(id)
);
const removedRoleIds = [...initialRoleIds].filter(
(id) => !currentResourceRoleIds.has(id)
);
const addedUserIds = [...currentResourceUserIds].filter(
(id) => !initialUserIds.has(id)
);
const removedUserIds = [...initialUserIds].filter(
(id) => !currentResourceUserIds.has(id)
);
const currentResourceRoleIds = combinedRoles
.filter((r) => !policyRoleLockedIds.has(r.id))
.map((r) => Number(r.id));
const currentResourceUserIds = combinedUsers
.filter((u) => !policyUserLockedIds.has(u.id))
.map((u) => u.id);
// Use bulk-set endpoints (session-authenticated) which replace
// all resource-specific roles/users in one call
await Promise.all([
...addedRoleIds.map((id) =>
api.post(`/resource/${resourceId}/roles/add`, {
roleId: Number(id)
})
),
...removedRoleIds.map((id) =>
api.post(`/resource/${resourceId}/roles/remove`, {
roleId: Number(id)
})
),
...addedUserIds.map((id) =>
api.post(`/resource/${resourceId}/users/add`, {
userId: id
})
),
...removedUserIds.map((id) =>
api.post(`/resource/${resourceId}/users/remove`, {
userId: id
})
)
api.post(`/resource/${resourceId}/roles`, {
roleIds: currentResourceRoleIds
}),
api.post(`/resource/${resourceId}/users`, {
userIds: currentResourceUserIds
})
]);
// Update refs to reflect new state
initialResourceRoleIdsRef.current = currentResourceRoleIds;
initialResourceUserIdsRef.current = currentResourceUserIds;
initialResourceRoleIdsRef.current = new Set(
currentResourceRoleIds.map(String)
);
initialResourceUserIdsRef.current = new Set(currentResourceUserIds);
toast({
title: t("success"),
@@ -362,12 +347,27 @@ export function EditPolicyUsersRolesSectionForm({
{isResourceOverlay ? (
<RolesSelector
orgId={orgId}
selectedRoles={
combinedRoles
}
onSelectRoles={
setCombinedRoles
}
selectedRoles={combinedRoles.filter(
(role) => !role.isAdmin
)}
onSelectRoles={(roles) => {
setCombinedRoles(
roles
.map(
(role) => ({
...role,
isAdmin:
Boolean(
role.isAdmin
)
})
)
.filter(
(role) =>
!role.isAdmin
)
);
}}
disabled={isLoading}
restrictAdminRole
lockedIds={