mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-09 20:26:40 +00:00
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:
committed by
Shreyas Papinwar
parent
5f18c06e03
commit
e58f0c9f07
@@ -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`;
|
||||||
|
|||||||
@@ -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!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
37
server/lib/traefik/pathUtils.ts
Normal file
37
server/lib/traefik/pathUtils.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
Reference in New Issue
Block a user