Files
caddy-proxy-manager/src/lib/host-pattern-priority.ts
fuomag9 73c90894b1 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
2026-03-14 01:03:34 +01:00

188 lines
5.0 KiB
TypeScript

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