Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion server/db/pg/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ export const targets = pgTable("targets", {
internalPort: integer("internalPort"),
enabled: boolean("enabled").notNull().default(true),
path: text("path"),
pathMatchType: text("pathMatchType") // exact, prefix, regex
pathMatchType: text("pathMatchType"), // exact, prefix, regex
priority: integer("priority").notNull().default(100)
});

export const exitNodes = pgTable("exitNodes", {
Expand Down
3 changes: 2 additions & 1 deletion server/db/sqlite/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ export const targets = sqliteTable("targets", {
internalPort: integer("internalPort"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
path: text("path"),
pathMatchType: text("pathMatchType") // exact, prefix, regex
pathMatchType: text("pathMatchType"), // exact, prefix, regex
priority: integer("priority").notNull().default(100)
});

export const exitNodes = sqliteTable("exitNodes", {
Expand Down
6 changes: 4 additions & 2 deletions server/lib/blueprints/proxyResources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ export async function updateProxyResources(
enabled: targetData.enabled,
internalPort: internalPortToCreate,
path: targetData.path,
pathMatchType: targetData["path-match"]
pathMatchType: targetData["path-match"],
priority: targetData.priority
})
.returning();

Expand Down Expand Up @@ -327,7 +328,8 @@ export async function updateProxyResources(
port: targetData.port,
enabled: targetData.enabled,
path: targetData.path,
pathMatchType: targetData["path-match"]
pathMatchType: targetData["path-match"],
priority: targetData.priority
})
.where(eq(targets.targetId, existingTarget.targetId))
.returning();
Expand Down
3 changes: 2 additions & 1 deletion server/lib/blueprints/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export const TargetSchema = z.object({
enabled: z.boolean().optional().default(true),
"internal-port": z.number().int().min(1).max(65535).optional(),
path: z.string().optional(),
"path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable()
"path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable(),
priority: z.number().int().min(1).max(1000).optional().default(100)
});
export type TargetData = z.infer<typeof TargetSchema>;

Expand Down
3 changes: 2 additions & 1 deletion server/routers/target/createTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ const createTargetSchema = z
port: z.number().int().min(1).max(65535),
enabled: z.boolean().default(true),
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable()
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
priority: z.number().int().min(1).max(1000)
})
.strict();

Expand Down
3 changes: 2 additions & 1 deletion server/routers/target/listTargets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ function queryTargets(resourceId: number) {
siteId: targets.siteId,
siteType: sites.type,
path: targets.path,
pathMatchType: targets.pathMatchType
pathMatchType: targets.pathMatchType,
priority: targets.priority,
})
.from(targets)
.leftJoin(sites, eq(sites.siteId, targets.siteId))
Expand Down
3 changes: 2 additions & 1 deletion server/routers/target/updateTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ const updateTargetBodySchema = z
port: z.number().int().min(1).max(65535).optional(),
enabled: z.boolean().optional(),
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable()
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
priority: z.number().int().min(1).max(1000).optional(),
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
Expand Down
56 changes: 41 additions & 15 deletions server/routers/traefik/getTraefikConfig.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Request, Response } from "express";
import { db, exitNodes } from "@server/db";
import { and, eq, inArray, or, isNull, ne, isNotNull } from "drizzle-orm";
import { and, eq, inArray, or, isNull, ne, isNotNull, desc } from "drizzle-orm";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import config from "@server/lib/config";
Expand Down Expand Up @@ -133,7 +133,8 @@ export async function getTraefikConfig(
internalPort: targets.internalPort,
path: targets.path,
pathMatchType: targets.pathMatchType,

priority: targets.priority,

// Site fields
siteId: sites.siteId,
siteType: sites.type,
Expand All @@ -154,7 +155,8 @@ export async function getTraefikConfig(
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true)
)
);
)
.orderBy(desc(targets.priority), targets.targetId); // stable ordering

// Group by resource and include targets with their unique site data
const resourcesMap = new Map();
Expand All @@ -163,6 +165,7 @@ export async function getTraefikConfig(
const resourceId = row.resourceId;
const targetPath = sanitizePath(row.path) || ""; // Handle null/undefined paths
const pathMatchType = row.pathMatchType || "";
const priority = row.priority ?? 100;

// Create a unique key combining resourceId and path+pathMatchType
const pathKey = [targetPath, pathMatchType].filter(Boolean).join("-");
Expand All @@ -186,7 +189,8 @@ export async function getTraefikConfig(
targets: [],
headers: row.headers,
path: row.path, // the targets will all have the same path
pathMatchType: row.pathMatchType // the targets will all have the same pathMatchType
pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType
priority: priority // may be null, we fallback later
});
}

