mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-13 06:06:39 +00:00
Merge branch 'dev' into jit
This commit is contained in:
@@ -328,6 +328,14 @@ export const approvals = pgTable("approvals", {
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const bannedEmails = pgTable("bannedEmails", {
|
||||
email: varchar("email", { length: 255 }).primaryKey(),
|
||||
});
|
||||
|
||||
export const bannedIps = pgTable("bannedIps", {
|
||||
ip: varchar("ip", { length: 255 }).primaryKey(),
|
||||
});
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
|
||||
@@ -22,7 +22,8 @@ export const domains = pgTable("domains", {
|
||||
tries: integer("tries").notNull().default(0),
|
||||
certResolver: varchar("certResolver"),
|
||||
customCertResolver: varchar("customCertResolver"),
|
||||
preferWildcardCert: boolean("preferWildcardCert")
|
||||
preferWildcardCert: boolean("preferWildcardCert"),
|
||||
errorMessage: text("errorMessage")
|
||||
});
|
||||
|
||||
export const dnsRecords = pgTable("dnsRecords", {
|
||||
|
||||
@@ -318,6 +318,15 @@ export const approvals = sqliteTable("approvals", {
|
||||
.notNull()
|
||||
});
|
||||
|
||||
|
||||
export const bannedEmails = sqliteTable("bannedEmails", {
|
||||
email: text("email").primaryKey()
|
||||
});
|
||||
|
||||
export const bannedIps = sqliteTable("bannedIps", {
|
||||
ip: text("ip").primaryKey()
|
||||
});
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
|
||||
@@ -13,7 +13,8 @@ export const domains = sqliteTable("domains", {
|
||||
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
||||
tries: integer("tries").notNull().default(0),
|
||||
certResolver: text("certResolver"),
|
||||
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" })
|
||||
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
||||
errorMessage: text("errorMessage")
|
||||
});
|
||||
|
||||
export const dnsRecords = sqliteTable("dnsRecords", {
|
||||
|
||||
@@ -85,9 +85,7 @@ export async function deleteOrgById(
|
||||
deletedNewtIds.push(deletedNewt.newtId);
|
||||
await trx
|
||||
.delete(newtSessions)
|
||||
.where(
|
||||
eq(newtSessions.newtId, deletedNewt.newtId)
|
||||
);
|
||||
.where(eq(newtSessions.newtId, deletedNewt.newtId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,33 +119,38 @@ export async function deleteOrgById(
|
||||
eq(clientSitesAssociationsCache.clientId, client.clientId)
|
||||
);
|
||||
}
|
||||
|
||||
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
||||
|
||||
const allOrgDomains = await trx
|
||||
.select()
|
||||
.from(orgDomains)
|
||||
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
||||
.innerJoin(domains, eq(orgDomains.domainId, domains.domainId))
|
||||
.where(
|
||||
and(
|
||||
eq(orgDomains.orgId, orgId),
|
||||
eq(domains.configManaged, false)
|
||||
)
|
||||
);
|
||||
logger.info(`Found ${allOrgDomains.length} domains to delete`);
|
||||
const domainIdsToDelete: string[] = [];
|
||||
for (const orgDomain of allOrgDomains) {
|
||||
const domainId = orgDomain.domains.domainId;
|
||||
const orgCount = await trx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
const [orgCount] = await trx
|
||||
.select({ count: count() })
|
||||
.from(orgDomains)
|
||||
.where(eq(orgDomains.domainId, domainId));
|
||||
if (orgCount[0].count === 1) {
|
||||
logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`);
|
||||
if (orgCount.count === 1) {
|
||||
domainIdsToDelete.push(domainId);
|
||||
}
|
||||
}
|
||||
logger.info(`Found ${domainIdsToDelete.length} domains to delete`);
|
||||
if (domainIdsToDelete.length > 0) {
|
||||
await trx
|
||||
.delete(domains)
|
||||
.where(inArray(domains.domainId, domainIdsToDelete));
|
||||
}
|
||||
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
||||
|
||||
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
|
||||
|
||||
@@ -231,15 +234,13 @@ export function sendTerminationMessages(result: DeleteOrgByIdResult): void {
|
||||
);
|
||||
}
|
||||
for (const olmId of result.olmsToTerminate) {
|
||||
sendTerminateClient(
|
||||
0,
|
||||
OlmErrorCodes.TERMINATED_REKEYED,
|
||||
olmId
|
||||
).catch((error) => {
|
||||
logger.error(
|
||||
"Failed to send termination message to olm:",
|
||||
error
|
||||
);
|
||||
});
|
||||
sendTerminateClient(0, OlmErrorCodes.TERMINATED_REKEYED, olmId).catch(
|
||||
(error) => {
|
||||
logger.error(
|
||||
"Failed to send termination message to olm:",
|
||||
error
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,7 +571,7 @@ export async function updateClientSiteDestinations(
|
||||
destinations: [
|
||||
{
|
||||
destinationIP: site.sites.subnet.split("/")[0],
|
||||
destinationPort: site.sites.listenPort || 0
|
||||
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -579,7 +579,7 @@ export async function updateClientSiteDestinations(
|
||||
// add to the existing destinations
|
||||
destinations.destinations.push({
|
||||
destinationIP: site.sites.subnet.split("/")[0],
|
||||
destinationPort: site.sites.listenPort || 0
|
||||
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -218,10 +218,11 @@ export class TraefikConfigManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fetch if it's been more than 24 hours (for renewals)
|
||||
const dayInMs = 24 * 60 * 60 * 1000;
|
||||
const timeSinceLastFetch =
|
||||
Date.now() - this.lastCertificateFetch.getTime();
|
||||
|
||||
// Fetch if it's been more than 24 hours (daily routine check)
|
||||
if (timeSinceLastFetch > dayInMs) {
|
||||
logger.info("Fetching certificates due to 24-hour renewal check");
|
||||
return true;
|
||||
@@ -265,7 +266,7 @@ export class TraefikConfigManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any local certificates are missing or appear to be outdated
|
||||
// Check if any local certificates are missing (needs immediate fetch)
|
||||
for (const domain of domainsNeedingCerts) {
|
||||
const localState = this.lastLocalCertificateState.get(domain);
|
||||
if (!localState || !localState.exists) {
|
||||
@@ -274,17 +275,55 @@ export class TraefikConfigManager {
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if certificate is expiring soon (within 30 days)
|
||||
if (localState.expiresAt) {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
const secondsUntilExpiry = localState.expiresAt - nowInSeconds;
|
||||
const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24);
|
||||
if (daysUntilExpiry < 30) {
|
||||
logger.info(
|
||||
`Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)`
|
||||
);
|
||||
return true;
|
||||
// For expiry checks, throttle to every 6 hours to avoid querying the
|
||||
// API/DB on every monitor loop. The certificate-service renews certs
|
||||
// 45 days before expiry, so checking every 6 hours is plenty frequent
|
||||
// to pick up renewed certs promptly.
|
||||
const renewalCheckIntervalMs = 6 * 60 * 60 * 1000; // 6 hours
|
||||
if (timeSinceLastFetch > renewalCheckIntervalMs) {
|
||||
// Check non-wildcard certs for expiry (within 45 days to match
|
||||
// the server-side renewal window in certificate-service)
|
||||
for (const domain of domainsNeedingCerts) {
|
||||
const localState =
|
||||
this.lastLocalCertificateState.get(domain);
|
||||
if (localState?.expiresAt) {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
const secondsUntilExpiry =
|
||||
localState.expiresAt - nowInSeconds;
|
||||
const daysUntilExpiry =
|
||||
secondsUntilExpiry / (60 * 60 * 24);
|
||||
if (daysUntilExpiry < 45) {
|
||||
logger.info(
|
||||
`Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check wildcard certificates for expiry. These are not
|
||||
// included in domainsNeedingCerts since their subdomains are
|
||||
// filtered out, so we must check them separately.
|
||||
for (const [certDomain, state] of this
|
||||
.lastLocalCertificateState) {
|
||||
if (
|
||||
state.exists &&
|
||||
state.wildcard &&
|
||||
state.expiresAt
|
||||
) {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
const secondsUntilExpiry =
|
||||
state.expiresAt - nowInSeconds;
|
||||
const daysUntilExpiry =
|
||||
secondsUntilExpiry / (60 * 60 * 24);
|
||||
if (daysUntilExpiry < 45) {
|
||||
logger.info(
|
||||
`Fetching certificates due to upcoming expiry for wildcard cert ${certDomain} (${Math.round(daysUntilExpiry)} days remaining)`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -361,6 +400,32 @@ export class TraefikConfigManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Also include wildcard cert base domains that are
|
||||
// expiring or expired so they get re-fetched even though
|
||||
// their subdomains were filtered out above.
|
||||
for (const [certDomain, state] of this
|
||||
.lastLocalCertificateState) {
|
||||
if (
|
||||
state.exists &&
|
||||
state.wildcard &&
|
||||
state.expiresAt
|
||||
) {
|
||||
const nowInSeconds = Math.floor(
|
||||
Date.now() / 1000
|
||||
);
|
||||
const secondsUntilExpiry =
|
||||
state.expiresAt - nowInSeconds;
|
||||
const daysUntilExpiry =
|
||||
secondsUntilExpiry / (60 * 60 * 24);
|
||||
if (daysUntilExpiry < 45) {
|
||||
domainsToFetch.add(certDomain);
|
||||
logger.info(
|
||||
`Including expiring wildcard cert domain ${certDomain} in fetch (${Math.round(daysUntilExpiry)} days remaining)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (domainsToFetch.size > 0) {
|
||||
// Get valid certificates for domains not covered by wildcards
|
||||
validCertificates =
|
||||
|
||||
@@ -14,7 +14,7 @@ import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { resources, sites, Target, targets } from "@server/db";
|
||||
import createPathRewriteMiddleware from "./middleware";
|
||||
import { sanitize, validatePathRewriteConfig } from "./utils";
|
||||
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
|
||||
|
||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||
const badgerMiddlewareName = "badger";
|
||||
@@ -44,7 +44,7 @@ export async function getTraefikConfig(
|
||||
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
||||
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
||||
allowRawResources = true,
|
||||
allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE
|
||||
allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE
|
||||
): Promise<any> {
|
||||
// Get resources with their targets and sites in a single optimized query
|
||||
// Start from sites on this exit node, then join to targets and resources
|
||||
@@ -127,7 +127,7 @@ export async function getTraefikConfig(
|
||||
resourcesWithTargetsAndSites.forEach((row) => {
|
||||
const resourceId = row.resourceId;
|
||||
const resourceName = sanitize(row.resourceName) || "";
|
||||
const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths
|
||||
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
||||
const pathMatchType = row.pathMatchType || "";
|
||||
const rewritePath = row.rewritePath || "";
|
||||
const rewritePathType = row.rewritePathType || "";
|
||||
@@ -145,7 +145,7 @@ export async function getTraefikConfig(
|
||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||
const key = sanitize(mapKey);
|
||||
|
||||
if (!resourcesMap.has(key)) {
|
||||
if (!resourcesMap.has(mapKey)) {
|
||||
const validation = validatePathRewriteConfig(
|
||||
row.path,
|
||||
row.pathMatchType,
|
||||
@@ -160,9 +160,10 @@ export async function getTraefikConfig(
|
||||
return;
|
||||
}
|
||||
|
||||
resourcesMap.set(key, {
|
||||
resourcesMap.set(mapKey, {
|
||||
resourceId: row.resourceId,
|
||||
name: resourceName,
|
||||
key: key,
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
http: row.http,
|
||||
@@ -190,7 +191,7 @@ export async function getTraefikConfig(
|
||||
});
|
||||
}
|
||||
|
||||
resourcesMap.get(key).targets.push({
|
||||
resourcesMap.get(mapKey).targets.push({
|
||||
resourceId: row.resourceId,
|
||||
targetId: row.targetId,
|
||||
ip: row.ip,
|
||||
@@ -227,8 +228,9 @@ export async function getTraefikConfig(
|
||||
};
|
||||
|
||||
// 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 key = resource.key;
|
||||
|
||||
const routerName = `${key}-${resource.name}-router`;
|
||||
const serviceName = `${key}-${resource.name}-service`;
|
||||
|
||||
323
server/lib/traefik/pathEncoding.test.ts
Normal file
323
server/lib/traefik/pathEncoding.test.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { assertEquals } from "../../../test/assert";
|
||||
|
||||
// ── Pure function copies (inlined to avoid pulling in server dependencies) ──
|
||||
|
||||
function sanitize(input: string | null | undefined): string | undefined {
|
||||
if (!input) return undefined;
|
||||
if (input.length > 50) {
|
||||
input = input.substring(0, 50);
|
||||
}
|
||||
return input
|
||||
.replace(/[^a-zA-Z0-9-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Exact replica of the OLD key computation from upstream main.
|
||||
* Uses sanitize() for paths — this is what had the collision bug.
|
||||
*/
|
||||
function oldKeyComputation(
|
||||
resourceId: number,
|
||||
path: string | null,
|
||||
pathMatchType: string | null,
|
||||
rewritePath: string | null,
|
||||
rewritePathType: string | null
|
||||
): string {
|
||||
const targetPath = sanitize(path) || "";
|
||||
const pmt = pathMatchType || "";
|
||||
const rp = rewritePath || "";
|
||||
const rpt = rewritePathType || "";
|
||||
const pathKey = [targetPath, pmt, rp, rpt].filter(Boolean).join("-");
|
||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||
return sanitize(mapKey) || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Replica of the NEW key computation from our fix.
|
||||
* Uses encodePath() for paths — collision-free.
|
||||
*/
|
||||
function newKeyComputation(
|
||||
resourceId: number,
|
||||
path: string | null,
|
||||
pathMatchType: string | null,
|
||||
rewritePath: string | null,
|
||||
rewritePathType: string | null
|
||||
): string {
|
||||
const targetPath = encodePath(path);
|
||||
const pmt = pathMatchType || "";
|
||||
const rp = rewritePath || "";
|
||||
const rpt = rewritePathType || "";
|
||||
const pathKey = [targetPath, pmt, rp, rpt].filter(Boolean).join("-");
|
||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||
return sanitize(mapKey) || "";
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
function runTests() {
|
||||
console.log("Running path encoding tests...\n");
|
||||
|
||||
let passed = 0;
|
||||
|
||||
// ── encodePath unit tests ────────────────────────────────────────
|
||||
|
||||
// Test 1: null/undefined/empty
|
||||
{
|
||||
assertEquals(encodePath(null), "", "null should return empty");
|
||||
assertEquals(
|
||||
encodePath(undefined),
|
||||
"",
|
||||
"undefined should return empty"
|
||||
);
|
||||
assertEquals(encodePath(""), "", "empty string should return empty");
|
||||
console.log(" PASS: encodePath handles null/undefined/empty");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 2: root path
|
||||
{
|
||||
assertEquals(encodePath("/"), "2f", "/ should encode to 2f");
|
||||
console.log(" PASS: encodePath encodes root path");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 3: alphanumeric passthrough
|
||||
{
|
||||
assertEquals(encodePath("/api"), "2fapi", "/api encodes slash only");
|
||||
assertEquals(encodePath("/v1"), "2fv1", "/v1 encodes slash only");
|
||||
assertEquals(encodePath("abc"), "abc", "plain alpha passes through");
|
||||
console.log(" PASS: encodePath preserves alphanumeric chars");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 4: all special chars produce unique hex
|
||||
{
|
||||
const paths = ["/a/b", "/a-b", "/a.b", "/a_b", "/a b"];
|
||||
const results = paths.map((p) => encodePath(p));
|
||||
const unique = new Set(results);
|
||||
assertEquals(
|
||||
unique.size,
|
||||
paths.length,
|
||||
"all special-char paths must produce unique encodings"
|
||||
);
|
||||
console.log(
|
||||
" PASS: encodePath produces unique output for different special chars"
|
||||
);
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 5: output is always alphanumeric (safe for Traefik names)
|
||||
{
|
||||
const paths = [
|
||||
"/",
|
||||
"/api",
|
||||
"/a/b",
|
||||
"/a-b",
|
||||
"/a.b",
|
||||
"/complex/path/here"
|
||||
];
|
||||
for (const p of paths) {
|
||||
const e = encodePath(p);
|
||||
assertEquals(
|
||||
/^[a-zA-Z0-9]+$/.test(e),
|
||||
true,
|
||||
`encodePath("${p}") = "${e}" must be alphanumeric`
|
||||
);
|
||||
}
|
||||
console.log(" PASS: encodePath output is always alphanumeric");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 6: deterministic
|
||||
{
|
||||
assertEquals(
|
||||
encodePath("/api"),
|
||||
encodePath("/api"),
|
||||
"same input same output"
|
||||
);
|
||||
assertEquals(
|
||||
encodePath("/a/b/c"),
|
||||
encodePath("/a/b/c"),
|
||||
"same input same output"
|
||||
);
|
||||
console.log(" PASS: encodePath is deterministic");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 7: many distinct paths never collide
|
||||
{
|
||||
const paths = [
|
||||
"/",
|
||||
"/api",
|
||||
"/api/v1",
|
||||
"/api/v2",
|
||||
"/a/b",
|
||||
"/a-b",
|
||||
"/a.b",
|
||||
"/a_b",
|
||||
"/health",
|
||||
"/health/check",
|
||||
"/admin",
|
||||
"/admin/users",
|
||||
"/api/v1/users",
|
||||
"/api/v1/posts",
|
||||
"/app",
|
||||
"/app/dashboard"
|
||||
];
|
||||
const encoded = new Set(paths.map((p) => encodePath(p)));
|
||||
assertEquals(
|
||||
encoded.size,
|
||||
paths.length,
|
||||
`expected ${paths.length} unique encodings, got ${encoded.size}`
|
||||
);
|
||||
console.log(" PASS: 16 realistic paths all produce unique encodings");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// ── Collision fix: the actual bug we're fixing ───────────────────
|
||||
|
||||
// Test 8: /a/b and /a-b now have different keys (THE BUG FIX)
|
||||
{
|
||||
const keyAB = newKeyComputation(1, "/a/b", "prefix", null, null);
|
||||
const keyDash = newKeyComputation(1, "/a-b", "prefix", null, null);
|
||||
assertEquals(
|
||||
keyAB !== keyDash,
|
||||
true,
|
||||
"/a/b and /a-b MUST have different keys"
|
||||
);
|
||||
console.log(" PASS: collision fix — /a/b vs /a-b have different keys");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 9: demonstrate the old bug — old code maps /a/b and /a-b to same key
|
||||
{
|
||||
const oldKeyAB = oldKeyComputation(1, "/a/b", "prefix", null, null);
|
||||
const oldKeyDash = oldKeyComputation(1, "/a-b", "prefix", null, null);
|
||||
assertEquals(
|
||||
oldKeyAB,
|
||||
oldKeyDash,
|
||||
"old code MUST have this collision (confirms the bug exists)"
|
||||
);
|
||||
console.log(" PASS: confirmed old code bug — /a/b and /a-b collided");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 10: /api/v1 and /api-v1 — old code collision, new code fixes it
|
||||
{
|
||||
const oldKey1 = oldKeyComputation(1, "/api/v1", "prefix", null, null);
|
||||
const oldKey2 = oldKeyComputation(1, "/api-v1", "prefix", null, null);
|
||||
assertEquals(
|
||||
oldKey1,
|
||||
oldKey2,
|
||||
"old code collision for /api/v1 vs /api-v1"
|
||||
);
|
||||
|
||||
const newKey1 = newKeyComputation(1, "/api/v1", "prefix", null, null);
|
||||
const newKey2 = newKeyComputation(1, "/api-v1", "prefix", null, null);
|
||||
assertEquals(
|
||||
newKey1 !== newKey2,
|
||||
true,
|
||||
"new code must separate /api/v1 and /api-v1"
|
||||
);
|
||||
console.log(" PASS: collision fix — /api/v1 vs /api-v1");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 11: /app.v2 and /app/v2 and /app-v2 — three-way collision fixed
|
||||
{
|
||||
const a = newKeyComputation(1, "/app.v2", "prefix", null, null);
|
||||
const b = newKeyComputation(1, "/app/v2", "prefix", null, null);
|
||||
const c = newKeyComputation(1, "/app-v2", "prefix", null, null);
|
||||
const keys = new Set([a, b, c]);
|
||||
assertEquals(
|
||||
keys.size,
|
||||
3,
|
||||
"three paths must produce three unique keys"
|
||||
);
|
||||
console.log(
|
||||
" PASS: collision fix — three-way /app.v2, /app/v2, /app-v2"
|
||||
);
|
||||
passed++;
|
||||
}
|
||||
|
||||
// ── Edge cases ───────────────────────────────────────────────────
|
||||
|
||||
// Test 12: same path in different resources — always separate
|
||||
{
|
||||
const key1 = newKeyComputation(1, "/api", "prefix", null, null);
|
||||
const key2 = newKeyComputation(2, "/api", "prefix", null, null);
|
||||
assertEquals(
|
||||
key1 !== key2,
|
||||
true,
|
||||
"different resources with same path must have different keys"
|
||||
);
|
||||
console.log(" PASS: edge case — same path, different resources");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 13: same resource, different pathMatchType — separate keys
|
||||
{
|
||||
const exact = newKeyComputation(1, "/api", "exact", null, null);
|
||||
const prefix = newKeyComputation(1, "/api", "prefix", null, null);
|
||||
assertEquals(
|
||||
exact !== prefix,
|
||||
true,
|
||||
"exact vs prefix must have different keys"
|
||||
);
|
||||
console.log(" PASS: edge case — same path, different match types");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 14: same resource and path, different rewrite config — separate keys
|
||||
{
|
||||
const noRewrite = newKeyComputation(1, "/api", "prefix", null, null);
|
||||
const withRewrite = newKeyComputation(
|
||||
1,
|
||||
"/api",
|
||||
"prefix",
|
||||
"/backend",
|
||||
"prefix"
|
||||
);
|
||||
assertEquals(
|
||||
noRewrite !== withRewrite,
|
||||
true,
|
||||
"with vs without rewrite must have different keys"
|
||||
);
|
||||
console.log(" PASS: edge case — same path, different rewrite config");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 15: paths with special URL characters
|
||||
{
|
||||
const paths = ["/api?foo", "/api#bar", "/api%20baz", "/api+qux"];
|
||||
const keys = new Set(
|
||||
paths.map((p) => newKeyComputation(1, p, "prefix", null, null))
|
||||
);
|
||||
assertEquals(
|
||||
keys.size,
|
||||
paths.length,
|
||||
"special URL chars must produce unique keys"
|
||||
);
|
||||
console.log(" PASS: edge case — special URL characters in paths");
|
||||
passed++;
|
||||
}
|
||||
|
||||
console.log(`\nAll ${passed} tests passed!`);
|
||||
}
|
||||
|
||||
try {
|
||||
runTests();
|
||||
} catch (error) {
|
||||
console.error("Test failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -13,6 +13,26 @@ export function sanitize(input: string | null | undefined): string | undefined {
|
||||
.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);
|
||||
});
|
||||
}
|
||||
|
||||
export function validatePathRewriteConfig(
|
||||
path: string | null,
|
||||
pathMatchType: string | null,
|
||||
|
||||
@@ -34,7 +34,11 @@ import {
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { orgs, resources, sites, Target, targets } from "@server/db";
|
||||
import { sanitize, validatePathRewriteConfig } from "@server/lib/traefik/utils";
|
||||
import {
|
||||
sanitize,
|
||||
encodePath,
|
||||
validatePathRewriteConfig
|
||||
} from "@server/lib/traefik/utils";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import createPathRewriteMiddleware from "@server/lib/traefik/middleware";
|
||||
import {
|
||||
@@ -170,7 +174,7 @@ export async function getTraefikConfig(
|
||||
resourcesWithTargetsAndSites.forEach((row) => {
|
||||
const resourceId = row.resourceId;
|
||||
const resourceName = sanitize(row.resourceName) || "";
|
||||
const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths
|
||||
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
||||
const pathMatchType = row.pathMatchType || "";
|
||||
const rewritePath = row.rewritePath || "";
|
||||
const rewritePathType = row.rewritePathType || "";
|
||||
@@ -192,7 +196,7 @@ export async function getTraefikConfig(
|
||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||
const key = sanitize(mapKey);
|
||||
|
||||
if (!resourcesMap.has(key)) {
|
||||
if (!resourcesMap.has(mapKey)) {
|
||||
const validation = validatePathRewriteConfig(
|
||||
row.path,
|
||||
row.pathMatchType,
|
||||
@@ -207,9 +211,10 @@ export async function getTraefikConfig(
|
||||
return;
|
||||
}
|
||||
|
||||
resourcesMap.set(key, {
|
||||
resourcesMap.set(mapKey, {
|
||||
resourceId: row.resourceId,
|
||||
name: resourceName,
|
||||
key: key,
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
http: row.http,
|
||||
@@ -243,7 +248,7 @@ export async function getTraefikConfig(
|
||||
}
|
||||
|
||||
// Add target with its associated site data
|
||||
resourcesMap.get(key).targets.push({
|
||||
resourcesMap.get(mapKey).targets.push({
|
||||
resourceId: row.resourceId,
|
||||
targetId: row.targetId,
|
||||
ip: row.ip,
|
||||
@@ -296,8 +301,9 @@ export async function getTraefikConfig(
|
||||
};
|
||||
|
||||
// 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 key = resource.key;
|
||||
|
||||
const routerName = `${key}-${resource.name}-router`;
|
||||
const serviceName = `${key}-${resource.name}-service`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { db, users } from "@server/db";
|
||||
import { bannedEmails, bannedIps, db, users } from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { email, z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
@@ -65,6 +65,30 @@ export async function signup(
|
||||
skipVerificationEmail
|
||||
} = parsedBody.data;
|
||||
|
||||
const [bannedEmail] = await db
|
||||
.select()
|
||||
.from(bannedEmails)
|
||||
.where(eq(bannedEmails.email, email))
|
||||
.limit(1);
|
||||
if (bannedEmail) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "Signup blocked. Do not attempt to continue to use this service.")
|
||||
);
|
||||
}
|
||||
|
||||
if (req.ip) {
|
||||
const [bannedIp] = await db
|
||||
.select()
|
||||
.from(bannedIps)
|
||||
.where(eq(bannedIps.ip, req.ip))
|
||||
.limit(1);
|
||||
if (bannedIp) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "Signup blocked. Do not attempt to continue to use this service.")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const userId = generateId(15);
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ async function queryDomains(orgId: string, limit: number, offset: number) {
|
||||
tries: domains.tries,
|
||||
configManaged: domains.configManaged,
|
||||
certResolver: domains.certResolver,
|
||||
preferWildcardCert: domains.preferWildcardCert
|
||||
preferWildcardCert: domains.preferWildcardCert,
|
||||
errorMessage: domains.errorMessage
|
||||
})
|
||||
.from(orgDomains)
|
||||
.where(eq(orgDomains.orgId, orgId))
|
||||
|
||||
@@ -125,7 +125,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
||||
// Add site as a destination for this client
|
||||
const destination: PeerDestination = {
|
||||
destinationIP: site.subnet.split("/")[0],
|
||||
destinationPort: site.listenPort
|
||||
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
};
|
||||
|
||||
// Check if this destination is already in the array to avoid duplicates
|
||||
@@ -165,7 +165,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
||||
|
||||
const destination: PeerDestination = {
|
||||
destinationIP: peer.subnet.split("/")[0],
|
||||
destinationPort: peer.listenPort
|
||||
destinationPort: peer.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
};
|
||||
|
||||
// Check for duplicates
|
||||
|
||||
@@ -112,7 +112,7 @@ export async function updateHolePunch(
|
||||
destinations: destinations
|
||||
});
|
||||
} catch (error) {
|
||||
// logger.error(error); // FIX THIS
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
@@ -262,7 +262,7 @@ export async function updateAndGenerateEndpointDestinations(
|
||||
if (site.subnet && site.listenPort) {
|
||||
destinations.push({
|
||||
destinationIP: site.subnet.split("/")[0],
|
||||
destinationPort: site.listenPort
|
||||
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -339,10 +339,10 @@ export async function updateAndGenerateEndpointDestinations(
|
||||
handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!);
|
||||
}
|
||||
|
||||
if (!updatedSite || !updatedSite.subnet) {
|
||||
logger.warn(`Site not found: ${newt.siteId}`);
|
||||
throw new Error("Site not found");
|
||||
}
|
||||
// if (!updatedSite || !updatedSite.subnet) {
|
||||
// logger.warn(`Site not found: ${newt.siteId}`);
|
||||
// throw new Error("Site not found");
|
||||
// }
|
||||
|
||||
// Find all clients that connect to this site
|
||||
// const sitesClientPairs = await db
|
||||
|
||||
@@ -201,7 +201,8 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
hcTimeout: targetHealthCheck.hcTimeout,
|
||||
hcHeaders: targetHealthCheck.hcHeaders,
|
||||
hcMethod: targetHealthCheck.hcMethod,
|
||||
hcTlsServerName: targetHealthCheck.hcTlsServerName
|
||||
hcTlsServerName: targetHealthCheck.hcTlsServerName,
|
||||
hcStatus: targetHealthCheck.hcStatus
|
||||
})
|
||||
.from(targets)
|
||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
@@ -274,7 +275,8 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
hcTimeout: target.hcTimeout, // in seconds
|
||||
hcHeaders: hcHeadersSend,
|
||||
hcMethod: target.hcMethod,
|
||||
hcTlsServerName: target.hcTlsServerName
|
||||
hcTlsServerName: target.hcTlsServerName,
|
||||
hcStatus: target.hcStatus
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -104,11 +104,11 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||
const payload = {
|
||||
oldDestination: {
|
||||
destinationIP: existingSite.subnet?.split("/")[0],
|
||||
destinationPort: existingSite.listenPort
|
||||
destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
},
|
||||
newDestination: {
|
||||
destinationIP: site.subnet?.split("/")[0],
|
||||
destinationPort: site.listenPort
|
||||
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -104,6 +104,13 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!site.publicKey || site.publicKey == "") { // the site is not ready to accept new peers
|
||||
logger.warn(
|
||||
`Site ${site.siteId} has no public key, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
|
||||
// logger.warn(
|
||||
// `Site ${site.siteId} last hole punch is too old, skipping`
|
||||
|
||||
@@ -223,6 +223,20 @@ async function createHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent creating resource with same domain as dashboard
|
||||
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
||||
if (dashboardUrl) {
|
||||
const dashboardHost = new URL(dashboardUrl).hostname;
|
||||
if (fullDomain === dashboardHost) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Resource domain cannot be the same as the dashboard domain"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (build != "oss") {
|
||||
const existingLoginPages = await db
|
||||
.select()
|
||||
|
||||
@@ -353,6 +353,20 @@ async function updateHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent updating resource with same domain as dashboard
|
||||
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
||||
if (dashboardUrl) {
|
||||
const dashboardHost = new URL(dashboardUrl).hostname;
|
||||
if (fullDomain === dashboardHost) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Resource domain cannot be the same as the dashboard domain"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (build != "oss") {
|
||||
const existingLoginPages = await db
|
||||
.select()
|
||||
|
||||
Reference in New Issue
Block a user