diff --git a/messages/en-US.json b/messages/en-US.json index 2684646f..565a5598 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2349,6 +2349,7 @@ "enterConfirmation": "Enter confirmation", "blueprintViewDetails": "Details", "defaultIdentityProvider": "Default Identity Provider", + "defaultIdentityProviderDescription": "When a default identity provider is selected, the user will be automatically redirected to the provider for authentication.", "editInternalResourceDialogNetworkSettings": "Network Settings", "editInternalResourceDialogAccessPolicy": "Access Policy", "editInternalResourceDialogAddRoles": "Add Roles", diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 6488dd32..a8f2f4b5 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -2,7 +2,8 @@ import { domains, orgDomains, Resource, - resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, + resourceHeaderAuth, + resourceHeaderAuthExtendedCompatibility, resourcePincode, resourceRules, resourceWhitelist, @@ -16,8 +17,8 @@ import { userResources, users } from "@server/db"; -import {resources, targets, sites} from "@server/db"; -import {eq, and, asc, or, ne, count, isNotNull} from "drizzle-orm"; +import { resources, targets, sites } from "@server/db"; +import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm"; import { Config, ConfigSchema, @@ -25,12 +26,13 @@ import { TargetData } from "./types"; import logger from "@server/logger"; -import {createCertificate} from "#dynamic/routers/certificates/createCertificate"; -import {pickPort} from "@server/routers/target/helpers"; -import {resourcePassword} from "@server/db"; -import {hashPassword} from "@server/auth/password"; -import {isValidCIDR, isValidIP, isValidUrlGlobPattern} from "../validators"; -import {get} from "http"; +import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { pickPort } from "@server/routers/target/helpers"; +import { resourcePassword } from "@server/db"; +import { hashPassword } from "@server/auth/password"; +import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; +import { isLicensedOrSubscribed } from "../isLicencedOrSubscribed"; +import { build } from "@server/build"; export type ProxyResourcesResults = { proxyResource: Resource; @@ -63,7 +65,7 @@ export async function updateProxyResources( if (targetSiteId) { // Look up site by niceId [site] = await trx - .select({siteId: sites.siteId}) + .select({ siteId: sites.siteId }) .from(sites) .where( and( @@ -75,7 +77,7 @@ export async function updateProxyResources( } else if (siteId) { // Use the provided siteId directly, but verify it belongs to the org [site] = await trx - .select({siteId: sites.siteId}) + .select({ siteId: sites.siteId }) .from(sites) .where( and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) @@ -93,7 +95,7 @@ export async function updateProxyResources( let internalPortToCreate; if (!targetData["internal-port"]) { - const {internalPort, targetIps} = await pickPort( + const { internalPort, targetIps } = await pickPort( site.siteId!, trx ); @@ -209,6 +211,16 @@ export async function updateProxyResources( resource = existingResource; } else { // Update existing resource + + const isLicensed = await isLicensedOrSubscribed(orgId); + if (build == "enterprise" && !isLicensed) { + logger.warn( + "Server is not licensed! Clearing set maintenance screen values" + ); + // null the maintenance mode fields if not licensed + resourceData.maintenance = undefined; + } + [resource] = await trx .update(resources) .set({ @@ -228,12 +240,19 @@ export async function updateProxyResources( tlsServerName: resourceData["tls-server-name"] || null, emailWhitelistEnabled: resourceData.auth?.[ "whitelist-users" - ] + ] ? resourceData.auth["whitelist-users"].length > 0 : false, headers: headers || null, applyRules: - resourceData.rules && resourceData.rules.length > 0 + resourceData.rules && resourceData.rules.length > 0, + maintenanceModeEnabled: + resourceData.maintenance?.enabled, + maintenanceModeType: resourceData.maintenance?.type, + maintenanceTitle: resourceData.maintenance?.title, + maintenanceMessage: resourceData.maintenance?.message, + maintenanceEstimatedTime: + resourceData.maintenance?.["estimated-time"] }) .where( eq(resources.resourceId, existingResource.resourceId) @@ -303,8 +322,13 @@ export async function updateProxyResources( const headerAuthPassword = resourceData.auth?.["basic-auth"]?.password; const headerAuthExtendedCompatibility = - resourceData.auth?.["basic-auth"]?.extendedCompatibility; - if (headerAuthUser && headerAuthPassword && headerAuthExtendedCompatibility !== null) { + resourceData.auth?.["basic-auth"] + ?.extendedCompatibility; + if ( + headerAuthUser && + headerAuthPassword && + headerAuthExtendedCompatibility !== null + ) { const headerAuthHash = await hashPassword( Buffer.from( `${headerAuthUser}:${headerAuthPassword}` @@ -315,10 +339,13 @@ export async function updateProxyResources( resourceId: existingResource.resourceId, headerAuthHash }), - trx.insert(resourceHeaderAuthExtendedCompatibility).values({ - resourceId: existingResource.resourceId, - extendedCompatibilityIsActivated: headerAuthExtendedCompatibility - }) + trx + .insert(resourceHeaderAuthExtendedCompatibility) + .values({ + resourceId: existingResource.resourceId, + extendedCompatibilityIsActivated: + headerAuthExtendedCompatibility + }) ]); } } @@ -380,7 +407,7 @@ export async function updateProxyResources( if (targetSiteId) { // Look up site by niceId [site] = await trx - .select({siteId: sites.siteId}) + .select({ siteId: sites.siteId }) .from(sites) .where( and( @@ -392,7 +419,7 @@ export async function updateProxyResources( } else if (siteId) { // Use the provided siteId directly, but verify it belongs to the org [site] = await trx - .select({siteId: sites.siteId}) + .select({ siteId: sites.siteId }) .from(sites) .where( and( @@ -437,7 +464,7 @@ export async function updateProxyResources( if (checkIfTargetChanged(existingTarget, updatedTarget)) { let internalPortToUpdate; if (!targetData["internal-port"]) { - const {internalPort, targetIps} = await pickPort( + const { internalPort, targetIps } = await pickPort( site.siteId!, trx ); @@ -622,6 +649,15 @@ export async function updateProxyResources( ); } + const isLicensed = await isLicensedOrSubscribed(orgId); + if (build == "enterprise" && !isLicensed) { + logger.warn( + "Server is not licensed! Clearing set maintenance screen values" + ); + // null the maintenance mode fields if not licensed + resourceData.maintenance = undefined; + } + // Create new resource const [newResource] = await trx .insert(resources) @@ -643,7 +679,13 @@ export async function updateProxyResources( ssl: resourceSsl, headers: headers || null, applyRules: - resourceData.rules && resourceData.rules.length > 0 + resourceData.rules && resourceData.rules.length > 0, + maintenanceModeEnabled: resourceData.maintenance?.enabled, + maintenanceModeType: resourceData.maintenance?.type, + maintenanceTitle: resourceData.maintenance?.title, + maintenanceMessage: resourceData.maintenance?.message, + maintenanceEstimatedTime: + resourceData.maintenance?.["estimated-time"] }) .returning(); @@ -674,9 +716,14 @@ export async function updateProxyResources( const headerAuthUser = resourceData.auth?.["basic-auth"]?.user; const headerAuthPassword = resourceData.auth?.["basic-auth"]?.password; - const headerAuthExtendedCompatibility = resourceData.auth?.["basic-auth"]?.extendedCompatibility; + const headerAuthExtendedCompatibility = + resourceData.auth?.["basic-auth"]?.extendedCompatibility; - if (headerAuthUser && headerAuthPassword && headerAuthExtendedCompatibility !== null) { + if ( + headerAuthUser && + headerAuthPassword && + headerAuthExtendedCompatibility !== null + ) { const headerAuthHash = await hashPassword( Buffer.from( `${headerAuthUser}:${headerAuthPassword}` @@ -688,10 +735,13 @@ export async function updateProxyResources( resourceId: newResource.resourceId, headerAuthHash }), - trx.insert(resourceHeaderAuthExtendedCompatibility).values({ - resourceId: newResource.resourceId, - extendedCompatibilityIsActivated: headerAuthExtendedCompatibility - }), + trx + .insert(resourceHeaderAuthExtendedCompatibility) + .values({ + resourceId: newResource.resourceId, + extendedCompatibilityIsActivated: + headerAuthExtendedCompatibility + }) ]); } } @@ -1043,7 +1093,7 @@ async function getDomain( trx: Transaction ) { const [fullDomainExists] = await trx - .select({resourceId: resources.resourceId}) + .select({ resourceId: resources.resourceId }) .from(resources) .where( and( diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 00bd33af..cfc71ac2 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { portRangeStringSchema } from "@server/lib/ip"; +import { MaintenanceSchema } from "#dynamic/lib/blueprints/types"; export const SiteSchema = z.object({ name: z.string().min(1).max(100), @@ -156,7 +157,8 @@ export const ResourceSchema = z "host-header": z.string().optional(), "tls-server-name": z.string().optional(), headers: z.array(HeaderSchema).optional(), - rules: z.array(RuleSchema).optional() + rules: z.array(RuleSchema).optional(), + maintenance: MaintenanceSchema.optional() }) .refine( (resource) => { diff --git a/server/private/lib/blueprints/types.ts b/server/private/lib/blueprints/types.ts new file mode 100644 index 00000000..31663de3 --- /dev/null +++ b/server/private/lib/blueprints/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const MaintenanceSchema = z.object({ + enabled: z.boolean().optional(), + type: z.enum(["forced", "automatic"]).optional(), + title: z.string().max(255).nullable().optional(), + message: z.string().max(2000).nullable().optional(), + "estimated-time": z.string().max(100).nullable().optional() +}); diff --git a/server/routers/auditLogs/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts index cd1218ce..fe3a4c4f 100644 --- a/server/routers/auditLogs/queryRequestAnalytics.ts +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -105,6 +105,7 @@ async function query(query: Q) { // throw an error throw createHttpError( HttpCode.BAD_REQUEST, + // todo: is this even possible? `Too many distinct countries. Please narrow your query.` ); } diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index 602b4475..0513feb2 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -219,15 +219,17 @@ async function queryUniqueFilterAttributes( .limit(DISTINCT_LIMIT+1) ]); - if ( - uniqueActors.length > DISTINCT_LIMIT || - uniqueLocations.length > DISTINCT_LIMIT || - uniqueHosts.length > DISTINCT_LIMIT || - uniquePaths.length > DISTINCT_LIMIT || - uniqueResources.length > DISTINCT_LIMIT - ) { - throw new Error("Too many distinct filter attributes to retrieve. Please refine your time range."); - } + // TODO: for stuff like the paths this is too restrictive so lets just show some of the paths and the user needs to + // refine the time range to see what they need to see + // if ( + // uniqueActors.length > DISTINCT_LIMIT || + // uniqueLocations.length > DISTINCT_LIMIT || + // uniqueHosts.length > DISTINCT_LIMIT || + // uniquePaths.length > DISTINCT_LIMIT || + // uniqueResources.length > DISTINCT_LIMIT + // ) { + // throw new Error("Too many distinct filter attributes to retrieve. Please refine your time range."); + // } return { actors: uniqueActors diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts index b8d01c11..4017cfea 100644 --- a/server/routers/badger/exchangeSession.ts +++ b/server/routers/badger/exchangeSession.ts @@ -62,7 +62,26 @@ export async function exchangeSession( cleanHost = cleanHost.slice(0, -1 * matched.length); } - const clientIp = requestIp?.split(":")[0]; + const clientIp = requestIp + ? (() => { + if (requestIp.startsWith("[") && requestIp.includes("]")) { + const ipv6Match = requestIp.match(/\[(.*?)\]/); + if (ipv6Match) { + return ipv6Match[1]; + } + } + + const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}/; + if (ipv4Pattern.test(requestIp)) { + const lastColonIndex = requestIp.lastIndexOf(":"); + if (lastColonIndex !== -1) { + return requestIp.substring(0, lastColonIndex); + } + } + + return requestIp; + })() + : undefined; const [resource] = await db .select() diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 45f5f041..ca7c913e 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -13,7 +13,8 @@ import { LoginPage, Org, Resource, - ResourceHeaderAuth, ResourceHeaderAuthExtendedCompatibility, + ResourceHeaderAuth, + ResourceHeaderAuthExtendedCompatibility, ResourcePassword, ResourcePincode, ResourceRule, @@ -39,6 +40,8 @@ import { } from "#dynamic/lib/checkOrgAccessPolicy"; import { logRequestAudit } from "./logRequestAudit"; import cache from "@server/lib/cache"; +import semver from "semver"; +import { APP_VERSION } from "@server/lib/consts"; const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string(), z.string()).optional(), @@ -50,7 +53,8 @@ const verifyResourceSessionSchema = z.object({ path: z.string(), method: z.string(), tls: z.boolean(), - requestIp: z.string().optional() + requestIp: z.string().optional(), + badgerVersion: z.string().optional() }); export type VerifyResourceSessionSchema = z.infer< @@ -69,6 +73,7 @@ export type VerifyUserResponse = { headerAuthChallenged?: boolean; redirectUrl?: string; userData?: BasicUserData; + pangolinVersion?: string; }; export async function verifyResourceSession( @@ -97,7 +102,8 @@ export async function verifyResourceSession( requestIp, path, headers, - query + query, + badgerVersion } = parsedBody.data; // Extract HTTP Basic Auth credentials if present @@ -105,7 +111,15 @@ export async function verifyResourceSession( const clientIp = requestIp ? (() => { - logger.debug("Request IP:", { requestIp }); + const isNewerBadger = + badgerVersion && + semver.valid(badgerVersion) && + semver.gte(badgerVersion, "1.3.1"); + + if (isNewerBadger) { + return requestIp; + } + if (requestIp.startsWith("[") && requestIp.includes("]")) { // if brackets are found, extract the IPv6 address from between the brackets const ipv6Match = requestIp.match(/\[(.*?)\]/); @@ -114,12 +128,17 @@ export async function verifyResourceSession( } } - // ivp4 - // split at last colon - const lastColonIndex = requestIp.lastIndexOf(":"); - if (lastColonIndex !== -1) { - return requestIp.substring(0, lastColonIndex); + // Check if it looks like IPv4 (contains dots and matches IPv4 pattern) + // IPv4 format: x.x.x.x where x is 0-255 + const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}/; + if (ipv4Pattern.test(requestIp)) { + const lastColonIndex = requestIp.lastIndexOf(":"); + if (lastColonIndex !== -1) { + return requestIp.substring(0, lastColonIndex); + } } + + // Return as is return requestIp; })() : undefined; @@ -130,9 +149,7 @@ export async function verifyResourceSession( ? await getCountryCodeFromIp(clientIp) : undefined; - const ipAsn = clientIp - ? await getAsnFromIp(clientIp) - : undefined; + const ipAsn = clientIp ? await getAsnFromIp(clientIp) : undefined; let cleanHost = host; // if the host ends with :port, strip it @@ -178,7 +195,13 @@ export async function verifyResourceSession( cache.set(resourceCacheKey, resourceData, 5); } - const { resource, pincode, password, headerAuth, headerAuthExtendedCompatibility } = resourceData; + const { + resource, + pincode, + password, + headerAuth, + headerAuthExtendedCompatibility + } = resourceData; if (!resource) { logger.debug(`Resource not found ${cleanHost}`); @@ -474,8 +497,7 @@ export async function verifyResourceSession( return notAllowed(res); } - } - else if (headerAuth) { + } else if (headerAuth) { // if there are no other auth methods we need to return unauthorized if nothing is provided if ( !sso && @@ -713,7 +735,11 @@ export async function verifyResourceSession( } // If headerAuthExtendedCompatibility is activated but no clientHeaderAuth provided, force client to challenge - if (headerAuthExtendedCompatibility && headerAuthExtendedCompatibility.extendedCompatibilityIsActivated && !clientHeaderAuth){ + if ( + headerAuthExtendedCompatibility && + headerAuthExtendedCompatibility.extendedCompatibilityIsActivated && + !clientHeaderAuth + ) { return headerAuthChallenged(res, redirectPath, resource.orgId); } @@ -825,7 +851,7 @@ async function notAllowed( } const data = { - data: { valid: false, redirectUrl }, + data: { valid: false, redirectUrl, pangolinVersion: APP_VERSION }, success: true, error: false, message: "Access denied", @@ -839,8 +865,8 @@ function allowed(res: Response, userData?: BasicUserData) { const data = { data: userData !== undefined && userData !== null - ? { valid: true, ...userData } - : { valid: true }, + ? { valid: true, ...userData, pangolinVersion: APP_VERSION } + : { valid: true, pangolinVersion: APP_VERSION }, success: true, error: false, message: "Access allowed", @@ -879,7 +905,12 @@ async function headerAuthChallenged( } const data = { - data: { headerAuthChallenged: true, valid: false, redirectUrl }, + data: { + headerAuthChallenged: true, + valid: false, + redirectUrl, + pangolinVersion: APP_VERSION + }, success: true, error: false, message: "Access denied", diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 42e47efe..36e61c9d 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -56,12 +56,12 @@ async function getLatestOlmVersion(): Promise { return null; } - const tags = await response.json(); + let tags = await response.json(); if (!Array.isArray(tags) || tags.length === 0) { logger.warn("No tags found for Olm repository"); return null; } - + tags = tags.filter((version) => !version.name.includes("rc")); const latestVersion = tags[0].name; olmVersionCache.set("latestOlmVersion", latestVersion); diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index e7a3bb37..4fe05c26 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -39,12 +39,12 @@ async function getLatestNewtVersion(): Promise { return null; } - const tags = await response.json(); + let tags = await response.json(); if (!Array.isArray(tags) || tags.length === 0) { logger.warn("No tags found for Newt repository"); return null; } - + tags = tags.filter((version) => !version.name.includes("rc")); const latestVersion = tags[0].name; cache.set("latestNewtVersion", latestVersion); diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index 4c520733..08065dfc 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -16,7 +16,6 @@ import { SwitchInput } from "@app/components/SwitchInput"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; -import { CheckboxWithLabel } from "@app/components/ui/checkbox"; import { Form, FormControl, @@ -184,9 +183,6 @@ export default function ResourceAuthenticationPage() { const [ssoEnabled, setSsoEnabled] = useState(resource.sso); - const [autoLoginEnabled, setAutoLoginEnabled] = useState( - resource.skipToIdpId !== null && resource.skipToIdpId !== undefined - ); const [selectedIdpId, setSelectedIdpId] = useState( resource.skipToIdpId || null ); @@ -243,17 +239,12 @@ export default function ResourceAuthenticationPage() { text: w.email })) ); - if (autoLoginEnabled && !selectedIdpId && orgIdps.length > 0) { - setSelectedIdpId(orgIdps[0].idpId); - } hasInitializedRef.current = true; }, [ pageLoading, resourceRoles, resourceUsers, whitelist, - autoLoginEnabled, - selectedIdpId, orgIdps ]); @@ -269,16 +260,6 @@ export default function ResourceAuthenticationPage() { const data = usersRolesForm.getValues(); try { - // Validate that an IDP is selected if auto login is enabled - if (autoLoginEnabled && !selectedIdpId) { - toast({ - variant: "destructive", - title: t("error"), - description: t("selectIdpRequired") - }); - return; - } - const jobs = [ api.post(`/resource/${resource.resourceId}/roles`, { roleIds: data.roles.map((i) => parseInt(i.id)) @@ -288,7 +269,7 @@ export default function ResourceAuthenticationPage() { }), api.post(`/resource/${resource.resourceId}`, { sso: ssoEnabled, - skipToIdpId: autoLoginEnabled ? selectedIdpId : null + skipToIdpId: selectedIdpId }) ]; @@ -296,7 +277,7 @@ export default function ResourceAuthenticationPage() { updateResource({ sso: ssoEnabled, - skipToIdpId: autoLoginEnabled ? selectedIdpId : null + skipToIdpId: selectedIdpId }); updateAuthInfo({ @@ -619,88 +600,55 @@ export default function ResourceAuthenticationPage() { )} {ssoEnabled && allIdps.length > 0 && ( - <> -
- { - setAutoLoginEnabled( - checked as boolean +
+ + - setSelectedIdpId( - parseInt(value) - ) - } - value={ - selectedIdpId - ? selectedIdpId.toString() - : undefined - } - > - - - - - {allIdps.map( - (idp) => ( - - { - idp.text - } - - ) - )} - - -
- )} - + /> + + + + {t("none")} + + {allIdps.map((idp) => ( + + {idp.text} + + ))} + + +

+ {t( + "defaultIdentityProviderDescription" + )} +

+
)} diff --git a/src/app/auth/org/page.tsx b/src/app/auth/org/page.tsx index add6439c..aee0ec40 100644 --- a/src/app/auth/org/page.tsx +++ b/src/app/auth/org/page.tsx @@ -87,8 +87,6 @@ export default async function OrgAuthPage(props: { redirect(env.app.dashboardUrl); } - console.log(user, forceLogin); - if (user && !forceLogin) { let redirectToken: string | undefined; try { diff --git a/src/components/OrgSelectionForm.tsx b/src/components/OrgSelectionForm.tsx index 51d84d36..c625008e 100644 --- a/src/components/OrgSelectionForm.tsx +++ b/src/components/OrgSelectionForm.tsx @@ -95,7 +95,7 @@ export function OrgSelectionForm() {

{t("orgAuthWhatsThis")}{" "} + list: (enabled: boolean, version?: string) => queryOptions({ queryKey: ["PRODUCT_UPDATES"] as const, queryFn: async ({ signal }) => { const sp = new URLSearchParams({ - build + build, + ...(version ? { version } : {}) }); const data = await remote.get>( `/product-updates?${sp.toString()}`,