Expand All @@ -199,6 +203,7 @@ export async function getTraefikConfig(
port: row.port,
internalPort: row.internalPort,
enabled: row.targetEnabled,
priority: row.priority,
site: {
siteId: row.siteId,
type: row.siteType,
Expand Down Expand Up @@ -348,9 +353,30 @@ export async function getTraefikConfig(
}

let rule = `Host(\`${fullDomain}\`)`;
let priority = 100;

// priority logic
let priority: number;
if (resource.priority && resource.priority != 100) {
priority = resource.priority;
} else {
priority = 100;
if (resource.path && resource.pathMatchType) {
priority += 10;
if (resource.pathMatchType === "exact") {
priority += 5;
} else if (resource.pathMatchType === "prefix") {
priority += 3;
} else if (resource.pathMatchType === "regex") {
priority += 2;
}
if (resource.path === "/") {
priority = 1; // lowest for catch-all
}
}
}

if (resource.path && resource.pathMatchType) {
priority += 1;
//priority += 1;
// add path to rule based on match type
let path = resource.path;
// if the path doesn't start with a /, add it
Expand Down Expand Up @@ -406,7 +432,7 @@ export async function getTraefikConfig(

return (
(targets as TargetWithSite[])
.filter((target: TargetWithSite) => {
.filter((target: TargetWithSite) => {
if (!target.enabled) {
return false;
}
Expand All @@ -427,33 +453,33 @@ export async function getTraefikConfig(
) {
return false;
}
} else if (target.site.type === "newt") {
} else if (target.site.type === "newt") {
if (
!target.internalPort ||
!target.method ||
!target.site.subnet
) {
return false;
}
}
return true;
})
.map((target: TargetWithSite) => {
}
return true;
})
.map((target: TargetWithSite) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
return {
url: `${target.method}://${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
} else if (target.site.type === "newt") {
const ip =
target.site.subnet!.split("/")[0];
return {
url: `${target.method}://${ip}:${target.internalPort}`
};
}
})
}
})
// filter out duplicates
.filter(
(v, i, a) =>
Expand Down
61 changes: 55 additions & 6 deletions src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ import {
CircleCheck,
CircleX,
ArrowRight,
MoveRight
MoveRight,
ArrowUp,
Info,
ArrowDown
} from "lucide-react";
import { ContainersSelector } from "@app/components/ContainersSelector";
import { useTranslations } from "next-intl";
Expand All @@ -98,14 +101,16 @@ import {
import { Badge } from "@app/components/ui/badge";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { HeadersInput } from "@app/components/HeadersInput";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip";

const addTargetSchema = z.object({
ip: z.string().refine(isTargetValid),
method: z.string().nullable(),
port: z.coerce.number().int().positive(),
siteId: z.number().int().positive(),
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable()
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
priority: z.number().int().min(1).max(1000)
}).refine(
(data) => {
// If path is provided, pathMatchType must be provided
Expand Down Expand Up @@ -259,7 +264,8 @@ export default function ReverseProxyTargets(props: {
method: resource.http ? "http" : null,
port: "" as any as number,
path: null,
pathMatchType: null
pathMatchType: null,
priority: 100,
}
});

Expand Down Expand Up @@ -440,7 +446,8 @@ export default function ReverseProxyTargets(props: {
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: resource.resourceId
resourceId: resource.resourceId,
priority: 100
};

setTargets([...targets, newTarget]);
Expand All @@ -449,7 +456,8 @@ export default function ReverseProxyTargets(props: {
method: resource.http ? "http" : null,
port: "" as any as number,
path: null,
pathMatchType: null
pathMatchType: null,
priority: 100,
});
}

Expand Down Expand Up @@ -494,7 +502,8 @@ export default function ReverseProxyTargets(props: {
enabled: target.enabled,
siteId: target.siteId,
path: target.path,
pathMatchType: target.pathMatchType
pathMatchType: target.pathMatchType,
priority: target.priority
};

if (target.new) {
Expand Down Expand Up @@ -567,6 +576,46 @@ export default function ReverseProxyTargets(props: {
}

const columns: ColumnDef<LocalTarget>[] = [
{
id: "priority",
header: () => (
<div className="flex items-center gap-2">
Priority
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
),
cell: ({ row }) => {
return (
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
max="1000"
defaultValue={row.original.priority || 100}
className="w-20"
onBlur={(e) => {
const value = parseInt(e.target.value, 10);
if (value >= 1 && value <= 1000) {
updateTarget(row.original.targetId, {
...row.original,
priority: value
});
}
}}
/>
</div>
);
}
},
{
accessorKey: "path",
header: t("matchPath"),
Expand Down
Loading