Merge branch 'dev' into msg-delivery

This commit is contained in:
Owen
2025-12-23 16:57:17 -05:00
24 changed files with 143 additions and 170 deletions

View File

@@ -84,6 +84,10 @@ export class Config {
?.disable_basic_wireguard_sites ?.disable_basic_wireguard_sites
? "true" ? "true"
: "false"; : "false";
process.env.FLAGS_DISABLE_PRODUCT_HELP_BANNERS = parsedConfig.flags
?.disable_product_help_banners
? "true"
: "false";
process.env.PRODUCT_UPDATES_NOTIFICATION_ENABLED = parsedConfig.app process.env.PRODUCT_UPDATES_NOTIFICATION_ENABLED = parsedConfig.app
.notifications.product_updates .notifications.product_updates

View File

@@ -4,6 +4,7 @@ import { and, eq, isNotNull } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
import z from "zod"; import z from "zod";
import logger from "@server/logger"; import logger from "@server/logger";
import semver from "semver";
interface IPRange { interface IPRange {
start: bigint; start: bigint;
@@ -683,3 +684,35 @@ export function parsePortRangeString(
return result; 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;
}

View File

@@ -330,7 +330,8 @@ export const configSchema = z
enable_integration_api: z.boolean().optional(), enable_integration_api: z.boolean().optional(),
disable_local_sites: z.boolean().optional(), disable_local_sites: z.boolean().optional(),
disable_basic_wireguard_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(), .optional(),
dns: z dns: z

View File

@@ -41,9 +41,10 @@ type TargetWithSite = Target & {
export async function getTraefikConfig( export async function getTraefikConfig(
exitNodeId: number, exitNodeId: number,
siteTypes: string[], siteTypes: string[],
filterOutNamespaceDomains = false, filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
generateLoginPageRouters = false, generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
allowRawResources = true allowRawResources = true,
allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE
): Promise<any> { ): Promise<any> {
// Get resources with their targets and sites in a single optimized query // 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 // Start from sites on this exit node, then join to targets and resources

View File

@@ -17,6 +17,7 @@ import logger from "@server/logger";
import { and, eq, lt } from "drizzle-orm"; import { and, eq, lt } from "drizzle-orm";
import cache from "@server/lib/cache"; import cache from "@server/lib/cache";
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
import { stripPortFromHost } from "@server/lib/ip";
async function getAccessDays(orgId: string): Promise<number> { async function getAccessDays(orgId: string): Promise<number> {
// check cache first // check cache first
@@ -116,19 +117,7 @@ export async function logAccessAudit(data: {
} }
const clientIp = data.requestIp const clientIp = data.requestIp
? (() => { ? stripPortFromHost(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;
})()
: undefined; : undefined;
const countryCode = data.requestIp const countryCode = data.requestIp

View File

@@ -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 = {}; let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split("."); 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) => { const availableServers = targets.filter((target) => {
if (!target.enabled) return false; if (!target.enabled) return false;
@@ -464,7 +464,7 @@ export async function getTraefikConfig(
} }
} }
if (showMaintenancePage) { if (showMaintenancePage && allowMaintenancePage) {
const maintenanceServiceName = `${key}-maintenance-service`; const maintenanceServiceName = `${key}-maintenance-service`;
const maintenanceRouterName = `${key}-maintenance-router`; const maintenanceRouterName = `${key}-maintenance-router`;
const rewriteMiddlewareName = `${key}-maintenance-rewrite`; const rewriteMiddlewareName = `${key}-maintenance-rewrite`;

View File

@@ -247,7 +247,8 @@ hybridRouter.get(
["newt", "local", "wireguard"], // Allow them to use all the site types ["newt", "local", "wireguard"], // Allow them to use all the site types
true, // But don't allow domain namespace resources true, // But don't allow domain namespace resources
false, // Dont include login pages, false, // Dont include login pages,
true // allow raw resources true, // allow raw resources
false // dont generate maintenance page
); );
return response(res, { return response(res, {

View File

@@ -10,6 +10,7 @@ import { eq, and, gt } from "drizzle-orm";
import { createSession, generateSessionToken } from "@server/auth/sessions/app"; import { createSession, generateSessionToken } from "@server/auth/sessions/app";
import { encodeHexLowerCase } from "@oslojs/encoding"; import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { stripPortFromHost } from "@server/lib/ip";
const paramsSchema = z.object({ const paramsSchema = z.object({
code: z.string().min(1, "Code is required") code: z.string().min(1, "Code is required")
@@ -27,30 +28,6 @@ export type PollDeviceWebAuthResponse = {
token?: string; 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( export async function pollDeviceWebAuth(
req: Request, req: Request,
res: Response, res: Response,
@@ -70,7 +47,7 @@ export async function pollDeviceWebAuth(
try { try {
const { code } = parsedParams.data; const { code } = parsedParams.data;
const now = Date.now(); const now = Date.now();
const requestIp = extractIpFromRequest(req); const requestIp = req.ip ? stripPortFromHost(req.ip) : undefined;
// Hash the code before querying // Hash the code before querying
const hashedCode = hashDeviceCode(code); const hashedCode = hashDeviceCode(code);

View File

@@ -12,6 +12,7 @@ import { TimeSpan } from "oslo";
import { maxmindLookup } from "@server/db/maxmind"; import { maxmindLookup } from "@server/db/maxmind";
import { encodeHexLowerCase } from "@oslojs/encoding"; import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { stripPortFromHost } from "@server/lib/ip";
const bodySchema = z const bodySchema = z
.object({ .object({
@@ -39,30 +40,6 @@ function hashDeviceCode(code: string): string {
return encodeHexLowerCase(sha256(new TextEncoder().encode(code))); 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) // Helper function to get city from IP (if available)
async function getCityFromIp(ip: string): Promise<string | undefined> { async function getCityFromIp(ip: string): Promise<string | undefined> {
try { try {
@@ -112,7 +89,7 @@ export async function startDeviceWebAuth(
const hashedCode = hashDeviceCode(code); const hashedCode = hashDeviceCode(code);
// Extract IP from request // Extract IP from request
const ip = extractIpFromRequest(req); const ip = req.ip ? stripPortFromHost(req.ip) : undefined;
// Get city (optional, may return undefined) // Get city (optional, may return undefined)
const city = ip ? await getCityFromIp(ip) : undefined; const city = ip ? await getCityFromIp(ip) : undefined;

View File

@@ -19,6 +19,7 @@ import {
import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource"; import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { response } from "@server/lib/response"; import { response } from "@server/lib/response";
import { stripPortFromHost } from "@server/lib/ip";
const exchangeSessionBodySchema = z.object({ const exchangeSessionBodySchema = z.object({
requestToken: z.string(), requestToken: z.string(),
@@ -62,26 +63,7 @@ export async function exchangeSession(
cleanHost = cleanHost.slice(0, -1 * matched.length); cleanHost = cleanHost.slice(0, -1 * matched.length);
} }
const clientIp = requestIp const clientIp = requestIp ? stripPortFromHost(requestIp) : undefined;
? (() => {
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 const [resource] = await db
.select() .select()

View File

@@ -3,6 +3,7 @@ import logger from "@server/logger";
import { and, eq, lt } from "drizzle-orm"; import { and, eq, lt } from "drizzle-orm";
import cache from "@server/lib/cache"; import cache from "@server/lib/cache";
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
import { stripPortFromHost } from "@server/lib/ip";
/** /**
@@ -208,26 +209,7 @@ export async function logRequestAudit(
} }
const clientIp = body.requestIp const clientIp = body.requestIp
? (() => { ? stripPortFromHost(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;
})()
: undefined; : undefined;
// Add to buffer instead of writing directly to DB // Add to buffer instead of writing directly to DB

View File

@@ -21,7 +21,7 @@ import {
resourceSessions resourceSessions
} from "@server/db"; } from "@server/db";
import config from "@server/lib/config"; 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 { response } from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -110,37 +110,7 @@ export async function verifyResourceSession(
const clientHeaderAuth = extractBasicAuth(headers); const clientHeaderAuth = extractBasicAuth(headers);
const clientIp = requestIp const clientIp = requestIp
? (() => { ? stripPortFromHost(requestIp, badgerVersion)
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;
})()
: undefined; : undefined;
logger.debug("Client IP:", { clientIp }); logger.debug("Client IP:", { clientIp });

View File

@@ -60,11 +60,11 @@ export default async function migration() {
); );
await db.execute( 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( 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( await db.execute(

View File

@@ -73,16 +73,18 @@ export default async function migration() {
).run(); ).run();
db.prepare( db.prepare(
`ALTER TABLE 'siteResources' ADD 'tcpPortRangeString' text;` `ALTER TABLE 'siteResources' ADD 'tcpPortRangeString' text DEFAULT '*' NOT NULL;`
).run(); ).run();
db.prepare( db.prepare(
`ALTER TABLE 'siteResources' ADD 'udpPortRangeString' text;` `ALTER TABLE 'siteResources' ADD 'udpPortRangeString' text DEFAULT '*' NOT NULL;`
).run(); ).run();
db.prepare( db.prepare(
`ALTER TABLE 'siteResources' ADD 'disableIcmp' integer;` `ALTER TABLE 'siteResources' ADD 'disableIcmp' integer NOT NULL DEFAULT false;`
).run(); ).run();
})(); })();
db.pragma("foreign_keys = ON"); db.pragma("foreign_keys = ON");

View File

@@ -189,7 +189,7 @@ function MaintenanceSectionForm({
name="maintenanceModeEnabled" name="maintenanceModeEnabled"
render={({ field }) => { render={({ field }) => {
const isDisabled = const isDisabled =
isSecurityFeatureDisabled(); isSecurityFeatureDisabled() || resource.http === false;
return ( return (
<FormItem> <FormItem>

View File

@@ -162,3 +162,20 @@ p {
#nprogress .bar { #nprogress .bar {
background: var(--color-primary) !important; 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;
}
}

View File

@@ -1,9 +1,10 @@
"use client"; "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 { Card, CardContent } from "@app/components/ui/card";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEnvContext } from "@app/hooks/useEnvContext";
type DismissableBannerProps = { type DismissableBannerProps = {
storageKey: string; storageKey: string;
@@ -25,6 +26,12 @@ export const DismissableBanner = ({
const [isDismissed, setIsDismissed] = useState(true); const [isDismissed, setIsDismissed] = useState(true);
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext();
if (env.flags.disableProductHelpBanners) {
return null;
}
useEffect(() => { useEffect(() => {
const dismissedData = localStorage.getItem(storageKey); const dismissedData = localStorage.getItem(storageKey);
if (dismissedData) { if (dismissedData) {

View File

@@ -75,7 +75,7 @@ export async function Layout({
<div <div
className={cn( className={cn(
"container mx-auto max-w-12xl mb-12", "container mx-auto max-w-12xl mb-12",
showHeader && "md:pt-16" // Add top padding only on desktop to account for fixed header showHeader && "pt-16 md:pt-16" // Add top padding on mobile and desktop to account for fixed header
)} )}
> >
{children} {children}

View File

@@ -48,7 +48,7 @@ export function LayoutMobileMenu({
const t = useTranslations(); const t = useTranslations();
return ( return (
<div className="shrink-0 md:hidden"> <div className="shrink-0 md:hidden fixed top-0 left-0 right-0 z-50 bg-card border-b border-border">
<div className="h-16 flex items-center px-2"> <div className="h-16 flex items-center px-2">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{showSidebar && ( {showSidebar && (
@@ -72,7 +72,7 @@ export function LayoutMobileMenu({
<SheetDescription className="sr-only"> <SheetDescription className="sr-only">
{t("navbarDescription")} {t("navbarDescription")}
</SheetDescription> </SheetDescription>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto relative">
<div className="px-3"> <div className="px-3">
<OrgSelector <OrgSelector
orgId={orgId} orgId={orgId}
@@ -83,7 +83,7 @@ export function LayoutMobileMenu({
<div className="px-3"> <div className="px-3">
{!isAdminPage && {!isAdminPage &&
user.serverAdmin && ( user.serverAdmin && (
<div className="pb-3"> <div className="py-2">
<Link <Link
href="/admin" href="/admin"
className={cn( className={cn(
@@ -113,6 +113,7 @@ export function LayoutMobileMenu({
} }
/> />
</div> </div>
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
</div> </div>
<div className="px-3 pt-3 pb-3 space-y-4 border-t shrink-0"> <div className="px-3 pt-3 pb-3 space-y-4 border-t shrink-0">
<SupporterStatus /> <SupporterStatus />

View File

@@ -27,6 +27,8 @@ export function IdpDataTable<TData, TValue>({
searchColumn="name" searchColumn="name"
addButtonText={t("idpAdd")} addButtonText={t("idpAdd")}
onAdd={onAdd} onAdd={onAdd}
enableColumnVisibility={true}
stickyRightColumn="actions"
/> />
); );
} }

View File

@@ -118,6 +118,7 @@ export default function IdpTable({ idps, orgId }: Props) {
}, },
{ {
id: "actions", id: "actions",
enableHiding: false,
header: () => <span className="p-3">{t("actions")}</span>, header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const siteRow = row.original; const siteRow = row.original;

View File

@@ -3,7 +3,6 @@ import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { Loader2 } from "lucide-react";
const buttonVariants = cva( 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", "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<HTMLButtonElement, ButtonProps>(
{asChild ? ( {asChild ? (
props.children props.children
) : ( ) : (
<> <span className="relative inline-flex items-center justify-center">
<span
className={cn(
"inline-flex items-center justify-center",
loading && "opacity-0"
)}
>
{props.children}
</span>
{loading && ( {loading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <span className="absolute inset-0 flex items-center justify-center">
<span className="flex items-center gap-1">
<span
className="h-1 w-1 bg-current animate-dot-pulse"
style={{ animationDelay: "0ms" }}
/>
<span
className="h-1 w-1 bg-current animate-dot-pulse"
style={{ animationDelay: "200ms" }}
/>
<span
className="h-1 w-1 bg-current animate-dot-pulse"
style={{ animationDelay: "400ms" }}
/>
</span>
</span>
)} )}
{props.children} </span>
</>
)} )}
</Comp> </Comp>
); );

View File

@@ -59,7 +59,11 @@ export function pullEnv(): Env {
hideSupporterKey: hideSupporterKey:
process.env.HIDE_SUPPORTER_KEY === "true" ? true : false, process.env.HIDE_SUPPORTER_KEY === "true" ? true : false,
usePangolinDns: 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: { branding: {

View File

@@ -33,6 +33,7 @@ export type Env = {
disableBasicWireguardSites: boolean; disableBasicWireguardSites: boolean;
hideSupporterKey: boolean; hideSupporterKey: boolean;
usePangolinDns: boolean; usePangolinDns: boolean;
disableProductHelpBanners: boolean;
}; };
branding: { branding: {
appName?: string; appName?: string;