diff --git a/app/(auth)/link-account/LinkAccountClient.tsx b/app/(auth)/link-account/LinkAccountClient.tsx index 13555175..9406913a 100644 --- a/app/(auth)/link-account/LinkAccountClient.tsx +++ b/app/(auth)/link-account/LinkAccountClient.tsx @@ -59,7 +59,7 @@ export default function LinkAccountClient({ await signIn(provider, { callbackUrl: "/" }); - } catch (err) { + } catch { setError("An error occurred while linking your account"); setLoading(false); } diff --git a/app/(auth)/login/LoginClient.tsx b/app/(auth)/login/LoginClient.tsx index afe8445f..be4fbbed 100644 --- a/app/(auth)/login/LoginClient.tsx +++ b/app/(auth)/login/LoginClient.tsx @@ -66,7 +66,7 @@ export default function LoginClient({ enabledProviders = [] }: LoginClientProps) await signIn(providerId, { callbackUrl: "/" }); - } catch (error) { + } catch { setLoginError(`Failed to sign in with OAuth`); setOauthPending(null); } diff --git a/app/(dashboard)/certificates/components/AcmeTab.tsx b/app/(dashboard)/certificates/components/AcmeTab.tsx index d41a01a9..915a3c7f 100644 --- a/app/(dashboard)/certificates/components/AcmeTab.tsx +++ b/app/(dashboard)/certificates/components/AcmeTab.tsx @@ -2,7 +2,7 @@ import { Card, Chip, Stack, Typography } from "@mui/material"; import { DataTable } from "@/src/components/ui/DataTable"; -import type { AcmeHost, CertExpiryStatus } from "../page"; +import type { AcmeHost } from "../page"; import { RelativeTime } from "./RelativeTime"; type Props = { diff --git a/app/(dashboard)/certificates/components/CaTab.tsx b/app/(dashboard)/certificates/components/CaTab.tsx index c42cc686..e3a0c064 100644 --- a/app/(dashboard)/certificates/components/CaTab.tsx +++ b/app/(dashboard)/certificates/components/CaTab.tsx @@ -4,7 +4,6 @@ import { Box, Button, Card, - CardContent, Chip, Collapse, IconButton, diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 89a19c0d..e1578ca4 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -7,7 +7,7 @@ import { certificates, proxyHosts } from "@/src/lib/db/schema"; -import { count, desc, isNull, isNotNull, eq, sql } from "drizzle-orm"; +import { count, desc, isNull, sql } from "drizzle-orm"; import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; import SecurityIcon from "@mui/icons-material/Security"; import VpnKeyIcon from "@mui/icons-material/VpnKey"; diff --git a/app/(dashboard)/profile/ProfileClient.tsx b/app/(dashboard)/profile/ProfileClient.tsx index c211cb18..88018aa3 100644 --- a/app/(dashboard)/profile/ProfileClient.tsx +++ b/app/(dashboard)/profile/ProfileClient.tsx @@ -20,6 +20,7 @@ import { TextField, Typography } from "@mui/material"; +import type { ChipProps } from "@mui/material"; import { signIn } from "next-auth/react"; import PersonIcon from "@mui/icons-material/Person"; import LockIcon from "@mui/icons-material/Lock"; @@ -58,7 +59,6 @@ export default function ProfileClient({ user, enabledProviders }: ProfileClientP const hasPassword = !!user.password_hash; const hasOAuth = user.provider !== "credentials"; - const isCredentialsOnly = user.provider === "credentials"; const handlePasswordChange = async () => { setError(null); @@ -100,7 +100,7 @@ export default function ProfileClient({ user, enabledProviders }: ProfileClientP setNewPassword(""); setConfirmPassword(""); setLoading(false); - } catch (err) { + } catch { setError("An error occurred while changing password"); setLoading(false); } @@ -136,7 +136,7 @@ export default function ProfileClient({ user, enabledProviders }: ProfileClientP // Reload page to reflect changes setTimeout(() => window.location.reload(), 1500); - } catch (err) { + } catch { setError("An error occurred while unlinking OAuth"); setLoading(false); } @@ -166,7 +166,7 @@ export default function ProfileClient({ user, enabledProviders }: ProfileClientP await signIn(providerId, { callbackUrl: "/profile" }); - } catch (err) { + } catch { setError("An error occurred while linking OAuth"); setLoading(false); } @@ -219,7 +219,7 @@ export default function ProfileClient({ user, enabledProviders }: ProfileClientP }; reader.readAsDataURL(file); - } catch (err) { + } catch { setError("An error occurred while uploading avatar"); setLoading(false); } @@ -249,7 +249,7 @@ export default function ProfileClient({ user, enabledProviders }: ProfileClientP setLoading(false); setTimeout(() => window.location.reload(), 1000); - } catch (err) { + } catch { setError("An error occurred while deleting avatar"); setLoading(false); } @@ -262,7 +262,7 @@ export default function ProfileClient({ user, enabledProviders }: ProfileClientP return provider; }; - const getProviderColor = (provider: string) => { + const getProviderColor = (provider: string): ChipProps["color"] => { if (provider === "credentials") return "default"; return "primary"; }; @@ -371,7 +371,7 @@ export default function ProfileClient({ user, enabledProviders }: ProfileClientP diff --git a/app/(dashboard)/proxy-hosts/actions.ts b/app/(dashboard)/proxy-hosts/actions.ts index 1dbbb19e..49893977 100644 --- a/app/(dashboard)/proxy-hosts/actions.ts +++ b/app/(dashboard)/proxy-hosts/actions.ts @@ -427,6 +427,7 @@ export async function createProxyHostAction( _prevState: ActionState = INITIAL_ACTION_STATE, formData: FormData ): Promise { + void _prevState; try { const session = await requireAdmin(); const userId = Number(session.user.id); @@ -486,6 +487,7 @@ export async function updateProxyHostAction( _prevState: ActionState = INITIAL_ACTION_STATE, formData: FormData ): Promise { + void _prevState; try { const session = await requireAdmin(); const userId = Number(session.user.id); @@ -558,6 +560,7 @@ export async function deleteProxyHostAction( id: number, _prevState: ActionState = INITIAL_ACTION_STATE ): Promise { + void _prevState; try { const session = await requireAdmin(); const userId = Number(session.user.id); diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts index 25d68a64..c7de71d5 100644 --- a/app/(dashboard)/settings/actions.ts +++ b/app/(dashboard)/settings/actions.ts @@ -600,6 +600,8 @@ export async function updateGeoBlockSettingsAction(_prevState: ActionResult | nu } export async function syncSlaveInstancesAction(_prevState: ActionResult | null, _formData: FormData): Promise { + void _prevState; + void _formData; try { await requireAdmin(); const mode = await getInstanceMode(); @@ -661,7 +663,7 @@ export async function suppressWafRuleGloballyAction(ruleId: number): Promise %s\n' "$label" + "$@" + status=$? + if [ "$status" -eq 0 ]; then + printf ' %s: PASS\n' "$label" + else + printf ' %s: FAIL (%s)\n' "$label" "$status" + fi + return "$status" +} + +lint_status=0 +typecheck_status=0 +vitest_status=0 +e2e_status=0 + +run_step "Lint" npm run lint +lint_status=$? + +run_step "Typecheck" npm run typecheck +typecheck_status=$? + +run_step "Vitest" npm run test +vitest_status=$? + +run_step "Playwright" npm run test:e2e +e2e_status=$? + +printf '\n==> Summary\n' +printf ' Lint: %s\n' "$( [ "$lint_status" -eq 0 ] && printf PASS || printf FAIL )" +printf ' Typecheck: %s\n' "$( [ "$typecheck_status" -eq 0 ] && printf PASS || printf FAIL )" +printf ' Vitest: %s\n' "$( [ "$vitest_status" -eq 0 ] && printf PASS || printf FAIL )" +printf ' Playwright: %s\n' "$( [ "$e2e_status" -eq 0 ] && printf PASS || printf FAIL )" + +if [ "$lint_status" -ne 0 ] || [ "$typecheck_status" -ne 0 ] || [ "$vitest_status" -ne 0 ] || [ "$e2e_status" -ne 0 ]; then + exit 1 +fi diff --git a/site/scripts.js b/site/scripts.js index 2a481923..a3796474 100644 --- a/site/scripts.js +++ b/site/scripts.js @@ -1,3 +1,5 @@ +/* global document */ + const yearEl = document.getElementById('year'); if (yearEl) { yearEl.textContent = new Date().getFullYear(); diff --git a/src/components/proxy-hosts/HostDialogs.tsx b/src/components/proxy-hosts/HostDialogs.tsx index d7453739..f6a58bca 100644 --- a/src/components/proxy-hosts/HostDialogs.tsx +++ b/src/components/proxy-hosts/HostDialogs.tsx @@ -85,7 +85,7 @@ export function CreateHostDialog({ label="Domains" placeholder="app.example.com" defaultValue={initialData?.domains.join("\n") ?? ""} - helperText="One per line or comma-separated" + helperText="One per line or comma-separated. Wildcards like *.example.com are supported." multiline minRows={2} required @@ -190,7 +190,7 @@ export function EditHostDialog({ name="domains" label="Domains" defaultValue={host.domains.join("\n")} - helperText="One per line or comma-separated" + helperText="One per line or comma-separated. Wildcards like *.example.com are supported." multiline minRows={2} fullWidth diff --git a/src/components/proxy-hosts/SettingsToggles.tsx b/src/components/proxy-hosts/SettingsToggles.tsx index 561a5997..eb687dcd 100644 --- a/src/components/proxy-hosts/SettingsToggles.tsx +++ b/src/components/proxy-hosts/SettingsToggles.tsx @@ -1,13 +1,14 @@ import { Box, Stack, Switch, Typography } from "@mui/material"; +import type { SwitchProps } from "@mui/material"; import { useState } from "react"; type ToggleSetting = { - name: string; + name: "hsts_subdomains" | "skip_https_hostname_validation"; label: string; description: string; defaultChecked: boolean; - color?: "success" | "warning" | "default"; + color?: SwitchProps["color"]; }; type SettingsTogglesProps = { @@ -123,10 +124,10 @@ export function SettingsToggles({ diff --git a/src/components/proxy-hosts/UpstreamInput.tsx b/src/components/proxy-hosts/UpstreamInput.tsx index 1ebb12bc..49428316 100644 --- a/src/components/proxy-hosts/UpstreamInput.tsx +++ b/src/components/proxy-hosts/UpstreamInput.tsx @@ -1,5 +1,4 @@ - -import { Box, Button, IconButton, Stack, TextField, Tooltip, Typography, Autocomplete, InputAdornment } from "@mui/material"; +import { Box, Button, IconButton, Stack, TextField, Tooltip, Typography, Autocomplete } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import RemoveCircleIcon from "@mui/icons-material/RemoveCircle"; import { useState } from "react"; diff --git a/src/components/ui/PageHeader.tsx b/src/components/ui/PageHeader.tsx index f072d2e3..c559a2d3 100644 --- a/src/components/ui/PageHeader.tsx +++ b/src/components/ui/PageHeader.tsx @@ -1,5 +1,4 @@ - -import { Box, Button, Stack, Typography } from "@mui/material"; +import { Button, Stack, Typography } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import { ReactNode } from "react"; diff --git a/src/components/ui/StatusChip.tsx b/src/components/ui/StatusChip.tsx index 5b6854b3..daa54e06 100644 --- a/src/components/ui/StatusChip.tsx +++ b/src/components/ui/StatusChip.tsx @@ -1,12 +1,12 @@ - -import { Box, Typography, ChipProps } from "@mui/material"; +import { Box, Typography } from "@mui/material"; +import type { SxProps, Theme } from "@mui/material"; type StatusType = "active" | "inactive" | "error" | "warning"; type StatusChipProps = { status: StatusType; label?: string; - sx?: any; + sx?: SxProps; }; const STATUS_CONFIG: Record = { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 772307ef..e7dc9a51 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,11 +3,9 @@ import { type NextRequest, NextResponse } from "next/server"; import Credentials from "next-auth/providers/credentials"; import type { OAuthConfig } from "next-auth/providers"; import bcrypt from "bcryptjs"; -import { cookies } from "next/headers"; import db from "./db"; import { config } from "./config"; -import { users } from "./db/schema"; -import { findUserByProviderSubject, findUserByEmail, createUser, getUserById } from "./models/user"; +import { findUserByProviderSubject, createUser, getUserById } from "./models/user"; import { createAuditEvent } from "./models/audit"; import { decideLinkingStrategy, createLinkingToken, storeLinkingToken, autoLinkOAuth, linkOAuthAuthenticated } from "./services/account-linking"; @@ -72,15 +70,15 @@ function createCredentialsProvider() { const credentialsProvider = createCredentialsProvider(); // Create OAuth providers based on configuration -function createOAuthProviders(): OAuthConfig[] { - const providers: OAuthConfig[] = []; +function createOAuthProviders(): OAuthConfig>[] { + const providers: OAuthConfig>[] = []; if ( config.oauth.enabled && config.oauth.clientId && config.oauth.clientSecret ) { - const oauthProvider: OAuthConfig = { + const oauthProvider: OAuthConfig> = { id: "oauth2", name: config.oauth.providerName, type: "oidc", @@ -93,11 +91,20 @@ function createOAuthProviders(): OAuthConfig[] { // PKCE is the default for OIDC; state is added as defence-in-depth checks: ["pkce", "state"], profile(profile) { + const sub = typeof profile.sub === "string" ? profile.sub : undefined; + const id = typeof profile.id === "string" ? profile.id : undefined; + const name = typeof profile.name === "string" ? profile.name : undefined; + const preferredUsername = + typeof profile.preferred_username === "string" ? profile.preferred_username : undefined; + const email = typeof profile.email === "string" ? profile.email : undefined; + const picture = typeof profile.picture === "string" ? profile.picture : null; + const avatarUrl = typeof profile.avatar_url === "string" ? profile.avatar_url : null; + return { - id: profile.sub ?? profile.id, - name: profile.name ?? profile.preferred_username ?? profile.email, - email: profile.email, - image: profile.picture ?? profile.avatar_url ?? null, + id: sub ?? id, + name: name ?? preferredUsername ?? email, + email, + image: picture ?? avatarUrl, }; }, }; @@ -117,7 +124,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ signIn: "/login", }, callbacks: { - async signIn({ user, account, profile }) { + async signIn({ user, account }) { // Credentials provider - handled by authorize function if (account?.provider === "credentials") { return true; @@ -131,7 +138,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ try { // Check if this is an OAuth linking attempt by checking the database const { pendingOAuthLinks } = await import("./db/schema"); - const { eq, and, gt } = await import("drizzle-orm"); + const { eq } = await import("drizzle-orm"); const { nowIso } = await import("./db"); // Find ALL non-expired pending links for this provider diff --git a/src/lib/caddy-monitor.ts b/src/lib/caddy-monitor.ts index ddcafa32..5b5b6d58 100644 --- a/src/lib/caddy-monitor.ts +++ b/src/lib/caddy-monitor.ts @@ -7,7 +7,6 @@ import http from "node:http"; import https from "node:https"; import { config } from "./config"; import { applyCaddyConfig } from "./caddy"; -import { getSetting, setSetting } from "./settings"; type CaddyMonitorState = { isHealthy: boolean; @@ -20,7 +19,7 @@ const HEALTH_CHECK_INTERVAL = 10000; // Check every 10 seconds const MAX_CONSECUTIVE_FAILURES = 3; // Consider unhealthy after 3 failures const REAPPLY_DELAY = 5000; // Wait 5 seconds after detecting restart before reapplying -let monitorState: CaddyMonitorState = { +const monitorState: CaddyMonitorState = { isHealthy: false, lastConfigId: null, lastCheckTime: 0, @@ -67,7 +66,7 @@ async function getCaddyConfigId(): Promise { // Check if config is essentially empty (default state after restart) const isEmpty = !configData.apps || Object.keys(configData.apps).length === 0; return isEmpty ? "empty" : "configured"; - } catch (error) { + } catch { // Network error or timeout return null; } diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index ad0ab67f..d6399ec5 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -4,7 +4,6 @@ import { join } from "node:path"; import { isIP } from "node:net"; import crypto from "node:crypto"; import { - PRIVATE_RANGES_CIDRS, expandPrivateRanges, isPlainObject, mergeDeep, @@ -12,15 +11,19 @@ import { parseOptionalJson, parseCustomHandlers, formatDialAddress, - parseHostPort, parseUpstreamTarget, toDurationMs, - type ParsedUpstreamTarget, } from "./caddy-utils"; +import { + groupHostPatternsByPriority, + sortAutomationPoliciesBySubjectPriority, + sortRoutesByHostPriority, + sortTlsPoliciesBySniPriority, +} from "./host-pattern-priority"; import http from "node:http"; import https from "node:https"; import db, { nowIso } from "./db"; -import { isNull, isNotNull } from "drizzle-orm"; +import { isNull } from "drizzle-orm"; import { config } from "./config"; import { getCloudflareSettings, @@ -47,7 +50,7 @@ import { proxyHosts } from "./db/schema"; import { type GeoBlockMode, type WafHostConfig, type MtlsConfig } from "./models/proxy-hosts"; -import { pemToBase64Der, buildClientAuthentication, groupMtlsDomainsByCaSet } from "./caddy-mtls"; +import { buildClientAuthentication, groupMtlsDomainsByCaSet } from "./caddy-mtls"; import { buildWafHandler, resolveEffectiveWaf } from "./caddy-waf"; const CERTS_DIR = process.env.CERTS_DIRECTORY || join(process.cwd(), "data", "certs"); @@ -633,6 +636,7 @@ async function buildProxyRoutes( if (domains.length === 0) { continue; } + const domainGroups = groupHostPatternsByPriority(domains); // Require upstreams const upstreams = parseJson(row.upstreams, []); @@ -674,24 +678,26 @@ async function buildProxyRoutes( } if (row.ssl_forced) { - hostRoutes.push({ - match: [ - { - host: domains, - expression: '{http.request.scheme} == "http"' - } - ], - handle: [ - { - handler: "static_response", - status_code: 308, - headers: { - Location: ["https://{http.request.host}{http.request.uri}"] + for (const domainGroup of domainGroups) { + hostRoutes.push({ + match: [ + { + host: domainGroup, + expression: '{http.request.scheme} == "http"' } - } - ], - terminal: true - }); + ], + handle: [ + { + handler: "static_response", + status_code: 308, + headers: { + Location: ["https://{http.request.host}{http.request.uri}"] + } + } + ], + terminal: true + }); + } } if (row.access_list_id) { @@ -767,7 +773,6 @@ async function buildProxyRoutes( outpostRoute = { match: [ { - host: domains, path: [`/${authentik.outpostDomain}/*`] } ], @@ -932,88 +937,97 @@ async function buildProxyRoutes( // Path-based authentication support if (authentik.protectedPaths && authentik.protectedPaths.length > 0) { - // Create separate routes for each protected path - for (const protectedPath of authentik.protectedPaths) { - const protectedHandlers: Record[] = [...handlers]; - const protectedReverseProxy = JSON.parse(JSON.stringify(reverseProxyHandler)); + for (const domainGroup of domainGroups) { + // Create separate routes for each protected path + for (const protectedPath of authentik.protectedPaths) { + const protectedHandlers: Record[] = [...handlers]; + const protectedReverseProxy = JSON.parse(JSON.stringify(reverseProxyHandler)); - protectedHandlers.push(forwardAuthHandler); - protectedHandlers.push(protectedReverseProxy); + protectedHandlers.push(forwardAuthHandler); + protectedHandlers.push(protectedReverseProxy); + + hostRoutes.push({ + match: [ + { + host: domainGroup, + path: [protectedPath] + } + ], + handle: protectedHandlers, + terminal: true + }); + } + + if (outpostRoute) { + const outpostMatches = (outpostRoute.match as Array> | undefined) ?? []; + hostRoutes.push({ + ...outpostRoute, + match: outpostMatches.map((match) => ({ + ...match, + host: domainGroup + })) + }); + } + + const unprotectedHandlers: Record[] = [...handlers, reverseProxyHandler]; hostRoutes.push({ match: [ { - host: domains, - path: [protectedPath] + host: domainGroup } ], - handle: protectedHandlers, + handle: unprotectedHandlers, terminal: true }); } - - // Add the outpost route AFTER protected paths but BEFORE the catch-all - // This ensures the outpost callback route is properly handled - if (outpostRoute) { - hostRoutes.push(outpostRoute); - } - - // Create a catch-all route for non-protected paths (without forward auth) - const unprotectedHandlers: Record[] = [...handlers]; - unprotectedHandlers.push(reverseProxyHandler); - - hostRoutes.push({ - match: [ - { - host: domains - } - ], - handle: unprotectedHandlers, - terminal: true - }); } else { - // No path-based protection: protect entire domain (backward compatibility) - // Add outpost route first to handle callbacks - if (outpostRoute) { - hostRoutes.push(outpostRoute); + for (const domainGroup of domainGroups) { + if (outpostRoute) { + const outpostMatches = (outpostRoute.match as Array> | undefined) ?? []; + hostRoutes.push({ + ...outpostRoute, + match: outpostMatches.map((match) => ({ + ...match, + host: domainGroup + })) + }); + } + + const routeHandlers: Record[] = [...handlers, forwardAuthHandler, reverseProxyHandler]; + const route: CaddyHttpRoute = { + match: [ + { + host: domainGroup + } + ], + handle: routeHandlers, + terminal: true + }; + + hostRoutes.push(route); } - - handlers.push(forwardAuthHandler); - handlers.push(reverseProxyHandler); - + } + } else { + for (const domainGroup of domainGroups) { const route: CaddyHttpRoute = { match: [ { - host: domains + host: domainGroup } ], - handle: handlers, + handle: [...handlers, reverseProxyHandler], terminal: true }; hostRoutes.push(route); } - } else { - // No Authentik: standard reverse proxy - handlers.push(reverseProxyHandler); - - const route: CaddyHttpRoute = { - match: [ - { - host: domains - } - ], - handle: handlers, - terminal: true - }; - - hostRoutes.push(route); } routes.push(...hostRoutes); } - return routes; + return sortRoutesByHostPriority(routes); } function buildTlsConnectionPolicies( @@ -1041,12 +1055,14 @@ function buildTlsConnectionPolicies( const pushMtlsPolicies = (mTlsDomains: string[]) => { const groups = groupMtlsDomainsByCaSet(mTlsDomains, mTlsDomainMap); for (const domainGroup of groups.values()) { - const mTlsAuth = buildAuth(domainGroup); - if (mTlsAuth) { - policies.push({ match: { sni: domainGroup }, client_authentication: mTlsAuth }); - } else { - // All CAs have all certs revoked — drop connections rather than allow through without mTLS - policies.push({ match: { sni: domainGroup }, drop: true }); + for (const priorityGroup of groupHostPatternsByPriority(domainGroup)) { + const mTlsAuth = buildAuth(priorityGroup); + if (mTlsAuth) { + policies.push({ match: { sni: priorityGroup }, client_authentication: mTlsAuth }); + } else { + // All CAs have all certs revoked — drop connections rather than allow through without mTLS + policies.push({ match: { sni: priorityGroup }, drop: true }); + } } } }; @@ -1061,8 +1077,8 @@ function buildTlsConnectionPolicies( if (mTlsDomains.length > 0) { pushMtlsPolicies(mTlsDomains); } - if (nonMTlsDomains.length > 0) { - policies.push({ match: { sni: nonMTlsDomains } }); + for (const priorityGroup of groupHostPatternsByPriority(nonMTlsDomains)) { + policies.push({ match: { sni: priorityGroup } }); } } @@ -1089,8 +1105,8 @@ function buildTlsConnectionPolicies( if (mTlsDomains.length > 0) { pushMtlsPolicies(mTlsDomains); } - if (nonMTlsDomains.length > 0) { - policies.push({ match: { sni: nonMTlsDomains } }); + for (const priorityGroup of groupHostPatternsByPriority(nonMTlsDomains)) { + policies.push({ match: { sni: priorityGroup } }); } readyCertificates.add(id); @@ -1108,8 +1124,8 @@ function buildTlsConnectionPolicies( if (mTlsDomains.length > 0) { pushMtlsPolicies(mTlsDomains); } - if (nonMTlsDomains.length > 0) { - policies.push({ match: { sni: nonMTlsDomains } }); + for (const priorityGroup of groupHostPatternsByPriority(nonMTlsDomains)) { + policies.push({ match: { sni: priorityGroup } }); } readyCertificates.add(id); @@ -1117,7 +1133,7 @@ function buildTlsConnectionPolicies( } return { - policies, + policies: sortTlsPoliciesBySniPriority(policies), readyCertificates, importedCertPems }; @@ -1160,42 +1176,39 @@ async function buildTlsAutomation( // Add policy for auto-managed domains (certificate_id = null) if (hasAutoManagedDomains) { - const subjects = Array.from(autoManagedDomains); - - // Build issuer configuration - const issuer: Record = { - module: "acme" - }; - - if (options.acmeEmail) { - issuer.email = options.acmeEmail; - } - - // Use DNS-01 challenge if Cloudflare is configured, otherwise use HTTP-01 - if (hasCloudflare) { - const providerConfig: Record = { - name: "cloudflare", - api_token: cloudflare.apiToken + for (const subjects of groupHostPatternsByPriority(Array.from(autoManagedDomains))) { + const issuer: Record = { + module: "acme" }; - const dnsChallenge: Record = { - provider: providerConfig - }; - - // Add custom DNS resolvers if configured - if (dnsResolvers.length > 0) { - dnsChallenge.resolvers = dnsResolvers; + if (options.acmeEmail) { + issuer.email = options.acmeEmail; } - issuer.challenges = { - dns: dnsChallenge - }; - } + if (hasCloudflare) { + const providerConfig: Record = { + name: "cloudflare", + api_token: cloudflare.apiToken + }; - policies.push({ - subjects, - issuers: [issuer] - }); + const dnsChallenge: Record = { + provider: providerConfig + }; + + if (dnsResolvers.length > 0) { + dnsChallenge.resolvers = dnsResolvers; + } + + issuer.challenges = { + dns: dnsChallenge + }; + } + + policies.push({ + subjects, + issuers: [issuer] + }); + } } // Add policies for explicitly managed certificates @@ -1207,40 +1220,39 @@ async function buildTlsAutomation( managedCertificateIds.add(entry.certificate.id); - // Build issuer configuration - const issuer: Record = { - module: "acme" - }; - - if (options.acmeEmail) { - issuer.email = options.acmeEmail; - } - - // Use DNS-01 challenge if Cloudflare is configured, otherwise use HTTP-01 - if (hasCloudflare) { - const providerConfig: Record = { - name: "cloudflare", - api_token: cloudflare.apiToken + for (const subjectGroup of groupHostPatternsByPriority(subjects)) { + const issuer: Record = { + module: "acme" }; - const dnsChallenge: Record = { - provider: providerConfig - }; - - // Add custom DNS resolvers if configured - if (dnsResolvers.length > 0) { - dnsChallenge.resolvers = dnsResolvers; + if (options.acmeEmail) { + issuer.email = options.acmeEmail; } - issuer.challenges = { - dns: dnsChallenge - }; - } + if (hasCloudflare) { + const providerConfig: Record = { + name: "cloudflare", + api_token: cloudflare.apiToken + }; - policies.push({ - subjects, - issuers: [issuer] - }); + const dnsChallenge: Record = { + provider: providerConfig + }; + + if (dnsResolvers.length > 0) { + dnsChallenge.resolvers = dnsResolvers; + } + + issuer.challenges = { + dns: dnsChallenge + }; + } + + policies.push({ + subjects: subjectGroup, + issuers: [issuer] + }); + } } if (policies.length === 0) { @@ -1252,7 +1264,7 @@ async function buildTlsAutomation( return { tlsApp: { automation: { - policies + policies: sortAutomationPoliciesBySubjectPriority(policies) } }, managedCertificateIds diff --git a/src/lib/config.ts b/src/lib/config.ts index a4812b39..c475045e 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -180,9 +180,9 @@ export function validateProductionConfig() { if (isRuntimeProduction) { // Force validation by accessing the config values // This will throw if defaults are being used in production - const _ = config.sessionSecret; - const __ = config.adminUsername; - const ___ = config.adminPassword; + void config.sessionSecret; + void config.adminUsername; + void config.adminPassword; } } diff --git a/src/lib/db.ts b/src/lib/db.ts index 7a7d6815..9c17cc36 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -88,10 +88,18 @@ function runMigrations() { try { migrate(db, { migrationsFolder }); globalForDrizzle.__MIGRATIONS_RAN__ = true; - } catch (error: any) { + } catch (error: unknown) { // During build, pages may be pre-rendered in parallel, causing race conditions // with migrations. If tables already exist, just continue. - if (error?.code === 'SQLITE_ERROR' && error?.message?.includes('already exists')) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + "message" in error && + error.code === "SQLITE_ERROR" && + typeof error.message === "string" && + error.message.includes("already exists") + ) { console.log('Database tables already exist, skipping migrations'); globalForDrizzle.__MIGRATIONS_RAN__ = true; return; diff --git a/src/lib/host-pattern-priority.ts b/src/lib/host-pattern-priority.ts new file mode 100644 index 00000000..d7dbc74f --- /dev/null +++ b/src/lib/host-pattern-priority.ts @@ -0,0 +1,187 @@ +type HostPatternInfo = { + normalized: string; + wildcard: boolean; + labelCount: number; + suffixLength: number; +}; + +type RouteMatch = { + host?: string[]; + path?: string[]; +}; + +type RouteLike = { + match?: RouteMatch[]; +}; + +type TlsPolicyLike = { + match?: { + sni?: string[]; + }; +}; + +type AutomationPolicyLike = { + subjects?: string[]; +}; + +function normalizeHostPattern(pattern: string) { + return pattern.trim().toLowerCase().replace(/\.$/, ""); +} + +function getHostPatternInfo(pattern: string): HostPatternInfo { + const normalized = normalizeHostPattern(pattern); + const wildcard = normalized.startsWith("*."); + const suffix = wildcard ? normalized.slice(2) : normalized; + + return { + normalized, + wildcard, + labelCount: suffix ? suffix.split(".").length : 0, + suffixLength: suffix.length, + }; +} + +function getHostPriorityKey(info: HostPatternInfo) { + return `${info.wildcard ? "wildcard" : "exact"}:${info.labelCount}`; +} + +function getPathPriority(paths: string[]) { + if (paths.length === 0) { + return { hasPath: false, wildcard: true, length: 0 }; + } + + return paths.reduce( + (best, path) => { + const wildcard = path.endsWith("*"); + const candidate = { + hasPath: true, + wildcard, + length: path.length, + }; + + if (!best.hasPath) { + return candidate; + } + + if (best.wildcard !== candidate.wildcard) { + return candidate.wildcard ? best : candidate; + } + + if (candidate.length !== best.length) { + return candidate.length > best.length ? candidate : best; + } + + return best; + }, + { hasPath: false, wildcard: true, length: 0 } + ); +} + +export function compareHostPatterns(a: string, b: string) { + const infoA = getHostPatternInfo(a); + const infoB = getHostPatternInfo(b); + + if (infoA.wildcard !== infoB.wildcard) { + return infoA.wildcard ? 1 : -1; + } + + if (infoA.labelCount !== infoB.labelCount) { + return infoB.labelCount - infoA.labelCount; + } + + if (infoA.suffixLength !== infoB.suffixLength) { + return infoB.suffixLength - infoA.suffixLength; + } + + return infoA.normalized.localeCompare(infoB.normalized); +} + +export function groupHostPatternsByPriority(patterns: string[]) { + const sorted = [...patterns].sort(compareHostPatterns); + const groups: string[][] = []; + + for (const pattern of sorted) { + const info = getHostPatternInfo(pattern); + const key = getHostPriorityKey(info); + const currentGroup = groups[groups.length - 1]; + + if (!currentGroup) { + groups.push([info.normalized]); + continue; + } + + const currentKey = getHostPriorityKey(getHostPatternInfo(currentGroup[0])); + if (currentKey === key) { + currentGroup.push(info.normalized); + continue; + } + + groups.push([info.normalized]); + } + + return groups; +} + +export function sortRoutesByHostPriority(routes: T[]) { + return routes + .map((route, index) => ({ route, index })) + .sort((left, right) => { + const leftHosts = (left.route.match ?? []).flatMap((match) => match.host ?? []); + const rightHosts = (right.route.match ?? []).flatMap((match) => match.host ?? []); + + if (leftHosts.length > 0 && rightHosts.length > 0) { + const hostComparison = compareHostPatterns(leftHosts[0], rightHosts[0]); + if (hostComparison !== 0) { + return hostComparison; + } + } else if (leftHosts.length !== rightHosts.length) { + return rightHosts.length - leftHosts.length; + } + + const leftPaths = (left.route.match ?? []).flatMap((match) => match.path ?? []); + const rightPaths = (right.route.match ?? []).flatMap((match) => match.path ?? []); + const leftPathPriority = getPathPriority(leftPaths); + const rightPathPriority = getPathPriority(rightPaths); + + if (leftPathPriority.hasPath !== rightPathPriority.hasPath) { + return leftPathPriority.hasPath ? -1 : 1; + } + + if (leftPathPriority.wildcard !== rightPathPriority.wildcard) { + return leftPathPriority.wildcard ? 1 : -1; + } + + if (leftPathPriority.length !== rightPathPriority.length) { + return rightPathPriority.length - leftPathPriority.length; + } + + return left.index - right.index; + }) + .map(({ route }) => route); +} + +export function sortTlsPoliciesBySniPriority(policies: T[]) { + return [...policies].sort((left, right) => { + const leftSni = left.match?.sni ?? []; + const rightSni = right.match?.sni ?? []; + + if (leftSni.length > 0 && rightSni.length > 0) { + return compareHostPatterns(leftSni[0], rightSni[0]); + } + + return rightSni.length - leftSni.length; + }); +} + +export function sortAutomationPoliciesBySubjectPriority(policies: T[]) { + return [...policies].sort((left, right) => { + const leftSubjects = left.subjects ?? []; + const rightSubjects = right.subjects ?? []; + + if (leftSubjects.length > 0 && rightSubjects.length > 0) { + return compareHostPatterns(leftSubjects[0], rightSubjects[0]); + } + + return rightSubjects.length - leftSubjects.length; + }); +} diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts index d432aa71..3a74c5c2 100644 --- a/src/lib/models/proxy-hosts.ts +++ b/src/lib/models/proxy-hosts.ts @@ -3,7 +3,8 @@ import { applyCaddyConfig } from "../caddy"; import { logAuditEvent } from "../audit"; import { proxyHosts } from "../db/schema"; import { desc, eq, count, like, or } from "drizzle-orm"; -import { getAuthentikSettings, getGeoBlockSettings, GeoBlockSettings } from "../settings"; +import { type GeoBlockSettings } from "../settings"; +import { normalizeProxyHostDomains } from "../proxy-host-domains"; const DEFAULT_AUTHENTIK_HEADERS = [ "X-Authentik-Username", @@ -1410,9 +1411,7 @@ export async function listProxyHostsPaginated(limit: number, offset: number, sea } export async function createProxyHost(input: ProxyHostInput, actorUserId: number) { - if (!input.domains || input.domains.length === 0) { - throw new Error("At least one domain must be specified"); - } + const domains = normalizeProxyHostDomains(input.domains ?? []); if (!input.upstreams || input.upstreams.length === 0) { throw new Error("At least one upstream must be specified"); @@ -1424,7 +1423,7 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number .insert(proxyHosts) .values({ name: input.name.trim(), - domains: JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))), + domains: JSON.stringify(domains), upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))), certificateId: input.certificate_id ?? null, accessListId: input.access_list_id ?? null, @@ -1472,7 +1471,9 @@ export async function updateProxyHost(id: number, input: Partial throw new Error("Proxy host not found"); } - const domains = input.domains ? JSON.stringify(Array.from(new Set(input.domains))) : JSON.stringify(existing.domains); + const domains = JSON.stringify( + input.domains ? normalizeProxyHostDomains(input.domains) : existing.domains + ); const upstreams = input.upstreams ? JSON.stringify(Array.from(new Set(input.upstreams))) : JSON.stringify(existing.upstreams); const existingMeta: ProxyHostMeta = { custom_reverse_proxy_json: existing.custom_reverse_proxy_json ?? undefined, diff --git a/src/lib/models/user.ts b/src/lib/models/user.ts index 94325226..49426230 100644 --- a/src/lib/models/user.ts +++ b/src/lib/models/user.ts @@ -1,6 +1,6 @@ import db, { nowIso, toIso } from "../db"; import { users } from "../db/schema"; -import { and, asc, count, eq } from "drizzle-orm"; +import { and, count, eq } from "drizzle-orm"; export type User = { id: number; diff --git a/src/lib/proxy-host-domains.ts b/src/lib/proxy-host-domains.ts new file mode 100644 index 00000000..6c9d1c5c --- /dev/null +++ b/src/lib/proxy-host-domains.ts @@ -0,0 +1,52 @@ +import { isIP } from "node:net"; + +const HOST_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +function isValidHostname(value: string) { + if (!value || value.length > 253) { + return false; + } + + return value.split(".").every((label) => HOST_LABEL_REGEX.test(label)); +} + +export function isValidProxyHostDomain(value: string) { + const normalized = value.trim().toLowerCase().replace(/\.$/, ""); + if (!normalized) { + return false; + } + + if (normalized.startsWith("*.")) { + const baseDomain = normalized.slice(2); + return !baseDomain.includes("*") && isValidHostname(baseDomain); + } + + if (normalized.includes("*")) { + return false; + } + + return isIP(normalized) !== 0 || isValidHostname(normalized); +} + +export function normalizeProxyHostDomains(domains: string[]) { + const normalizedDomains = Array.from( + new Set( + domains + .map((domain) => domain.trim().toLowerCase().replace(/\.$/, "")) + .filter(Boolean) + ) + ); + + if (normalizedDomains.length === 0) { + throw new Error("At least one domain must be specified"); + } + + const invalidDomain = normalizedDomains.find((domain) => !isValidProxyHostDomain(domain)); + if (invalidDomain) { + throw new Error( + `Invalid domain "${invalidDomain}". Wildcards are supported only as the left-most label, for example "*.example.com".` + ); + } + + return normalizedDomains; +} diff --git a/tests/e2e/functional/ssl-redirect.spec.ts b/tests/e2e/functional/ssl-redirect.spec.ts index 323f2b19..080a845e 100644 --- a/tests/e2e/functional/ssl-redirect.spec.ts +++ b/tests/e2e/functional/ssl-redirect.spec.ts @@ -8,7 +8,6 @@ * Domain: func-ssl.test */ import { test, expect } from '@playwright/test'; -import { createProxyHost } from '../../helpers/proxy-api'; import { httpGet, waitForRoute } from '../../helpers/http'; import { injectFormFields } from '../../helpers/http'; diff --git a/tests/e2e/mobile/mobile-layout.spec.ts b/tests/e2e/mobile/mobile-layout.spec.ts index 26fe5adf..daa32ec4 100644 --- a/tests/e2e/mobile/mobile-layout.spec.ts +++ b/tests/e2e/mobile/mobile-layout.spec.ts @@ -1,18 +1,10 @@ import { test, expect } from '@playwright/test'; -// All tests in this file are intended for the mobile-iphone project. -// They rely on the iPhone 15 viewport (393x852) set in playwright.config.ts. +// Force a mobile viewport even under the desktop Chromium project so these +// checks validate responsive behavior instead of self-skipping. +test.use({ viewport: { width: 393, height: 852 } }); -// Skip this entire describe block when running on non-mobile projects (e.g. chromium desktop). -// The mobile-iphone project uses WebKit (iPhone 15) so we detect by viewport width. test.describe('Mobile layout', () => { - test.beforeEach(async ({ page }) => { - // Skip on desktop viewports — these tests are mobile-only - const viewport = page.viewportSize(); - if (!viewport || viewport.width > 600) { - test.skip(); - } - }); test('app bar is visible with hamburger and title', async ({ page }) => { await page.goto('/'); // The MUI AppBar should be present on mobile diff --git a/tests/integration/access-lists-passwords.test.ts b/tests/integration/access-lists-passwords.test.ts index 5f7dfaa8..343241ef 100644 --- a/tests/integration/access-lists-passwords.test.ts +++ b/tests/integration/access-lists-passwords.test.ts @@ -4,7 +4,7 @@ * Verifies that the model layer hashes passwords before storage and that * bcrypt.compare() succeeds with the correct password. */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { createTestDb, type TestDb } from '../helpers/db'; import { accessLists, accessListEntries } from '@/src/lib/db/schema'; import { eq } from 'drizzle-orm'; diff --git a/tests/integration/instance-sync.test.ts b/tests/integration/instance-sync.test.ts index 5abf4f8c..fc2a7424 100644 --- a/tests/integration/instance-sync.test.ts +++ b/tests/integration/instance-sync.test.ts @@ -33,7 +33,7 @@ vi.mock('../../src/lib/db', async () => { }); // These imports must come AFTER vi.mock to pick up the mocked module. -import { buildSyncPayload, applySyncPayload } from '../../src/lib/instance-sync'; +import { buildSyncPayload, applySyncPayload, type SyncPayload } from '../../src/lib/instance-sync'; import * as schema from '../../src/lib/db/schema'; // --------------------------------------------------------------------------- @@ -197,7 +197,7 @@ describe('buildSyncPayload', () => { describe('applySyncPayload', () => { /** Build a minimal valid payload (all data empty, all settings null). */ - function emptyPayload() { + function emptyPayload(): SyncPayload { return { generated_at: nowIso(), settings: { @@ -212,18 +212,18 @@ describe('applySyncPayload', () => { geoblock: null, }, data: { - certificates: [] as any[], - caCertificates: [] as any[], - issuedClientCertificates: [] as any[], - accessLists: [] as any[], - accessListEntries: [] as any[], - proxyHosts: [] as any[], + certificates: [], + caCertificates: [], + issuedClientCertificates: [], + accessLists: [], + accessListEntries: [], + proxyHosts: [], }, }; } it('runs without error on an empty payload', async () => { - await expect(applySyncPayload(emptyPayload() as any)).resolves.toBeUndefined(); + await expect(applySyncPayload(emptyPayload())).resolves.toBeUndefined(); }); it('clears existing proxy hosts when payload has empty array', async () => { @@ -231,7 +231,7 @@ describe('applySyncPayload', () => { const before = await ctx.db.select().from(schema.proxyHosts); expect(before).toHaveLength(1); - await applySyncPayload(emptyPayload() as any); + await applySyncPayload(emptyPayload()); const after = await ctx.db.select().from(schema.proxyHosts); expect(after).toHaveLength(0); @@ -262,7 +262,7 @@ describe('applySyncPayload', () => { }, ]; - await applySyncPayload(payload as any); + await applySyncPayload(payload); const rows = await ctx.db.select().from(schema.proxyHosts); expect(rows).toHaveLength(1); @@ -297,7 +297,7 @@ describe('applySyncPayload', () => { }, ]; - await applySyncPayload(payload as any); + await applySyncPayload(payload); const rows = await ctx.db.select().from(schema.proxyHosts); expect(rows).toHaveLength(1); @@ -329,8 +329,8 @@ describe('applySyncPayload', () => { }, ]; - await applySyncPayload(payload as any); - await applySyncPayload(payload as any); + await applySyncPayload(payload); + await applySyncPayload(payload); const rows = await ctx.db.select().from(schema.proxyHosts); expect(rows).toHaveLength(1); @@ -341,7 +341,7 @@ describe('applySyncPayload', () => { const payload = emptyPayload(); payload.settings.general = { primaryDomain: 'example.com' }; - await applySyncPayload(payload as any); + await applySyncPayload(payload); const row = await ctx.db.query.settings.findFirst({ where: (t, { eq }) => eq(t.key, 'synced:general'), @@ -354,7 +354,7 @@ describe('applySyncPayload', () => { const payload = emptyPayload(); payload.settings.cloudflare = null; - await applySyncPayload(payload as any); + await applySyncPayload(payload); const row = await ctx.db.query.settings.findFirst({ where: (t, { eq }) => eq(t.key, 'synced:cloudflare'), @@ -373,7 +373,7 @@ describe('applySyncPayload', () => { { id: 1, accessListId: 1, username: 'synceduser', passwordHash: '$2b$10$fakehash', createdAt: now, updatedAt: now }, ]; - await applySyncPayload(payload as any); + await applySyncPayload(payload); const lists = await ctx.db.select().from(schema.accessLists); expect(lists).toHaveLength(1); diff --git a/tests/integration/proxy-hosts-meta.test.ts b/tests/integration/proxy-hosts-meta.test.ts index 2ff4e005..619a145f 100644 --- a/tests/integration/proxy-hosts-meta.test.ts +++ b/tests/integration/proxy-hosts-meta.test.ts @@ -8,7 +8,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createTestDb, type TestDb } from '../helpers/db'; import { proxyHosts } from '@/src/lib/db/schema'; -import { eq } from 'drizzle-orm'; let db: TestDb; @@ -28,14 +27,14 @@ async function insertHost(overrides: Partial = { upstreams: JSON.stringify(['backend:8080']), certificateId: null, accessListId: null, - sslForced: 0, - hstsEnabled: 0, - hstsSubdomains: 0, - allowWebsocket: 0, - preserveHostHeader: 0, - skipHttpsHostnameValidation: 0, + sslForced: false, + hstsEnabled: false, + hstsSubdomains: false, + allowWebsocket: false, + preserveHostHeader: false, + skipHttpsHostnameValidation: false, meta: null, - enabled: 1, + enabled: true, createdAt: now, updatedAt: now, ...overrides, @@ -192,14 +191,14 @@ describe('proxy-hosts load balancer meta', () => { describe('proxy-hosts boolean fields', () => { it('sslForced is stored and retrieved truthy', async () => { - const host = await insertHost({ sslForced: 1 }); + const host = await insertHost({ sslForced: true }); const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); // Drizzle may return SQLite 0/1 as number or as boolean depending on schema mode expect(Boolean(row!.sslForced)).toBe(true); }); it('hstsEnabled and hstsSubdomains round-trip correctly', async () => { - const host = await insertHost({ hstsEnabled: 1, hstsSubdomains: 1 }); + const host = await insertHost({ hstsEnabled: true, hstsSubdomains: true }); const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); expect(Boolean(row!.hstsEnabled)).toBe(true); expect(Boolean(row!.hstsSubdomains)).toBe(true); @@ -212,7 +211,7 @@ describe('proxy-hosts boolean fields', () => { }); it('enabled can be set to disabled (falsy)', async () => { - const host = await insertHost({ enabled: 0 }); + const host = await insertHost({ enabled: false }); const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); expect(Boolean(row!.enabled)).toBe(false); }); diff --git a/tests/unit/host-pattern-priority.test.ts b/tests/unit/host-pattern-priority.test.ts new file mode 100644 index 00000000..72d4b387 --- /dev/null +++ b/tests/unit/host-pattern-priority.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + compareHostPatterns, + groupHostPatternsByPriority, + sortAutomationPoliciesBySubjectPriority, + sortRoutesByHostPriority, + sortTlsPoliciesBySniPriority, +} from "@/src/lib/host-pattern-priority"; + +describe("compareHostPatterns", () => { + it("puts exact hosts ahead of same-level wildcards", () => { + expect(compareHostPatterns("api.example.com", "*.example.com")).toBeLessThan(0); + }); + + it("puts deeper patterns ahead of broader ones", () => { + expect(compareHostPatterns("foo.sub.example.com", "foo.example.com")).toBeLessThan(0); + expect(compareHostPatterns("*.sub.example.com", "*.example.com")).toBeLessThan(0); + }); +}); + +describe("groupHostPatternsByPriority", () => { + it("splits exact and wildcard domains into deterministic priority groups", () => { + expect( + groupHostPatternsByPriority([ + "*.example.com", + "admin.example.com", + "*.sub.example.com", + "api.example.com", + ]) + ).toEqual([ + ["admin.example.com", "api.example.com"], + ["*.sub.example.com"], + ["*.example.com"], + ]); + }); +}); + +describe("sortRoutesByHostPriority", () => { + it("orders exact routes before matching wildcard routes", () => { + const routes = sortRoutesByHostPriority([ + { match: [{ host: ["*.example.com"] }], id: "wildcard" }, + { match: [{ host: ["api.example.com"] }], id: "exact" }, + ]); + + expect(routes.map((route) => (route as { id: string }).id)).toEqual(["exact", "wildcard"]); + }); + + it("keeps path-specific routes ahead of catch-all routes for the same host group", () => { + const routes = sortRoutesByHostPriority([ + { match: [{ host: ["api.example.com"] }], id: "catch-all" }, + { match: [{ host: ["api.example.com"], path: ["/auth/*"] }], id: "path" }, + ]); + + expect(routes.map((route) => (route as { id: string }).id)).toEqual(["path", "catch-all"]); + }); +}); + +describe("sortTlsPoliciesBySniPriority", () => { + it("orders exact SNI policies before same-level wildcard SNI policies", () => { + const policies = sortTlsPoliciesBySniPriority([ + { match: { sni: ["*.example.com"] }, id: "wildcard" }, + { match: { sni: ["api.example.com"] }, id: "exact" }, + ]); + + expect(policies.map((policy) => (policy as { id: string }).id)).toEqual(["exact", "wildcard"]); + }); +}); + +describe("sortAutomationPoliciesBySubjectPriority", () => { + it("orders exact automation subjects before wildcard subjects", () => { + const policies = sortAutomationPoliciesBySubjectPriority([ + { subjects: ["*.example.com"], id: "wildcard" }, + { subjects: ["api.example.com"], id: "exact" }, + ]); + + expect(policies.map((policy) => (policy as { id: string }).id)).toEqual(["exact", "wildcard"]); + }); +}); diff --git a/tests/unit/proxy-host-domains.test.ts b/tests/unit/proxy-host-domains.test.ts new file mode 100644 index 00000000..aa1633e3 --- /dev/null +++ b/tests/unit/proxy-host-domains.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { isValidProxyHostDomain, normalizeProxyHostDomains } from "@/src/lib/proxy-host-domains"; + +describe("isValidProxyHostDomain", () => { + it("accepts standard hostnames", () => { + expect(isValidProxyHostDomain("app.example.com")).toBe(true); + expect(isValidProxyHostDomain("localhost")).toBe(true); + }); + + it("accepts wildcard hostnames on the left-most label", () => { + expect(isValidProxyHostDomain("*.example.com")).toBe(true); + expect(isValidProxyHostDomain("*.local")).toBe(true); + }); + + it("rejects wildcard hostnames outside the left-most label", () => { + expect(isValidProxyHostDomain("api.*.example.com")).toBe(false); + expect(isValidProxyHostDomain("*.*.example.com")).toBe(false); + expect(isValidProxyHostDomain("example.*")).toBe(false); + }); + + it("accepts IP literals", () => { + expect(isValidProxyHostDomain("192.168.1.10")).toBe(true); + expect(isValidProxyHostDomain("2001:db8::1")).toBe(true); + }); +}); + +describe("normalizeProxyHostDomains", () => { + it("normalizes case, strips trailing dots, and deduplicates domains", () => { + expect( + normalizeProxyHostDomains([" *.Example.com. ", "APP.EXAMPLE.COM", "*.example.com"]) + ).toEqual(["*.example.com", "app.example.com"]); + }); + + it("throws a helpful error for invalid wildcard placement", () => { + expect(() => normalizeProxyHostDomains(["api.*.example.com"])).toThrow( + 'Invalid domain "api.*.example.com". Wildcards are supported only as the left-most label, for example "*.example.com".' + ); + }); +});