fix: preserve backward-compatible router names while fixing path collisions

Use encodePath only for internal map key grouping (collision-free) and
sanitize for Traefik-facing router/service names (unchanged for existing
users). Extract pure functions into pathUtils.ts so tests can run without
DB dependencies.
This commit is contained in:
Shreyas
2026-02-23 12:31:30 +05:30
committed by Shreyas Papinwar
parent 5f18c06e03
commit e58f0c9f07
5 changed files with 165 additions and 54 deletions

View File

@@ -127,25 +127,42 @@ export async function getTraefikConfig(
resourcesWithTargetsAndSites.forEach((row) => { resourcesWithTargetsAndSites.forEach((row) => {
const resourceId = row.resourceId; const resourceId = row.resourceId;
const resourceName = sanitize(row.resourceName) || ""; const resourceName = sanitize(row.resourceName) || "";
const targetPath = encodePath(row.path); // Encode path preserving uniqueness for key generation
const pathMatchType = row.pathMatchType || ""; const pathMatchType = row.pathMatchType || "";
const rewritePath = row.rewritePath || ""; const rewritePath = row.rewritePath || "";
const rewritePathType = row.rewritePathType || ""; const rewritePathType = row.rewritePathType || "";
const priority = row.priority ?? 100; const priority = row.priority ?? 100;
// Create a unique key combining resourceId, path config, and rewrite config // Use encodePath for the internal map key to avoid collisions
const pathKey = [ // (e.g. "/a/b" vs "/a-b" must map to different groups)
targetPath, const encodedPath = encodePath(row.path);
const internalPathKey = [
encodedPath,
pathMatchType, pathMatchType,
rewritePath, rewritePath,
rewritePathType rewritePathType
] ]
.filter(Boolean) .filter(Boolean)
.join("-"); .join("-");
const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); const internalMapKey = [resourceId, internalPathKey]
const key = sanitize(mapKey); .filter(Boolean)
.join("-");
if (!resourcesMap.has(key)) { // Use sanitize for the Traefik-facing key to preserve backward-compatible
// router/service names (existing sticky session cookies, etc.)
const sanitizedPath = sanitize(row.path) || "";
const traefikPathKey = [
sanitizedPath,
pathMatchType,
rewritePath,
rewritePathType
]
.filter(Boolean)
.join("-");
const traefikKey = sanitize(
[resourceId, traefikPathKey].filter(Boolean).join("-")
);
if (!resourcesMap.has(internalMapKey)) {
const validation = validatePathRewriteConfig( const validation = validatePathRewriteConfig(
row.path, row.path,
row.pathMatchType, row.pathMatchType,
@@ -160,9 +177,10 @@ export async function getTraefikConfig(
return; return;
} }
resourcesMap.set(key, { resourcesMap.set(internalMapKey, {
resourceId: row.resourceId, resourceId: row.resourceId,
name: resourceName, name: resourceName,
traefikKey: traefikKey, // backward-compatible key for Traefik names
fullDomain: row.fullDomain, fullDomain: row.fullDomain,
ssl: row.ssl, ssl: row.ssl,
http: row.http, http: row.http,
@@ -190,7 +208,7 @@ export async function getTraefikConfig(
}); });
} }
resourcesMap.get(key).targets.push({ resourcesMap.get(internalMapKey).targets.push({
resourceId: row.resourceId, resourceId: row.resourceId,
targetId: row.targetId, targetId: row.targetId,
ip: row.ip, ip: row.ip,
@@ -227,8 +245,9 @@ export async function getTraefikConfig(
}; };
// get the key and the resource // get the key and the resource
for (const [key, resource] of resourcesMap.entries()) { for (const [, resource] of resourcesMap.entries()) {
const targets = resource.targets as TargetWithSite[]; const targets = resource.targets as TargetWithSite[];
const key = resource.traefikKey; // backward-compatible key for Traefik names
const routerName = `${key}-${resource.name}-router`; const routerName = `${key}-${resource.name}-router`;
const serviceName = `${key}-${resource.name}-service`; const serviceName = `${key}-${resource.name}-service`;

View File

@@ -1,5 +1,5 @@
import { assertEquals } from "@test/assert"; import { assertEquals } from "../../../test/assert";
import { encodePath, sanitize } from "./utils"; import { encodePath, sanitize } from "./pathUtils";
function runTests() { function runTests() {
console.log("Running path encoding tests...\n"); console.log("Running path encoding tests...\n");
@@ -159,6 +159,72 @@ function runTests() {
console.log(" PASS: encoded values are alphanumeric"); console.log(" PASS: encoded values are alphanumeric");
} }
// Test 9: backward compatibility - Traefik names use sanitize, internal keys use encodePath
{
// Simulate the dual-key approach from getTraefikConfig
function makeKeys(
resourceId: number,
path: string | null,
pathMatchType: string | null
) {
// Internal map key (collision-free grouping)
const encodedPath = encodePath(path);
const internalPathKey = [encodedPath, pathMatchType || ""]
.filter(Boolean)
.join("-");
const internalMapKey = [resourceId, internalPathKey]
.filter(Boolean)
.join("-");
// Traefik-facing key (backward-compatible naming)
const sanitizedPath = sanitize(path) || "";
const traefikPathKey = [sanitizedPath, pathMatchType || ""]
.filter(Boolean)
.join("-");
const traefikKey = sanitize(
[resourceId, traefikPathKey].filter(Boolean).join("-")
);
return { internalMapKey, traefikKey };
}
// /a/b and /a-b should have DIFFERENT internal keys (no collision)
const keysAB = makeKeys(1, "/a/b", "prefix");
const keysDash = makeKeys(1, "/a-b", "prefix");
assertEquals(
keysAB.internalMapKey !== keysDash.internalMapKey,
true,
"/a/b and /a-b must have different internal map keys"
);
// /a/b and /a-b produce the SAME Traefik key (backward compat — sanitize maps both to "a-b")
assertEquals(
keysAB.traefikKey,
keysDash.traefikKey,
"/a/b and /a-b should produce same Traefik key (sanitize behavior)"
);
// For a resource with no path, Traefik key should match the old behavior
const keysNoPath = makeKeys(42, null, null);
assertEquals(
keysNoPath.traefikKey,
"42",
"no-path resource should have resourceId-only Traefik key"
);
// Traefik key for /api prefix should match old sanitize-based output
const keysApi = makeKeys(1, "/api", "prefix");
assertEquals(
keysApi.traefikKey,
"1-api-prefix",
"Traefik key should match old sanitize-based format"
);
console.log(
" PASS: backward compatibility - internal keys differ, Traefik names preserved"
);
}
console.log("\nAll path encoding tests passed!"); console.log("\nAll path encoding tests passed!");
} }

View File

@@ -0,0 +1,37 @@
/**
* Pure utility functions for path/name encoding.
* No external dependencies — safe to import in tests.
*/
export function sanitize(input: string | null | undefined): string | undefined {
if (!input) return undefined;
// clean any non alphanumeric characters from the input and replace with dashes
// the input cant be too long either, so limit to 50 characters
if (input.length > 50) {
input = input.substring(0, 50);
}
return input
.replace(/[^a-zA-Z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Encode a URL path into a collision-free alphanumeric string suitable for use
* in Traefik map keys.
*
* Unlike sanitize(), this preserves uniqueness by encoding each non-alphanumeric
* character as its hex code. Different paths always produce different outputs.
*
* encodePath("/api") => "2fapi"
* encodePath("/a/b") => "2fa2fb"
* encodePath("/a-b") => "2fa2db" (different from /a/b)
* encodePath("/") => "2f"
* encodePath(null) => ""
*/
export function encodePath(path: string | null | undefined): string {
if (!path) return "";
return path.replace(/[^a-zA-Z0-9]/g, (ch) => {
return ch.charCodeAt(0).toString(16);
});
}

View File

@@ -1,37 +1,7 @@
import logger from "@server/logger"; import logger from "@server/logger";
export function sanitize(input: string | null | undefined): string | undefined { // Re-export pure functions from dependency-free module
if (!input) return undefined; export { sanitize, encodePath } from "./pathUtils";
// clean any non alphanumeric characters from the input and replace with dashes
// the input cant be too long either, so limit to 50 characters
if (input.length > 50) {
input = input.substring(0, 50);
}
return input
.replace(/[^a-zA-Z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Encode a URL path into a collision-free alphanumeric string suitable for use
* in Traefik router/service names and map keys.
*
* Unlike sanitize(), this preserves uniqueness by encoding each non-alphanumeric
* character as its hex code. Different paths always produce different outputs.
*
* encodePath("/api") => "2fapi"
* encodePath("/a/b") => "2fa2fb"
* encodePath("/a-b") => "2fa2db" (different from /a/b)
* encodePath("/") => "2f"
* encodePath(null) => ""
*/
export function encodePath(path: string | null | undefined): string {
if (!path) return "";
return path.replace(/[^a-zA-Z0-9]/g, (ch) => {
return ch.charCodeAt(0).toString(16);
});
}
export function validatePathRewriteConfig( export function validatePathRewriteConfig(
path: string | null, path: string | null,

View File

@@ -174,7 +174,6 @@ export async function getTraefikConfig(
resourcesWithTargetsAndSites.forEach((row) => { resourcesWithTargetsAndSites.forEach((row) => {
const resourceId = row.resourceId; const resourceId = row.resourceId;
const resourceName = sanitize(row.resourceName) || ""; const resourceName = sanitize(row.resourceName) || "";
const targetPath = encodePath(row.path); // Encode path preserving uniqueness for key generation
const pathMatchType = row.pathMatchType || ""; const pathMatchType = row.pathMatchType || "";
const rewritePath = row.rewritePath || ""; const rewritePath = row.rewritePath || "";
const rewritePathType = row.rewritePathType || ""; const rewritePathType = row.rewritePathType || "";
@@ -184,19 +183,37 @@ export async function getTraefikConfig(
return; return;
} }
// Create a unique key combining resourceId, path config, and rewrite config // Use encodePath for the internal map key to avoid collisions
const pathKey = [ // (e.g. "/a/b" vs "/a-b" must map to different groups)
targetPath, const encodedPath = encodePath(row.path);
const internalPathKey = [
encodedPath,
pathMatchType, pathMatchType,
rewritePath, rewritePath,
rewritePathType rewritePathType
] ]
.filter(Boolean) .filter(Boolean)
.join("-"); .join("-");
const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); const internalMapKey = [resourceId, internalPathKey]
const key = sanitize(mapKey); .filter(Boolean)
.join("-");
if (!resourcesMap.has(key)) { // Use sanitize for the Traefik-facing key to preserve backward-compatible
// router/service names (existing sticky session cookies, etc.)
const sanitizedPath = sanitize(row.path) || "";
const traefikPathKey = [
sanitizedPath,
pathMatchType,
rewritePath,
rewritePathType
]
.filter(Boolean)
.join("-");
const traefikKey = sanitize(
[resourceId, traefikPathKey].filter(Boolean).join("-")
);
if (!resourcesMap.has(internalMapKey)) {
const validation = validatePathRewriteConfig( const validation = validatePathRewriteConfig(
row.path, row.path,
row.pathMatchType, row.pathMatchType,
@@ -211,9 +228,10 @@ export async function getTraefikConfig(
return; return;
} }
resourcesMap.set(key, { resourcesMap.set(internalMapKey, {
resourceId: row.resourceId, resourceId: row.resourceId,
name: resourceName, name: resourceName,
traefikKey: traefikKey, // backward-compatible key for Traefik names
fullDomain: row.fullDomain, fullDomain: row.fullDomain,
ssl: row.ssl, ssl: row.ssl,
http: row.http, http: row.http,
@@ -247,7 +265,7 @@ export async function getTraefikConfig(
} }
// Add target with its associated site data // Add target with its associated site data
resourcesMap.get(key).targets.push({ resourcesMap.get(internalMapKey).targets.push({
resourceId: row.resourceId, resourceId: row.resourceId,
targetId: row.targetId, targetId: row.targetId,
ip: row.ip, ip: row.ip,
@@ -300,8 +318,9 @@ export async function getTraefikConfig(
}; };
// get the key and the resource // get the key and the resource
for (const [key, resource] of resourcesMap.entries()) { for (const [, resource] of resourcesMap.entries()) {
const targets = resource.targets as TargetWithSite[]; const targets = resource.targets as TargetWithSite[];
const key = resource.traefikKey; // backward-compatible key for Traefik names
const routerName = `${key}-${resource.name}-router`; const routerName = `${key}-${resource.name}-router`;
const serviceName = `${key}-${resource.name}-service`; const serviceName = `${key}-${resource.name}-service`;