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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Collapse,
|
||||
IconButton,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.` };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
/* global process */
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
|
||||
@@ -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
43
scripts/test-all.sh
Executable 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
|
||||
@@ -1,3 +1,5 @@
|
||||
/* global document */
|
||||
|
||||
const yearEl = document.getElementById('year');
|
||||
if (yearEl) {
|
||||
yearEl.textContent = new Date().getFullYear();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 }> = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
316
src/lib/caddy.ts
316
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<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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
187
src/lib/host-pattern-priority.ts
Normal file
187
src/lib/host-pattern-priority.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
52
src/lib/proxy-host-domains.ts
Normal file
52
src/lib/proxy-host-domains.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
78
tests/unit/host-pattern-priority.test.ts
Normal file
78
tests/unit/host-pattern-priority.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
39
tests/unit/proxy-host-domains.test.ts
Normal file
39
tests/unit/proxy-host-domains.test.ts
Normal 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".'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user