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".'
+ );
+ });
+});