diff --git a/server/lib/config.ts b/server/lib/config.ts index 405db2d1..d3931ec3 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -84,6 +84,10 @@ export class Config { ?.disable_basic_wireguard_sites ? "true" : "false"; + process.env.FLAGS_DISABLE_PRODUCT_HELP_BANNERS = parsedConfig.flags + ?.disable_product_help_banners + ? "true" + : "false"; process.env.PRODUCT_UPDATES_NOTIFICATION_ENABLED = parsedConfig.app .notifications.product_updates diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 280939f2..21ec78c1 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -4,6 +4,7 @@ import { and, eq, isNotNull } from "drizzle-orm"; import config from "@server/lib/config"; import z from "zod"; import logger from "@server/logger"; +import semver from "semver"; interface IPRange { start: bigint; @@ -683,3 +684,35 @@ export function parsePortRangeString( return result; } + +export function stripPortFromHost(ip: string, badgerVersion?: string): string { + const isNewerBadger = + badgerVersion && + semver.valid(badgerVersion) && + semver.gte(badgerVersion, "1.3.1"); + + if (isNewerBadger) { + return ip; + } + + if (ip.startsWith("[") && ip.includes("]")) { + // if brackets are found, extract the IPv6 address from between the brackets + const ipv6Match = ip.match(/\[(.*?)\]/); + if (ipv6Match) { + return ipv6Match[1]; + } + } + + // 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(ip)) { + const lastColonIndex = ip.lastIndexOf(":"); + if (lastColonIndex !== -1) { + return ip.substring(0, lastColonIndex); + } + } + + // Return as is + return ip; +} diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index da567820..90ebdc89 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -330,7 +330,8 @@ export const configSchema = z enable_integration_api: z.boolean().optional(), disable_local_sites: z.boolean().optional(), disable_basic_wireguard_sites: z.boolean().optional(), - disable_config_managed_domains: z.boolean().optional() + disable_config_managed_domains: z.boolean().optional(), + disable_product_help_banners: z.boolean().optional() }) .optional(), dns: z diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index e5bf3881..06754ffa 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -41,9 +41,10 @@ type TargetWithSite = Target & { export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], - filterOutNamespaceDomains = false, - generateLoginPageRouters = false, - allowRawResources = true + filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE + generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE + allowRawResources = true, + allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE ): Promise { // 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 diff --git a/server/private/lib/logAccessAudit.ts b/server/private/lib/logAccessAudit.ts index 5c423c60..33dcaf1f 100644 --- a/server/private/lib/logAccessAudit.ts +++ b/server/private/lib/logAccessAudit.ts @@ -17,6 +17,7 @@ import logger from "@server/logger"; import { and, eq, lt } from "drizzle-orm"; import cache from "@server/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; +import { stripPortFromHost } from "@server/lib/ip"; async function getAccessDays(orgId: string): Promise { // check cache first @@ -116,19 +117,7 @@ export async function logAccessAudit(data: { } const clientIp = data.requestIp - ? (() => { - if ( - data.requestIp.startsWith("[") && - data.requestIp.includes("]") - ) { - // if brackets are found, extract the IPv6 address from between the brackets - const ipv6Match = data.requestIp.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - return data.requestIp; - })() + ? stripPortFromHost(data.requestIp) : undefined; const countryCode = data.requestIp diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 62c60696..18410e62 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -358,18 +358,6 @@ export async function getTraefikConfig( } } - if (resource.ssl) { - config_output.http.routers![routerName + "-redirect"] = { - entryPoints: [ - config.getRawConfig().traefik.http_entrypoint - ], - middlewares: [redirectHttpsMiddlewareName], - service: serviceName, - rule: rule, - priority: priority - }; - } - let tls = {}; if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { const domainParts = fullDomain.split("."); @@ -435,6 +423,18 @@ export async function getTraefikConfig( } } + if (resource.ssl) { + config_output.http.routers![routerName + "-redirect"] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [redirectHttpsMiddlewareName], + service: serviceName, + rule: rule, + priority: priority + }; + } + const availableServers = targets.filter((target) => { if (!target.enabled) return false; @@ -464,7 +464,7 @@ export async function getTraefikConfig( } } - if (showMaintenancePage) { + if (showMaintenancePage && allowMaintenancePage) { const maintenanceServiceName = `${key}-maintenance-service`; const maintenanceRouterName = `${key}-maintenance-router`; const rewriteMiddlewareName = `${key}-maintenance-rewrite`; diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 009b2fe1..bbc0e0c8 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -247,7 +247,8 @@ hybridRouter.get( ["newt", "local", "wireguard"], // Allow them to use all the site types true, // But don't allow domain namespace resources false, // Dont include login pages, - true // allow raw resources + true, // allow raw resources + false // dont generate maintenance page ); return response(res, { diff --git a/server/routers/auth/pollDeviceWebAuth.ts b/server/routers/auth/pollDeviceWebAuth.ts index a5c71362..30d7183e 100644 --- a/server/routers/auth/pollDeviceWebAuth.ts +++ b/server/routers/auth/pollDeviceWebAuth.ts @@ -10,6 +10,7 @@ import { eq, and, gt } from "drizzle-orm"; import { createSession, generateSessionToken } from "@server/auth/sessions/app"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; +import { stripPortFromHost } from "@server/lib/ip"; const paramsSchema = z.object({ code: z.string().min(1, "Code is required") @@ -27,30 +28,6 @@ export type PollDeviceWebAuthResponse = { token?: string; }; -// Helper function to extract IP from request (same as in startDeviceWebAuth) -function extractIpFromRequest(req: Request): string | undefined { - const ip = req.ip || req.socket.remoteAddress; - if (!ip) { - return undefined; - } - - // Handle IPv6 format [::1] or IPv4 format - if (ip.startsWith("[") && ip.includes("]")) { - const ipv6Match = ip.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - - // Handle IPv4 with port (split at last colon) - const lastColonIndex = ip.lastIndexOf(":"); - if (lastColonIndex !== -1) { - return ip.substring(0, lastColonIndex); - } - - return ip; -} - export async function pollDeviceWebAuth( req: Request, res: Response, @@ -70,7 +47,7 @@ export async function pollDeviceWebAuth( try { const { code } = parsedParams.data; const now = Date.now(); - const requestIp = extractIpFromRequest(req); + const requestIp = req.ip ? stripPortFromHost(req.ip) : undefined; // Hash the code before querying const hashedCode = hashDeviceCode(code); diff --git a/server/routers/auth/startDeviceWebAuth.ts b/server/routers/auth/startDeviceWebAuth.ts index 85fb5262..e28e750c 100644 --- a/server/routers/auth/startDeviceWebAuth.ts +++ b/server/routers/auth/startDeviceWebAuth.ts @@ -12,6 +12,7 @@ import { TimeSpan } from "oslo"; import { maxmindLookup } from "@server/db/maxmind"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; +import { stripPortFromHost } from "@server/lib/ip"; const bodySchema = z .object({ @@ -39,30 +40,6 @@ function hashDeviceCode(code: string): string { return encodeHexLowerCase(sha256(new TextEncoder().encode(code))); } -// Helper function to extract IP from request -function extractIpFromRequest(req: Request): string | undefined { - const ip = req.ip; - if (!ip) { - return undefined; - } - - // Handle IPv6 format [::1] or IPv4 format - if (ip.startsWith("[") && ip.includes("]")) { - const ipv6Match = ip.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - - // Handle IPv4 with port (split at last colon) - const lastColonIndex = ip.lastIndexOf(":"); - if (lastColonIndex !== -1) { - return ip.substring(0, lastColonIndex); - } - - return ip; -} - // Helper function to get city from IP (if available) async function getCityFromIp(ip: string): Promise { try { @@ -112,7 +89,7 @@ export async function startDeviceWebAuth( const hashedCode = hashDeviceCode(code); // Extract IP from request - const ip = extractIpFromRequest(req); + const ip = req.ip ? stripPortFromHost(req.ip) : undefined; // Get city (optional, may return undefined) const city = ip ? await getCityFromIp(ip) : undefined; diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts index 4017cfea..bde5518b 100644 --- a/server/routers/badger/exchangeSession.ts +++ b/server/routers/badger/exchangeSession.ts @@ -19,6 +19,7 @@ import { import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource"; import config from "@server/lib/config"; import { response } from "@server/lib/response"; +import { stripPortFromHost } from "@server/lib/ip"; const exchangeSessionBodySchema = z.object({ requestToken: z.string(), @@ -62,26 +63,7 @@ export async function exchangeSession( cleanHost = cleanHost.slice(0, -1 * matched.length); } - 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 clientIp = requestIp ? stripPortFromHost(requestIp) : undefined; const [resource] = await db .select() diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 80e3f419..aade2f98 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -3,6 +3,7 @@ import logger from "@server/logger"; import { and, eq, lt } from "drizzle-orm"; import cache from "@server/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; +import { stripPortFromHost } from "@server/lib/ip"; /** @@ -208,26 +209,7 @@ export async function logRequestAudit( } const clientIp = body.requestIp - ? (() => { - if ( - body.requestIp.startsWith("[") && - body.requestIp.includes("]") - ) { - // if brackets are found, extract the IPv6 address from between the brackets - const ipv6Match = body.requestIp.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - - // ivp4 - // split at last colon - const lastColonIndex = body.requestIp.lastIndexOf(":"); - if (lastColonIndex !== -1) { - return body.requestIp.substring(0, lastColonIndex); - } - return body.requestIp; - })() + ? stripPortFromHost(body.requestIp) : undefined; // Add to buffer instead of writing directly to DB diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index ca7c913e..0da83c03 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -21,7 +21,7 @@ import { resourceSessions } from "@server/db"; import config from "@server/lib/config"; -import { isIpInCidr } from "@server/lib/ip"; +import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; import { response } from "@server/lib/response"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; @@ -110,37 +110,7 @@ export async function verifyResourceSession( const clientHeaderAuth = extractBasicAuth(headers); const clientIp = 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(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - - // 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; - })() + ? stripPortFromHost(requestIp, badgerVersion) : undefined; logger.debug("Client IP:", { clientIp }); diff --git a/server/setup/scriptsPg/1.14.0.ts b/server/setup/scriptsPg/1.14.0.ts index 7ccded5a..c396df0c 100644 --- a/server/setup/scriptsPg/1.14.0.ts +++ b/server/setup/scriptsPg/1.14.0.ts @@ -60,11 +60,11 @@ export default async function migration() { ); await db.execute( - sql`ALTER TABLE "siteResources" ADD COLUMN "tcpPortRangeString" varchar;` + sql`ALTER TABLE "siteResources" ADD COLUMN "tcpPortRangeString" varchar NOT NULL DEFAULT '*';` ); await db.execute( - sql`ALTER TABLE "siteResources" ADD COLUMN "udpPortRangeString" varchar;` + sql`ALTER TABLE "siteResources" ADD COLUMN "udpPortRangeString" varchar NOT NULL DEFAULT '*';` ); await db.execute( diff --git a/server/setup/scriptsSqlite/1.14.0.ts b/server/setup/scriptsSqlite/1.14.0.ts index a4883b8f..9559519a 100644 --- a/server/setup/scriptsSqlite/1.14.0.ts +++ b/server/setup/scriptsSqlite/1.14.0.ts @@ -73,16 +73,18 @@ export default async function migration() { ).run(); db.prepare( - `ALTER TABLE 'siteResources' ADD 'tcpPortRangeString' text;` + `ALTER TABLE 'siteResources' ADD 'tcpPortRangeString' text DEFAULT '*' NOT NULL;` ).run(); db.prepare( - `ALTER TABLE 'siteResources' ADD 'udpPortRangeString' text;` + `ALTER TABLE 'siteResources' ADD 'udpPortRangeString' text DEFAULT '*' NOT NULL;` ).run(); db.prepare( - `ALTER TABLE 'siteResources' ADD 'disableIcmp' integer;` + `ALTER TABLE 'siteResources' ADD 'disableIcmp' integer NOT NULL DEFAULT false;` ).run(); + + })(); db.pragma("foreign_keys = ON"); diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index 897c5d00..7cf9339b 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -189,7 +189,7 @@ function MaintenanceSectionForm({ name="maintenanceModeEnabled" render={({ field }) => { const isDisabled = - isSecurityFeatureDisabled(); + isSecurityFeatureDisabled() || resource.http === false; return ( diff --git a/src/app/globals.css b/src/app/globals.css index bd5860a6..70c614c0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -162,3 +162,20 @@ p { #nprogress .bar { background: var(--color-primary) !important; } + +@keyframes dot-pulse { + 0%, 80%, 100% { + opacity: 0.3; + transform: scale(0.8); + } + 40% { + opacity: 1; + transform: scale(1); + } +} + +@layer utilities { + .animate-dot-pulse { + animation: dot-pulse 1.4s ease-in-out infinite; + } +} diff --git a/src/components/DismissableBanner.tsx b/src/components/DismissableBanner.tsx index 9d8b246e..c4230c54 100644 --- a/src/components/DismissableBanner.tsx +++ b/src/components/DismissableBanner.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { useState, useEffect, type ReactNode } from "react"; +import React, { useState, useEffect, type ReactNode, useEffectEvent } from "react"; import { Card, CardContent } from "@app/components/ui/card"; import { X } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type DismissableBannerProps = { storageKey: string; @@ -25,6 +26,12 @@ export const DismissableBanner = ({ const [isDismissed, setIsDismissed] = useState(true); const t = useTranslations(); + const { env } = useEnvContext(); + + if (env.flags.disableProductHelpBanners) { + return null; + } + useEffect(() => { const dismissedData = localStorage.getItem(storageKey); if (dismissedData) { diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 3523dbd7..71dff6bf 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -75,7 +75,7 @@ export async function Layout({
{children} diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index 7c4da0bc..a7588f35 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -48,7 +48,7 @@ export function LayoutMobileMenu({ const t = useTranslations(); return ( -
+
{showSidebar && ( @@ -72,7 +72,7 @@ export function LayoutMobileMenu({ {t("navbarDescription")} -
+
{!isAdminPage && user.serverAdmin && ( -
+
+
diff --git a/src/components/private/OrgIdpDataTable.tsx b/src/components/private/OrgIdpDataTable.tsx index a7dc1850..9a45f49e 100644 --- a/src/components/private/OrgIdpDataTable.tsx +++ b/src/components/private/OrgIdpDataTable.tsx @@ -27,6 +27,8 @@ export function IdpDataTable({ searchColumn="name" addButtonText={t("idpAdd")} onAdd={onAdd} + enableColumnVisibility={true} + stickyRightColumn="actions" /> ); } diff --git a/src/components/private/OrgIdpTable.tsx b/src/components/private/OrgIdpTable.tsx index f5fdfe40..387fd3c7 100644 --- a/src/components/private/OrgIdpTable.tsx +++ b/src/components/private/OrgIdpTable.tsx @@ -118,6 +118,7 @@ export default function IdpTable({ idps, orgId }: Props) { }, { id: "actions", + enableHiding: false, header: () => {t("actions")}, cell: ({ row }) => { const siteRow = row.original; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index f530ace1..9f32e9ce 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -3,7 +3,6 @@ import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@app/lib/cn"; -import { Loader2 } from "lucide-react"; const buttonVariants = cva( "cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50", @@ -75,12 +74,34 @@ const Button = React.forwardRef( {asChild ? ( props.children ) : ( - <> + + + {props.children} + {loading && ( - + + + + + + + )} - {props.children} - + )} ); diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index dbe47bd5..ed0057de 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -59,7 +59,11 @@ export function pullEnv(): Env { hideSupporterKey: process.env.HIDE_SUPPORTER_KEY === "true" ? true : false, usePangolinDns: - process.env.USE_PANGOLIN_DNS === "true" ? true : false + process.env.USE_PANGOLIN_DNS === "true" ? true : false, + disableProductHelpBanners: + process.env.FLAGS_DISABLE_PRODUCT_HELP_BANNERS === "true" + ? true + : false }, branding: { diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index e40ac5d3..1f54f680 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -33,6 +33,7 @@ export type Env = { disableBasicWireguardSites: boolean; hideSupporterKey: boolean; usePangolinDns: boolean; + disableProductHelpBanners: boolean; }; branding: { appName?: string;