Handle wildcard proxy hosts and stabilize test coverage

- accept wildcard proxy host domains like *.example.com with validation and normalization
- make exact hosts win over overlapping wildcards in generated routes and TLS policies
- add unit coverage for host-pattern priority and wildcard domain handling
- add a single test:all entry point and clean up lint/typecheck issues so the suite runs cleanly
- run mobile layout Playwright checks under both chromium and mobile-iphone
This commit is contained in:
fuomag9
2026-03-14 01:02:57 +01:00
parent 94d17c6d2a
commit 73c90894b1
33 changed files with 671 additions and 249 deletions

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 = {

View File

@@ -4,7 +4,6 @@ import {
Box,
Button,
Card,
CardContent,
Chip,
Collapse,
IconButton,

View File

@@ -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";

View File

@@ -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
<Chip
label={getProviderName(user.provider)}
size="small"
color={getProviderColor(user.provider) as any}
color={getProviderColor(user.provider)}
/>
</Box>

View File

@@ -427,6 +427,7 @@ export async function createProxyHostAction(
_prevState: ActionState = INITIAL_ACTION_STATE,
formData: FormData
): Promise<ActionState> {
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<ActionState> {
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<ActionState> {
void _prevState;
try {
const session = await requireAdmin();
const userId = Number(session.user.id);

View File

@@ -600,6 +600,8 @@ export async function updateGeoBlockSettingsAction(_prevState: ActionResult | nu
}
export async function syncSlaveInstancesAction(_prevState: ActionResult | null, _formData: FormData): Promise<ActionResult> {
void _prevState;
void _formData;
try {
await requireAdmin();
const mode = await getInstanceMode();
@@ -661,7 +663,7 @@ export async function suppressWafRuleGloballyAction(ruleId: number): Promise<Act
await saveWafSettings({ ...base, excluded_rule_ids: ids });
try {
await applyCaddyConfig();
} catch (err) {
} catch {
revalidatePath("/settings");
return { success: true, message: `Rule ${ruleId} added to exclusions. Warning: could not reload Caddy.` };
}

View File

@@ -1,4 +1,4 @@
import { fileURLToPath } from 'node:url';
/* global process */
/** @type {import('next').NextConfig} */
const nextConfig = {

View File

@@ -9,6 +9,7 @@
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test:all": "./scripts/test-all.sh",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"test": "vitest run --config tests/vitest.config.ts",

43
scripts/test-all.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/bin/sh
run_step() {
label="$1"
shift
printf '\n==> %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

View File

@@ -1,3 +1,5 @@
/* global document */
const yearEl = document.getElementById('year');
if (yearEl) {
yearEl.textContent = new Date().getFullYear();

View File

@@ -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

View File

@@ -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({
</Box>
<Switch
name={setting.name}
checked={values[setting.name as keyof typeof values] as boolean}
onChange={handleChange(setting.name as keyof typeof values)}
checked={values[setting.name]}
onChange={handleChange(setting.name)}
size="small"
color={setting.color as any}
color={setting.color}
/>
</Stack>
</Box>

View File

@@ -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";

View File

@@ -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";

View File

@@ -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<Theme>;
};
const STATUS_CONFIG: Record<StatusType, { color: string; label: string }> = {

View File

@@ -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<any>[] {
const providers: OAuthConfig<any>[] = [];
function createOAuthProviders(): OAuthConfig<Record<string, unknown>>[] {
const providers: OAuthConfig<Record<string, unknown>>[] = [];
if (
config.oauth.enabled &&
config.oauth.clientId &&
config.oauth.clientSecret
) {
const oauthProvider: OAuthConfig<any> = {
const oauthProvider: OAuthConfig<Record<string, unknown>> = {
id: "oauth2",
name: config.oauth.providerName,
type: "oidc",
@@ -93,11 +91,20 @@ function createOAuthProviders(): OAuthConfig<any>[] {
// 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

View File

@@ -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<string | null> {
// 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;
}

View File

@@ -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<string[]>(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<string, unknown>[] = [...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<string, unknown>[] = [...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<Record<string, unknown>> | undefined) ?? [];
hostRoutes.push({
...outpostRoute,
match: outpostMatches.map((match) => ({
...match,
host: domainGroup
}))
});
}
const unprotectedHandlers: Record<string, unknown>[] = [...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<string, unknown>[] = [...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<Record<string, unknown>> | undefined) ?? [];
hostRoutes.push({
...outpostRoute,
match: outpostMatches.map((match) => ({
...match,
host: domainGroup
}))
});
}
const routeHandlers: Record<string, unknown>[] = [...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<string, unknown> = {
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<string, string> = {
name: "cloudflare",
api_token: cloudflare.apiToken
for (const subjects of groupHostPatternsByPriority(Array.from(autoManagedDomains))) {
const issuer: Record<string, unknown> = {
module: "acme"
};
const dnsChallenge: Record<string, unknown> = {
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<string, string> = {
name: "cloudflare",
api_token: cloudflare.apiToken
};
policies.push({
subjects,
issuers: [issuer]
});
const dnsChallenge: Record<string, unknown> = {
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<string, unknown> = {
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<string, string> = {
name: "cloudflare",
api_token: cloudflare.apiToken
for (const subjectGroup of groupHostPatternsByPriority(subjects)) {
const issuer: Record<string, unknown> = {
module: "acme"
};
const dnsChallenge: Record<string, unknown> = {
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<string, string> = {
name: "cloudflare",
api_token: cloudflare.apiToken
};
policies.push({
subjects,
issuers: [issuer]
});
const dnsChallenge: Record<string, unknown> = {
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

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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<T extends RouteLike>(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<T extends TlsPolicyLike>(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<T extends AutomationPolicyLike>(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;
});
}

View File

@@ -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<ProxyHostInput>
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,

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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

View File

@@ -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';

View File

@@ -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);

View File

@@ -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<typeof proxyHosts.$inferInsert> = {
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);
});

View File

@@ -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"]);
});
});

View File

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