Compare commits

...

55 Commits

Author SHA1 Message Date
Fred KISSIE 97c91fbdc9 ♻️ rename destinationDomain to destinationHost to include the Scheme 2026-09-18 23:55:51 +02:00
Fred KISSIE 57b80bf74e ♻️ Add ssl field to redirects and add traefik config to public file 2026-09-18 23:38:46 +02:00
Fred KISSIE d06a261cd6 ♻️ refactor 2026-09-18 21:04:56 +02:00
Fred KISSIE 67f45023db Merge branch 'dev' into feat/redirect-tables 2026-09-18 20:59:18 +02:00
Fred KISSIE 2cb5cea48f handle redirect request in badger's verifySession 2026-09-18 20:29:52 +02:00
Fred KISSIE 2f5e3eede6 📝 doc 2026-09-17 21:48:05 +02:00
Fred KISSIE 85adae063b ♻️ handle priority field 2026-09-17 21:44:56 +02:00
Fred KISSIE ec36317057 finish redirect traefik config 2026-09-17 21:32:55 +02:00
miloschwartz 5ca08d71f0 change restart site toast text 2026-09-17 13:31:00 -04:00
Owen 1f453dc04f Send out of address space errors to sites and clients 2026-09-17 09:24:15 -04:00
Fred KISSIE c51bcbb578 🚧 wip: traefik config for redirect 2026-09-16 19:30:10 +02:00
Owen 65ccd5a89d Enhance error handling for subscription lifecycle events in billing hooks 2026-09-16 12:11:25 -04:00
Owen 76a4f50ccf Update how billing is presented for license keys 2026-09-16 11:42:19 -04:00
miloschwartz 1dfe0124be add expanded resource laucher screenshot 2026-09-16 10:52:35 -04:00
miloschwartz aa52174f96 update resource launcher screenshot 2026-09-16 10:52:35 -04:00
Owen e23ae5707f Link direct to self hosted 2026-09-16 10:43:46 -04:00
Owen 17053ac2bd Fix agent display again 2026-09-16 10:38:52 -04:00
Owen 735787b183 Fix #3778 2026-09-16 10:11:20 -04:00
Owen 262ca8a1d0 Add domain validation for inference mode in resource forms 2026-09-16 09:41:46 -04:00
Owen Schwartz 66c9bdbfa3 Merge pull request #3774 from fosrl/dev
Make migration idempotent
2026-09-16 09:11:14 -04:00
Owen 4716a2a647 Make migration idempotent
Ref #3759
2026-09-16 09:08:20 -04:00
Owen Schwartz 847f44197b Merge pull request #3772 from Hayyan612/fix/site-type-badge-fallback
fix(sites): fall back to newtVersion so the Type badge is never empty
2026-09-16 08:59:07 -04:00
Owen Schwartz 6aacd4d185 Merge pull request #3770 from Hayyan612/fix/systemd-exec-path
fix(ui): point the manual systemd unit at the installed CLI path
2026-09-16 08:57:37 -04:00
Owen Schwartz ab3db3bc68 Merge pull request #3769 from Hayyan612/fix/backup-filename-timestamp
fix(setup): correct month index and zero-pad database backup file names
2026-09-16 08:57:12 -04:00
Hayyan Hajwani b52baceb50 fix(sites): fall back to newtVersion so the Type badge is never empty
The site overview rendered an empty badge for any site whose newt has not
reported the newer agent fields. The early return only bails out when both
agent and newtVersion are missing, so a site with newtVersion set but agent
null fell through to a badge whose label came solely from agent and whose
version came solely from agentVersion, leaving both blank.

Label such a site Newt and fall back to newtVersion for the version. Updating
the newt populated the new fields, which is why the badge appeared to fix
itself on upgrade.

Closes #3766
2026-09-16 13:55:24 +05:30
Hayyan Hajwani 0d2c8a37ef fix(ui): point the manual systemd unit at the installed CLI path
The service file offered on the site install screen hardcoded
/home/owen/fossorial/cli/bin/pangolin, a developer machine path, so the unit
fails to start on a normal install.

get-cli.sh installs to /usr/local/bin ("Prefer /usr/local/bin for system-wide
installation"), which is also where the bare `pangolin` calls in the surrounding
commands resolve from.

Closes #3768
2026-09-16 13:54:14 +05:30
Hayyan Hajwani 9298ec7cdb fix(setup): correct month index and zero-pad database backup file names
Backup names were built inline from Date#getMonth, which is zero-indexed, so a
backup taken on 12 September 2026 was written as db_2026-8-12_20-35-56.sqlite.
No field was zero-padded either, giving names like db_2026-8-12_20-36-2.sqlite.

Extract formatBackupTimestamp into server/lib and use it from both places that
built the string: the backupDb helper in migrationsSqlite.ts and the inline copy
in the 1.0.0-beta9 setup script. Padding every field also makes the names sort
lexicographically in the order the backups were taken.

Adds tests covering both reported names, single-digit padding and sort order.
Reverting the helper to the old formula fails them with the exact name from the
report.
2026-09-16 13:46:57 +05:30
Owen Schwartz 0fdff2feee Merge pull request #3765 from fosrl/dev
Show the version
2026-09-15 22:31:22 -04:00
Owen 8e042e6433 Show the version 2026-09-15 22:31:05 -04:00
Owen Schwartz 883ad14326 Merge pull request #3764 from fosrl/dev
fix premature migration execution
2026-09-15 22:30:19 -04:00
miloschwartz 3cb41211ee fix premature migration execution 2026-09-15 20:44:17 -04:00
Owen Schwartz ee4a1a6b18 Merge pull request #3758 from fosrl/dev
Update link in reference deployment
2026-09-15 17:17:22 -04:00
Owen Schwartz a10972990a Merge pull request #3757 from fosrl/dev
Install go 1.26
2026-09-15 16:57:15 -04:00
Owen Schwartz 6d6e105711 Merge pull request #3756 from fosrl/dev
1.23.0
2026-09-15 16:48:42 -04:00
Fred KISSIE a0c77a4c85 ♻️ lil refactor 2026-09-14 23:52:27 +02:00
Fred KISSIE b58cf31856 💄 create and prefetch certificates for redirects 2026-09-14 23:49:22 +02:00
Fred KISSIE fd7780528f 🚧 Create certificate for Redirect (in case of domain) 2026-09-14 23:42:20 +02:00
Fred KISSIE 4a41e6b650 ♻️ fix resource id link 2026-09-14 19:18:10 +02:00
Fred KISSIE 4bf85ead77 💬 update text 2026-09-11 23:34:01 +02:00
Fred KISSIE f19b2c391f ♻️ order by id desc 2026-09-11 22:56:35 +02:00
Fred KISSIE 5a6c23e05a 💄 Some UI changes 2026-09-11 21:52:37 +02:00
Fred KISSIE 4324eebc1f 💄 Redirect table 2026-09-11 21:25:20 +02:00
Fred KISSIE 7ce2668b5b 🚧 redirect create form 2026-09-10 22:05:45 +02:00
Fred KISSIE 9e85b39fd5 🚧 wip 2026-09-10 20:08:47 +02:00
Fred KISSIE 30ca3e7e97 list resources table 2026-09-10 18:19:46 +02:00
Fred KISSIE 2bccdc33fc 🚧 redirects index page 2026-09-10 02:44:31 +02:00
Fred KISSIE 05d1d4d143 🚧 Redirect cRUD 2026-09-10 00:31:39 +02:00
Fred KISSIE ae7315244f 🗃️ create DB models 2026-09-10 00:17:36 +02:00
Fred KISSIE 241ecc13e2 🚧 wip 2026-09-09 01:43:14 +02:00
Owen 82c5dcf16f Fix tsconfig to use react-jsx 2026-09-08 16:45:56 -04:00
Owen 59f0c90836 Fix circular import 2026-09-08 16:42:22 -04:00
Owen b0e64a5e5a Widen subnet 2026-09-08 16:31:58 -04:00
Owen 733d3ece0e Quiet up error logs 2026-09-08 10:01:59 -04:00
Owen 59b228ce39 Quiet log message 2026-09-08 09:26:57 -04:00
Owen 080bcbaf97 Use endpoint instead of reachableAt for remote nodes 2026-09-07 11:55:45 -04:00
63 changed files with 4708 additions and 251 deletions
+1
View File
@@ -142,6 +142,7 @@ Give users a landing page to quickly find and open the resources they can access
* Create reusable views for common access patterns
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
<img src="public/screenshots/resource-launcher-expanded.png" alt="Resource Launcher Details Panel" width="100%" />
## Download Clients
+59 -2
View File
@@ -132,7 +132,7 @@
"siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for <b>{name}</b>? The site will briefly lose connectivity.",
"siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.",
"siteRestarted": "Site restarted",
"siteRestartedDescription": "The WireGuard tunnel has been restarted.",
"siteRestartedDescription": "The site has been restarted.",
"siteErrorRestart": "Failed to restart site",
"siteErrorRestartDescription": "An error occurred while restarting the site.",
"siteSettingDescription": "Configure the settings on the site",
@@ -234,6 +234,9 @@
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
"privateResourcesBannerTitle": "Zero-Trust Private Access",
"privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.",
"licenseBillingBannerTitle": "Manage License Billing",
"licenseBillingBannerDescription": "To manage billing for your license keys, including payment methods and invoices, visit the billing page.",
"licenseBillingBannerButton": "Go to Billing",
"resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource",
@@ -2120,6 +2123,7 @@
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
"sidebarApiKeys": "API Keys",
"sidebarOrgs": "Organizations",
"sidebarRedirects": "Redirects",
"sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings",
"sidebarAllUsers": "Users",
@@ -3565,6 +3569,7 @@
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"validVirtualAPIKey": "Valid Virtual API Key",
"allowedRedirect": "Allowed Redirect",
"view": "View",
"configManaged": "Config Managed",
"connectedClient": "Connected Client",
@@ -4397,5 +4402,57 @@
"sessionToolbarShow": "Show toolbar",
"sessionToolbarHide": "Hide toolbar",
"actionUpdateSiteApprovals": "Update Site Approvals",
"check": "Check"
"check": "Check",
"redirectsTitle": "Manage Redirects",
"redirectsDescription": "Forward requests from a path on your domains or resources to another URL",
"redirectsSearch": "Search redirects...",
"redirectAdd": "Add Redirect",
"redirectSource": "Source",
"redirectDestination": "Destination",
"redirectAttachedTo": "Attached To",
"redirectType": "Type",
"redirectTypePermanent": "Permanent (308)",
"redirectTypeTemporary": "Temporary (307)",
"redirectUpdated": "Redirect updated successfully",
"redirectErrorUpdate": "Failed to update redirect",
"redirectDeleted": "Redirect deleted successfully",
"redirectErrorDelete": "Failed to delete redirect",
"redirectDelete": "Delete Redirect",
"redirectDeleteConfirm": "Confirm Delete Redirect",
"redirectQuestionRemove": "Are you sure you want to remove this redirect?",
"redirectMessageRemove": "Once removed, requests matching this redirect will no longer be forwarded.",
"redirectDestinationHost": "Destination",
"redirectDestinationHostDescription": "Where requests are sent, including the scheme, such as https://example.com",
"redirectDestinationHostRequired": "Enter a destination",
"redirectSameDomainAsSource": "Same domain as source",
"redirectSameDomainAsSourceDescription": "Keep the destination on the source domain and only change the path",
"redirectDestinationHostInvalid": "Enter a valid destination with a scheme, such as https://example.com",
"redirectMatchPathDescription": "Which incoming paths this redirect applies to",
"redirectRewritePathDescription": "Optionally change the path before redirecting. Leave unset to keep the original path.",
"redirectRewritePathRequired": "Enter a rewrite path, or choose Strip Prefix",
"redirectMatchPathInvalidRegex": "Match path must be a valid regular expression",
"redirectPriorityInvalid": "Enter a whole number between 1 and 1000",
"redirectPriorityDescription": "Higher priority routes are evaluated first. Redirects are always evaluated before targets.",
"redirectCreate": "Create Redirect",
"redirectCreateDescription": "Forward requests matching a path to another URL",
"redirectEditDescription": "Update how this redirect forwards incoming requests",
"redirectGoBack": "Back to Redirects",
"redirectCreated": "Redirect created successfully",
"redirectErrorCreate": "Failed to create redirect",
"redirectSettings": "Redirect Rule",
"selectedRedirectDomain": "Selected Domain",
"selectedRedirectResource": "Selected Resource",
"redirectResourceNoDomain": "This resource has no domain",
"redirectSourceSectionDescription": "Choose the domain or resource this redirect applies to",
"redirectSettingsDescription": "Set which paths to match and where to send them",
"redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it",
"redirectAttachedToDescription": "Choose whether this redirect applies to a whole domain or a single resource",
"redirectAttachDomain": "Domain",
"redirectAttachResource": "Resource",
"redirectDomainRequired": "Select a domain to attach this redirect to",
"redirectResourceRequired": "Select a resource to attach this redirect to",
"redirectPermanent": "Permanent Redirect",
"redirectPermanentDescription": "Respond with 308 instead of 307. Permanent redirects are cached by browsers.",
"redirectSslDescription": "Serve this redirect over HTTPS. Turn off to match requests on plain HTTP.",
"redirectSslInheritedDescription": "Inherited from the attached resource's TLS setting."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

After

Width:  |  Height:  |  Size: 713 KiB

+6 -1
View File
@@ -205,7 +205,12 @@ export enum ActionsEnum {
deleteVirtualApiKey = "deleteVirtualApiKey",
getVirtualApiKey = "getVirtualApiKey",
listVirtualApiKeys = "listVirtualApiKeys",
updateVirtualApiKey = "updateVirtualApiKey"
updateVirtualApiKey = "updateVirtualApiKey",
createRedirect = "createRedirect",
deleteRedirect = "deleteRedirect",
getRedirect = "getRedirect",
listRedirects = "listRedirects",
updateRedirect = "updateRedirect"
}
export async function checkUserActionPermission(
+25
View File
@@ -4,6 +4,7 @@ import {
aiProviders,
clients,
db,
redirects,
resourcePolicies,
resources,
siteResources
@@ -140,6 +141,30 @@ export async function getUniqueProviderName(orgId: string): Promise<string> {
}
}
export async function getUniqueRedirectName(orgId: string): Promise<string> {
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error("Could not generate a unique name");
}
const name = generateName();
const redirectCount = await db
.select({
niceId: redirects.niceId,
orgId: redirects.orgId
})
.from(redirects)
.where(and(eq(redirects.niceId, name), eq(redirects.orgId, orgId)));
if (redirectCount.length === 0) {
return name;
}
loops++;
}
}
export async function getUniqueResourcePolicyName(
orgId: string
): Promise<string> {
+36
View File
@@ -227,6 +227,41 @@ export const resources = pgTable(
]
);
export const redirects = pgTable("redirects", {
redirectId: serial("redirectId").primaryKey(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
resourceId: integer("resourceId").references(() => resources.resourceId, {
onDelete: "cascade"
}),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "cascade"
}),
niceId: text("niceId").notNull(),
name: varchar("name").notNull(),
subdomain: varchar("subdomain"),
destinationHost: varchar("destinationHost").notNull(), // scheme://host[:port]
pathMatchType: varchar("pathMatchType")
.$type<"exact" | "prefix" | "regex">()
.notNull()
.default("regex"), // exact, prefix, regex
matchPath: varchar("matchPath"),
rewritePath: varchar("rewritePath"), // if set, rewrites the path to this value,
// else, the original path will be kept
rewritePathType: varchar("rewritePathType").$type<
"exact" | "prefix" | "regex" | "stripPrefix"
>(), // exact, prefix, regex, stripPrefix
priority: integer("priority").default(100),
permanent: boolean("permanent").notNull().default(false),
// Only consulted for domain-attached redirects; resource-attached ones
// inherit the resource's ssl setting.
ssl: boolean("ssl").notNull().default(true),
enabled: boolean("enabled").notNull().default(true)
});
export const resourceAiProviders = pgTable(
"resourceAiProviders",
{
@@ -2067,6 +2102,7 @@ export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
export type Redirect = InferSelectModel<typeof redirects>;
export type AiProvider = InferSelectModel<typeof aiProviders>;
export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>;
+67 -2
View File
@@ -35,10 +35,12 @@ import {
resourcePolicyHeaderAuth,
ResourcePolicyHeaderAuth,
resourceWhitelist,
resourcePolicyWhiteList
resourcePolicyWhiteList,
redirects,
domains
} from "@server/db";
import { alias } from "@server/db";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import logger from "@server/logger";
export type ResourceWithAuth = {
@@ -53,11 +55,74 @@ export type ResourceWithAuth = {
org: Org;
};
export type RedirectByHost = {
redirectId: number;
orgId: string;
matchPath: string | null;
pathMatchType: string;
destinationHost: string;
rewritePath: string | null;
rewritePathType: string | null;
permanent: boolean;
priority: number | null;
};
export type UserSessionWithUser = {
session: any;
user: any;
};
/**
* Enabled redirects listening on the given host, highest priority first.
* A redirect listens on its resource's fullDomain when attached to one,
* otherwise on subdomain.baseDomain (or the bare baseDomain) of its domain.
* Mirrors the host resolution in getTraefikConfig so badger agrees with
* what Traefik routed.
*/
export async function getRedirectsByHost(
host: string
): Promise<RedirectByHost[]> {
// A literal "*." leading label matches any single subdomain, like
// wildcard resources do.
const parts = host.split(".");
const candidates = [host];
for (let i = 1; i < parts.length; i++) {
candidates.push(`*.${parts.slice(i).join(".")}`);
}
const redirectHost = sql<string>`case
when ${redirects.resourceId} is not null then ${resources.fullDomain}
when ${redirects.subdomain} is null then ${domains.baseDomain}
else ${redirects.subdomain} || '.' || ${domains.baseDomain}
end`;
return db
.select({
redirectId: redirects.redirectId,
orgId: redirects.orgId,
matchPath: redirects.matchPath,
pathMatchType: redirects.pathMatchType,
destinationHost: redirects.destinationHost,
rewritePath: redirects.rewritePath,
rewritePathType: redirects.rewritePathType,
permanent: redirects.permanent,
priority: redirects.priority
})
.from(redirects)
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
.where(
and(
eq(redirects.enabled, true),
// Traefik drops resource-attached redirects along with a
// disabled resource; do the same here.
or(isNull(redirects.resourceId), eq(resources.enabled, true)),
inArray(redirectHost, candidates)
)
)
.orderBy(desc(redirects.priority));
}
/**
* Get resource by domain with pincode and password information
*/
+38
View File
@@ -243,6 +243,43 @@ export const resources = sqliteTable(
(table) => [index("idx_resources_orgId").on(table.orgId)]
);
export const redirects = sqliteTable("redirects", {
redirectId: integer("redirectId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
resourceId: integer("resourceId").references(() => resources.resourceId, {
onDelete: "cascade"
}),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "cascade"
}),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
subdomain: text("subdomain"),
destinationHost: text("destinationHost").notNull(), // scheme://host[:port]
pathMatchType: text("pathMatchType")
.$type<"exact" | "prefix" | "regex">()
.notNull()
.default("regex"), // exact, prefix, regex
matchPath: text("matchPath"),
rewritePath: text("rewritePath"), // if set, rewrites the path to this value,
// else, the original path will be kept
rewritePathType: text("rewritePathType").$type<
"exact" | "prefix" | "regex" | "stripPrefix"
>(), // exact, prefix, regex, stripPrefix
priority: integer("priority").default(100),
permanent: integer("permanent", { mode: "boolean" })
.notNull()
.default(false),
// Only consulted for domain-attached redirects; resource-attached ones
// inherit the resource's ssl setting.
ssl: integer("ssl", { mode: "boolean" }).notNull().default(true),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
});
export const resourceAiProviders = sqliteTable(
"resourceAiProviders",
{
@@ -2106,6 +2143,7 @@ export type ResourcePolicyHeaderAuth = InferSelectModel<
>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type Redirect = InferSelectModel<typeof redirects>;
export type AiProvider = InferSelectModel<typeof aiProviders>;
export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>;
@@ -93,9 +93,8 @@ export const EnterpriseEditionKeyGenerated = ({
</EmailSection>
<EmailText>
If you need to purchase additional license keys or
modify your existing license, please reach out to
our support team at{" "}
For any questions or concerns regarding your license
or billing, please reach out to support at{" "}
<a
href="mailto:support@pangolin.net"
className="text-primary font-medium"
+101
View File
@@ -0,0 +1,101 @@
import { formatBackupTimestamp } from "./backupFileName";
import { assertEquals } from "@test/assert";
// Local-time constructors are used throughout, matching formatBackupTimestamp,
// so these cases do not depend on the machine's timezone.
function testMonthIsOneIndexed() {
console.log("Running month indexing tests...");
// The case from the report: a backup taken on 12 September 2026 was named
// db_2026-8-12_... because Date#getMonth is zero-indexed.
{
const result = formatBackupTimestamp(new Date(2026, 8, 12, 20, 35, 56));
assertEquals(
result,
"2026-09-12_20-35-56",
"September must render as 09, not 8"
);
}
// The other reported name, db_2026-0-23_..., was a January backup.
{
const result = formatBackupTimestamp(new Date(2026, 0, 23, 20, 25, 49));
assertEquals(
result,
"2026-01-23_20-25-49",
"January must render as 01, not 0"
);
}
{
const result = formatBackupTimestamp(new Date(2026, 11, 31, 23, 59, 59));
assertEquals(
result,
"2026-12-31_23-59-59",
"December must render as 12"
);
}
}
function testEveryFieldIsZeroPadded() {
console.log("Running zero padding tests...");
// db_2026-8-12_20-36-2 in the report: a single-digit second was not padded.
{
const result = formatBackupTimestamp(new Date(2026, 8, 12, 20, 36, 2));
assertEquals(
result,
"2026-09-12_20-36-02",
"Single-digit seconds must be padded"
);
}
{
const result = formatBackupTimestamp(new Date(2026, 0, 1, 0, 0, 0));
assertEquals(
result,
"2026-01-01_00-00-00",
"Midnight on the first of the month must pad every field"
);
}
}
function testNamesSortChronologically() {
console.log("Running sort order tests...");
// Zero padding means a plain lexicographic sort of the backups directory
// lists the backups in the order they were taken.
const taken = [
new Date(2026, 8, 12, 20, 36, 2),
new Date(2026, 0, 23, 20, 25, 49),
new Date(2026, 8, 12, 20, 35, 56),
new Date(2026, 11, 31, 23, 59, 59)
];
const sorted = taken
.map((date) => formatBackupTimestamp(date))
.sort();
assertEquals(
sorted.join(","),
[
"2026-01-23_20-25-49",
"2026-09-12_20-35-56",
"2026-09-12_20-36-02",
"2026-12-31_23-59-59"
].join(","),
"Backup names must sort into the order the backups were taken"
);
}
// Run all tests
try {
testMonthIsOneIndexed();
testEveryFieldIsZeroPadded();
testNamesSortChronologically();
console.log("All tests passed successfully!");
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Builds the timestamp segment of a database backup file name.
*
* `Date#getMonth` is zero-indexed, so building this inline produced names like
* `db_2026-8-12_...` for a backup taken on 12 September 2026. Every field is
* also zero-padded, which keeps the names unambiguous and makes them sort
* lexicographically in the order they were taken.
*
* @param date The moment the backup is being taken. Defaults to now.
* @returns A timestamp of the form `YYYY-MM-DD_HH-MM-SS`.
*/
export function formatBackupTimestamp(date: Date = new Date()): string {
const pad = (value: number): string => String(value).padStart(2, "0");
const datePart = [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate())
].join("-");
const timePart = [
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds())
].join("-");
return `${datePart}_${timePart}`;
}
+127 -35
View File
@@ -1,55 +1,56 @@
import {
db,
targetHealthCheck,
domains,
aiProviders,
resourceAiProviders,
siteResources,
exitNodes,
redirects,
resources,
siteNetworks,
exitNodes
siteResources,
sites,
targetHealthCheck,
targets
} from "@server/db";
import regionalCache from "@server/lib/cache";
import config from "@server/lib/config";
import logger from "@server/logger";
import {
and,
desc,
eq,
inArray,
or,
isNull,
ne,
isNotNull,
desc,
isNull,
or,
sql
} from "drizzle-orm";
import logger from "@server/logger";
import config from "@server/lib/config";
import { resources, sites, targets } from "@server/db";
import { applyPathRewriteMiddleware } from "./middleware";
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
import regionalCache from "@server/lib/cache";
import { TargetWithSite } from "./types";
import {
AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME,
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
buildAiGatewayClientIpMiddleware,
buildAiGatewayHostHeaderMiddleware,
buildAiGatewayRouterAndService,
buildAiGatewayTrustMiddlewares,
getAiGatewayHost
} from "./aiGatewayMiddlewares";
import {
buildBrowserGatewayConfig,
buildBrowserGatewayResourcesMap
} from "./browserGateway";
import { buildWildcardTls } from "./certResolver";
import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule";
import { buildCustomHeadersMiddleware } from "./headersMiddleware";
import {
buildHttpLoadBalancerServers,
buildStickySessionCookie,
buildTcpUdpLoadBalancerServers,
buildStickySessionIp
buildStickySessionIp,
buildTcpUdpLoadBalancerServers
} from "./loadBalancer";
import { buildCustomHeadersMiddleware } from "./headersMiddleware";
import {
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME,
getAiGatewayHost,
buildAiGatewayTrustMiddlewares,
buildAiGatewayClientIpMiddleware,
buildAiGatewayHostHeaderMiddleware,
buildAiGatewayRouterAndService
} from "./aiGatewayMiddlewares";
import {
buildBrowserGatewayResourcesMap,
buildBrowserGatewayConfig
} from "./browserGateway";
import { applyPathRewriteMiddleware } from "./middleware";
import { buildRedirectConfig, RedirectRouteRow } from "./redirect";
import { appendPathMatch, buildHostRule, computeRoutePriority } from "./rule";
import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias";
import { TargetWithSite } from "./types";
import { encodePath, sanitize, validatePathRewriteConfig } from "./utils";
const redirectHttpsMiddlewareName = "redirect-to-https";
const badgerMiddlewareName = "badger";
@@ -128,6 +129,7 @@ export async function getTraefikConfig(
siteOnline: sites.online,
subnet: sites.exitNodeSubnet,
exitNodeId: sites.exitNodeId,
// Domain cert resolver fields
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert
@@ -326,12 +328,87 @@ export async function getTraefikConfig(
)
);
// Redirects have no targets/sites, so like inference resources they are
// queried separately and emitted on every exit node. A redirect listens
// either on a resource's fullDomain or on subdomain.baseDomain of a
// domain; the domain join resolves to whichever one applies.
const redirectRows = await db
.select({
name: redirects.name,
enabled: redirects.enabled,
redirectId: redirects.redirectId,
subdomain: redirects.subdomain,
matchPath: redirects.matchPath,
pathMatchType: redirects.pathMatchType,
priority: redirects.priority,
ssl: redirects.ssl,
// Resource (when attached to one)
resourceId: resources.resourceId,
resourceFullDomain: resources.fullDomain,
resourceSubdomain: resources.subdomain,
resourceSsl: resources.ssl,
resourceWildcard: resources.wildcard,
// Domain (the redirect's own, or the resource's)
baseDomain: domains.baseDomain,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert
})
.from(redirects)
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
.leftJoin(
domains,
eq(
domains.domainId,
sql`coalesce(${redirects.domainId}, ${resources.domainId})`
)
)
.where(
and(
eq(redirects.enabled, true),
or(isNull(redirects.resourceId), eq(resources.enabled, true))
)
)
.orderBy(desc(redirects.priority), redirects.redirectId); // stable ordering
const redirectRoutes: RedirectRouteRow[] = [];
for (const row of redirectRows) {
const attachedToResource = row.resourceId !== null;
const fullDomain = attachedToResource
? row.resourceFullDomain
: [row.subdomain, row.baseDomain].filter(Boolean).join(".");
if (!fullDomain) {
logger.debug(
`Redirect ${row.redirectId} has no host to listen on, skipping Traefik config`
);
continue;
}
redirectRoutes.push({
enabled: row.enabled,
name: sanitize(row.name) || "",
redirectId: row.redirectId,
fullDomain,
hasSubdomain: attachedToResource
? !!row.resourceSubdomain
: !!row.subdomain,
wildcard: row.resourceWildcard,
ssl: attachedToResource ? !!row.resourceSsl : row.ssl,
attachedTo: attachedToResource ? "resource" : "domain",
matchPath: row.matchPath,
pathMatchType: row.pathMatchType,
priority: row.priority,
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert
});
}
// make sure we have at least one resource
if (
resourcesMap.size === 0 &&
inferenceResources.length === 0 &&
browserGatewayResourcesMap.size === 0 &&
siteResourcesWithFullDomain.length === 0
siteResourcesWithFullDomain.length === 0 &&
redirectRoutes.length === 0
) {
return {};
}
@@ -518,6 +595,21 @@ export async function getTraefikConfig(
}
}
buildRedirectConfig({
config_output,
redirects: redirectRoutes,
badgerMiddlewareName,
redirectHttpsMiddlewareName,
resolveTls: (redirect) =>
buildWildcardTls({
fullDomain: redirect.fullDomain,
hasSubdomain: redirect.hasSubdomain,
domainCertResolver: redirect.domainCertResolver,
preferWildcardCert:
redirect.preferWildcardCert || redirect.wildcard
})
});
if (browserGatewayUiUrl) {
buildBrowserGatewayConfig({
config_output,
+134
View File
@@ -0,0 +1,134 @@
import { assertEquals } from "../../../test/assert";
import { rewriteRequestPath } from "./middleware";
function runTests() {
console.log("Running rewriteRequestPath tests...");
// no rewrite configured
assertEquals(
rewriteRequestPath("/a/b", "/a", "prefix", null, null),
"/a/b",
"no rewrite type"
);
// exact rewrite
assertEquals(
rewriteRequestPath("/old", "/old", "exact", "/new", "exact"),
"/new",
"exact -> exact"
);
assertEquals(
rewriteRequestPath("/old/x", "/old", "exact", "/new", "exact"),
"/old/x",
"exact rewrite only on exact match"
);
assertEquals(
rewriteRequestPath("/old", "/old", "exact", "new", "exact"),
"/new",
"leading slash added to rewrite"
);
assertEquals(
rewriteRequestPath("/anything", null, null, "/new", "exact"),
"/new",
"no match path + exact rewrite replaces path"
);
// prefix rewrite
assertEquals(
rewriteRequestPath("/old/a/b", "/old", "prefix", "/new", "prefix"),
"/new/a/b",
"prefix -> prefix keeps rest"
);
assertEquals(
rewriteRequestPath("/old", "/old", "prefix", "/new", "prefix"),
"/new",
"prefix -> prefix bare"
);
assertEquals(
rewriteRequestPath("/old", "/old", "exact", "/new", "prefix"),
"/new",
"exact -> prefix"
);
assertEquals(
rewriteRequestPath(
"/api/v1/users",
"^/api/v1/(.*)",
"regex",
"/v2/$1",
"prefix"
),
"/v2/users",
"regex -> prefix uses capture"
);
// regex rewrite
assertEquals(
rewriteRequestPath(
"/blog/2020/post",
"^/blog/(\\d+)/(.*)$",
"regex",
"/archive/$2-$1",
"regex"
),
"/archive/post-2020",
"regex -> regex"
);
assertEquals(
rewriteRequestPath("/old/x", "/old", "prefix", "/new$1", "regex"),
"/new/x",
"prefix -> regex has (.*) capture"
);
assertEquals(
rewriteRequestPath("/old", "/old", "exact", "/new", "regex"),
"/new",
"exact -> regex"
);
// stripPrefix
assertEquals(
rewriteRequestPath("/old/a", "/old", "prefix", null, "stripPrefix"),
"/a",
"stripPrefix"
);
assertEquals(
rewriteRequestPath("/old", "/old", "prefix", null, "stripPrefix"),
"/",
"stripPrefix to root"
);
assertEquals(
rewriteRequestPath("/old/a", "/old", "prefix", "/new", "stripPrefix"),
"/new/a",
"stripPrefix + addPrefix"
);
assertEquals(
rewriteRequestPath("/old", "/old", "exact", null, "stripPrefix"),
"/",
"stripPrefix exact"
);
// Same result the replacePathRegex middleware produces for this config
// (regex `^/old` -> `/`), quirky double slash included.
assertEquals(
rewriteRequestPath("/old/a", "^/old", "regex", null, "stripPrefix"),
"//a",
"stripPrefix regex mirrors Traefik"
);
assertEquals(
rewriteRequestPath("/old/a", "^/old/", "regex", null, "stripPrefix"),
"/a",
"stripPrefix regex with trailing slash"
);
assertEquals(
rewriteRequestPath("/a/b", null, null, null, "stripPrefix"),
"/a/b",
"stripPrefix without match path is a no-op"
);
console.log("All rewriteRequestPath tests passed!");
}
try {
runTests();
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}
+72
View File
@@ -194,6 +194,78 @@ export default function createPathRewriteMiddleware(
return { middlewares };
}
/**
* Apply a path rewrite to a request path in-process, producing the same
* result the replacePathRegex / stripPrefix middlewares built above would.
* Used where Pangolin issues the redirect itself (badger) instead of
* handing it to Traefik.
*/
export function rewriteRequestPath(
requestPath: string,
path: string | null,
pathMatchType: string | null,
rewritePath: string | null,
rewritePathType: string | null
): string {
if (!rewritePathType) {
return requestPath;
}
let target = rewritePath ?? "";
if (
rewritePathType !== "regex" &&
target !== "" &&
!target.startsWith("/")
) {
target = `/${target}`;
}
// Nothing was matched against, so there is nothing to strip or replace;
// an exact rewrite is the only one that still means something.
if (!path || !pathMatchType) {
return rewritePathType === "exact" ? target || "/" : requestPath;
}
let matched = path;
if (pathMatchType !== "regex" && !matched.startsWith("/")) {
matched = `/${matched}`;
}
const matchRegex =
pathMatchType === "regex"
? matched
: pathMatchType === "prefix"
? `^${escapeRegex(matched)}(.*)`
: `^${escapeRegex(matched)}$`;
switch (rewritePathType) {
case "exact":
return requestPath.replace(
new RegExp(`^${escapeRegex(matched)}$`),
target
);
case "prefix":
return requestPath.replace(
new RegExp(matchRegex),
pathMatchType === "prefix" ? `${target}$1` : target
);
case "regex":
return requestPath.replace(new RegExp(matchRegex), target);
case "stripPrefix": {
if (pathMatchType === "prefix") {
const stripped = requestPath.startsWith(matched)
? requestPath.slice(matched.length)
: requestPath;
const prefix = target && target !== "/" ? target : "";
return `${prefix}${stripped}` || "/";
}
return requestPath.replace(new RegExp(matchRegex), target || "/");
}
default:
return requestPath;
}
}
function escapeRegex(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+127
View File
@@ -0,0 +1,127 @@
import logger from "@server/logger";
import config from "@server/lib/config";
import {
buildHostRule,
appendPathMatch,
computeRedirectPriority
} from "@server/lib/traefik/rule";
export type RedirectRouteRow = {
redirectId: number;
/** Host the redirect listens on (resource fullDomain or subdomain.baseDomain). */
fullDomain: string;
hasSubdomain: boolean;
attachedTo: "resource" | "domain";
enabled: boolean;
name: string;
wildcard: boolean | null;
ssl: boolean;
matchPath: string | null;
pathMatchType: string;
priority: number | null;
domainCertResolver?: string | null;
preferWildcardCert?: boolean | null;
};
// Traefik requires a service on every router, but a redirect router's
// middleware chain always terminates the request with a 30x, so the service
// is never reached. noop@internal answers 418 if it ever is - treat that as
// a bug in the middleware chain, not something to route around.
const NOOP_SERVICE = "noop@internal";
export function buildRedirectConfig(params: {
config_output: any;
redirects: RedirectRouteRow[];
badgerMiddlewareName: string;
redirectHttpsMiddlewareName: string;
resolveTls: (row: RedirectRouteRow) => any | null;
}): void {
const {
config_output,
redirects,
badgerMiddlewareName,
redirectHttpsMiddlewareName,
resolveTls
} = params;
if (redirects.length === 0) {
return;
}
const httpEntrypoint = config.getRawConfig().traefik.http_entrypoint;
const httpsEntrypoint = config.getRawConfig().traefik.https_entrypoint;
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [badgerMiddlewareName, ...additionalMiddlewares];
for (const redirect of redirects) {
const routerName = `${redirect.redirectId}-redirect-${redirect.name}-router`;
logger.debug(
`Processing redirect ${redirect.name} with domain ${redirect.fullDomain}`
);
if (!redirect.enabled) {
logger.debug(
`Redirect ${redirect.name} is disabled, skipping Traefik config`
);
continue;
}
let tls: any = {};
if (redirect.ssl) {
tls = resolveTls(redirect);
if (tls === null) {
continue;
}
}
if (!config_output.http.routers) {
config_output.http.routers = {};
}
if (redirect.matchPath && redirect.pathMatchType === "regex") {
try {
new RegExp(redirect.matchPath);
} catch {
logger.debug(
`Invalid regex pattern in redirect ${redirect.redirectId} match path: ${redirect.matchPath}`
);
continue;
}
}
const rule = appendPathMatch(
buildHostRule(redirect.fullDomain, redirect.wildcard),
redirect.matchPath,
redirect.pathMatchType
);
const priority = computeRedirectPriority(
redirect.priority,
redirect.matchPath,
redirect.pathMatchType
);
// if resource is already attached to resource, we don't need to add the https redirect
// as it is already added in the resource traefik config
if (redirect.attachedTo !== "resource" && redirect.ssl) {
config_output.http.routers[`${routerName}-redirect`] = {
entryPoints: [httpEntrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: NOOP_SERVICE,
rule,
priority
};
}
config_output.http.routers[routerName] = {
entryPoints: [redirect.ssl ? httpsEntrypoint : httpEntrypoint],
middlewares: routerMiddlewares,
service: NOOP_SERVICE,
rule,
priority,
...(redirect.ssl ? { tls } : {})
};
}
}
+138
View File
@@ -0,0 +1,138 @@
import { assertEquals } from "../../../test/assert";
import {
computeRedirectPriority,
computeRoutePriority,
matchesPath
} from "./rule";
function runTests() {
console.log("Running matchesPath tests...");
// No path config matches everything
assertEquals(matchesPath("/anything", null, null), true, "null path");
assertEquals(matchesPath("/anything", "/a", null), true, "null type");
assertEquals(
matchesPath("/anything", null, "prefix"),
true,
"null path w/ type"
);
// exact
assertEquals(matchesPath("/api", "/api", "exact"), true, "exact match");
assertEquals(
matchesPath("/api/", "/api", "exact"),
false,
"exact trailing slash"
);
assertEquals(matchesPath("/api/x", "/api", "exact"), false, "exact child");
assertEquals(
matchesPath("/api", "api", "exact"),
true,
"exact leading slash added"
);
// prefix (segment-aware, like Traefik v3 PathPrefix)
assertEquals(
matchesPath("/products", "/products", "prefix"),
true,
"prefix itself"
);
assertEquals(
matchesPath("/products/", "/products", "prefix"),
true,
"prefix slash"
);
assertEquals(
matchesPath("/products/shoes", "/products", "prefix"),
true,
"prefix child"
);
assertEquals(
matchesPath("/productsforsale", "/products", "prefix"),
false,
"prefix partial segment"
);
assertEquals(
matchesPath("/products/shoes", "/products/", "prefix"),
true,
"prefix with trailing slash"
);
assertEquals(
matchesPath("/products", "/products/", "prefix"),
false,
"trailing-slash prefix vs bare"
);
assertEquals(
matchesPath("/other", "/products", "prefix"),
false,
"prefix miss"
);
// regex (unanchored, like PathRegexp)
assertEquals(
matchesPath("/api/v1/x", "^/api/.*", "regex"),
true,
"regex anchored"
);
assertEquals(
matchesPath("/x/api/v1", "/api/", "regex"),
true,
"regex unanchored"
);
assertEquals(matchesPath("/foo", "^/api", "regex"), false, "regex miss");
assertEquals(
matchesPath("/foo", "(", "regex"),
false,
"invalid regex never matches"
);
console.log("All matchesPath tests passed!");
console.log("Running priority tests...");
// Resource routers: explicit override, else derived from path specificity
assertEquals(computeRoutePriority(null, null, null), 100, "default");
assertEquals(computeRoutePriority(100, null, null), 100, "100 is auto");
assertEquals(computeRoutePriority(500, "/a", "exact"), 500, "explicit");
assertEquals(computeRoutePriority(null, "/a", "exact"), 115, "exact");
assertEquals(computeRoutePriority(null, "/a", "prefix"), 113, "prefix");
assertEquals(computeRoutePriority(null, "/a", "regex"), 112, "regex");
assertEquals(computeRoutePriority(null, "/", "prefix"), 1, "catch-all");
// Redirect routers always land above any resource router (max 1000)
assertEquals(
computeRedirectPriority(null, null, null),
1100,
"redirect default"
);
assertEquals(
computeRedirectPriority(null, "/", "prefix"),
1001,
"redirect catch-all"
);
assertEquals(
computeRedirectPriority(1, null, null),
1001,
"redirect lowest explicit"
);
assertEquals(
computeRedirectPriority(1000, null, null),
2000,
"redirect highest explicit"
);
assertEquals(
computeRedirectPriority(1, "/", "prefix") >
computeRoutePriority(1000, "/a", "exact"),
true,
"weakest redirect beats strongest resource"
);
console.log("All priority tests passed!");
}
try {
runTests();
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}
+64
View File
@@ -36,6 +36,48 @@ export function appendPathMatch(
return rule;
}
/**
* Server-side equivalent of the clause appendPathMatch emits, so badger can
* tell whether a request would have matched a given path config. Mirrors
* Traefik v3 semantics: Path is exact, PathPrefix is segment-aware
* (`/products` matches `/products/shoes` but not `/productsforsale`), and
* PathRegexp is an unanchored regex test.
*/
export function matchesPath(
requestPath: string,
path: string | null | undefined,
pathMatchType: string | null | undefined
): boolean {
if (!path || !pathMatchType) return true;
if (pathMatchType === "regex") {
try {
return new RegExp(path).test(requestPath);
} catch {
return false;
}
}
let p = path;
if (!p.startsWith("/")) {
p = `/${p}`;
}
if (pathMatchType === "exact") {
return requestPath === p;
} else if (pathMatchType === "prefix") {
if (!requestPath.startsWith(p)) {
return false;
}
if (p.endsWith("/")) {
return true;
}
const rest = requestPath.slice(p.length);
return rest === "" || rest.startsWith("/");
}
return true;
}
// Compute the router priority for a resource, favoring an explicit override
// and otherwise deriving it from the path match specificity.
export function computeRoutePriority(
@@ -63,3 +105,25 @@ export function computeRoutePriority(
}
return p;
}
// Redirects must always be evaluated before resource routers on the same
// host. Target and redirect priorities are both capped at 1000, so lifting
// every redirect by this offset puts them in a band (1001-2000) no resource
// router can reach, while explicit priorities still order redirects among
// themselves.
export const REDIRECT_PRIORITY_OFFSET = 1000;
/**
* Compute the router priority for a redirect: the same derivation as a
* resource router, shifted into the redirect band.
*/
export function computeRedirectPriority(
priority: number | null | undefined,
path: string | null | undefined,
pathMatchType: string | null | undefined
): number {
return (
computeRoutePriority(priority, path, pathMatchType) +
REDIRECT_PRIORITY_OFFSET
);
}
+42 -3
View File
@@ -1,8 +1,8 @@
import { Target } from "@server/db";
import type { Domain, Resource, Target } from "@server/db";
// Extended target type with site information, shared between the OSS and
// Target subset with site information, shared between the OSS and
// private getTraefikConfig implementations.
export type TargetWithSite = Target & {
export type TargetWithSite = {
resourceId: number;
targetId: number;
ip: string | null;
@@ -19,3 +19,42 @@ export type TargetWithSite = Target & {
online: boolean;
};
};
// A resource grouped with its targets for router/service generation. Every
// target in a group shares the same path/rewrite config, so those columns
// live on the resource rather than on each target.
export type ResourceWithTargets = Pick<
Resource,
| "resourceId"
| "fullDomain"
| "ssl"
| "proxyPort"
| "subdomain"
| "domainId"
| "enabled"
| "stickySession"
| "tlsServerName"
| "setHostHeader"
| "enableProxy"
| "headers"
| "proxyProtocol"
| "wildcard"
| "mode"
| "maintenanceModeEnabled"
| "maintenanceModeType"
| "maintenanceTitle"
| "maintenanceMessage"
| "maintenanceEstimatedTime"
> &
Pick<Target, "path" | "pathMatchType" | "rewritePath" | "rewritePathType"> & {
/** Sanitized resource name used in router/service names */
name: string;
/** Sanitized resourceId + path config, unique per router */
key: string;
priority: number;
proxyProtocolVersion: number;
// Left-joined from the resource's domain, so absent when there is none
domainCertResolver: Domain["certResolver"] | null;
preferWildcardCert: Domain["preferWildcardCert"] | null;
targets: TargetWithSite[];
};
+2 -1
View File
@@ -32,7 +32,8 @@ export enum OpenAPITags {
AiProvider = "AI Provider",
AiModel = "AI Model",
AiBudget = "AI Budget",
VirtualApiKey = "Virtual API Key"
VirtualApiKey = "Virtual API Key",
Redirect = "Redirect"
}
// Order here controls the order tags are displayed in Swagger UI
+133 -6
View File
@@ -18,6 +18,7 @@ import {
domains,
exitNodes,
loginPage,
redirects,
SiteResource,
targetHealthCheck
} from "@server/db";
@@ -56,7 +57,7 @@ import {
import { build } from "@server/build";
import license from "#private/license/license";
import regionalCache from "#private/lib/cache";
import { TargetWithSite } from "@server/lib/traefik/types";
import { ResourceWithTargets } from "@server/lib/traefik/types";
import { buildWildcardTls } from "@server/lib/traefik/certResolver";
import {
buildHostRule,
@@ -85,6 +86,10 @@ import {
buildBrowserGatewayConfig
} from "@server/lib/traefik/browserGateway";
import { buildSiteResourceAliasCertPlaceholders } from "@server/lib/traefik/siteResourceAlias";
import {
buildRedirectConfig,
RedirectRouteRow
} from "@server/lib/traefik/redirect";
const redirectHttpsMiddlewareName = "redirect-to-https";
const redirectToRootMiddlewareName = "redirect-to-root";
@@ -214,7 +219,7 @@ export async function getTraefikConfig(
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
// Group by resource and include targets with their unique site data
const resourcesMap = new Map();
const resourcesMap = new Map<string, ResourceWithTargets>();
for (const row of resourcesWithTargetsAndSites) {
if (!["http", "tcp", "udp"].includes(row.mode)) {
@@ -242,7 +247,7 @@ export async function getTraefikConfig(
.filter(Boolean)
.join("-");
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
const key = sanitize(mapKey);
const key = sanitize(mapKey) ?? "";
if (!resourcesMap.has(mapKey)) {
const validation = validatePathRewriteConfig(
@@ -296,7 +301,7 @@ export async function getTraefikConfig(
}
// Add target with its associated site data
resourcesMap.get(mapKey).targets.push({
resourcesMap.get(mapKey)!.targets.push({
resourceId: row.resourceId,
targetId: row.targetId,
ip: row.ip,
@@ -396,6 +401,89 @@ export async function getTraefikConfig(
)
);
// Redirects have no targets/sites, so like inference resources they are
// queried separately and emitted on every exit node. A redirect listens
// either on a resource's fullDomain or on subdomain.baseDomain of a
// domain; the domain join resolves to whichever one applies.
const redirectRows = await db
.select({
name: redirects.name,
enabled: redirects.enabled,
redirectId: redirects.redirectId,
subdomain: redirects.subdomain,
matchPath: redirects.matchPath,
pathMatchType: redirects.pathMatchType,
priority: redirects.priority,
ssl: redirects.ssl,
// Resource (when attached to one)
resourceId: resources.resourceId,
resourceFullDomain: resources.fullDomain,
resourceSubdomain: resources.subdomain,
resourceSsl: resources.ssl,
resourceWildcard: resources.wildcard,
// Domain (the redirect's own, or the resource's)
baseDomain: domains.baseDomain,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert,
domainNamespaceId: domainNamespaces.domainNamespaceId
})
.from(redirects)
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
.leftJoin(
domains,
eq(
domains.domainId,
sql`coalesce(${redirects.domainId}, ${resources.domainId})`
)
)
.leftJoin(
domainNamespaces,
eq(domainNamespaces.domainId, domains.domainId)
)
.where(
and(
eq(redirects.enabled, true),
or(isNull(redirects.resourceId), eq(resources.enabled, true))
)
)
.orderBy(desc(redirects.priority), redirects.redirectId); // stable ordering
const redirectRoutes: RedirectRouteRow[] = [];
for (const row of redirectRows) {
if (filterOutNamespaceDomains && row.domainNamespaceId) {
continue;
}
const attachedToResource = row.resourceId !== null;
const fullDomain = attachedToResource
? row.resourceFullDomain
: [row.subdomain, row.baseDomain].filter(Boolean).join(".");
if (!fullDomain) {
logger.debug(
`Redirect ${row.redirectId} has no host to listen on, skipping Traefik config`
);
continue;
}
redirectRoutes.push({
enabled: row.enabled,
name: sanitize(row.name) || "",
redirectId: row.redirectId,
fullDomain,
hasSubdomain: attachedToResource
? !!row.resourceSubdomain
: !!row.subdomain,
wildcard: row.resourceWildcard,
ssl: attachedToResource ? !!row.resourceSsl : row.ssl,
attachedTo: attachedToResource ? "resource" : "domain",
matchPath: row.matchPath,
pathMatchType: row.pathMatchType,
priority: row.priority,
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert
});
}
// Pangolin-managed DNS-01/ACME cert mode requires either a tier1
// license (self-hosted) or a saas build - otherwise fall back to
// Traefik's own cert resolvers (buildWildcardTls) throughout.
@@ -435,6 +523,12 @@ export async function getTraefikConfig(
domains.add(sr.fullDomain);
}
}
// Include redirect hosts
for (const redirect of redirectRoutes) {
if (redirect.ssl) {
domains.add(redirect.fullDomain);
}
}
// get the valid certs for these domains
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
@@ -467,9 +561,14 @@ export async function getTraefikConfig(
}
};
console.dir(
{ resourcesMap, resourcesWithTargetsAndSites },
{ depth: null }
);
// get the key and the resource
for (const [, resource] of resourcesMap.entries()) {
const targets = resource.targets as TargetWithSite[];
const targets = resource.targets;
const key = resource.key;
const routerName = `${key}-${resource.name}-router`;
@@ -592,7 +691,7 @@ export async function getTraefikConfig(
if (!target.site.online) return false;
if (target.health == "unhealthy") return false;
if (target.health === "unhealthy") return false;
return true;
});
@@ -781,6 +880,34 @@ export async function getTraefikConfig(
}
}
buildRedirectConfig({
config_output,
redirects: redirectRoutes,
badgerMiddlewareName,
redirectHttpsMiddlewareName,
resolveTls: (redirect) => {
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
return buildWildcardTls({
fullDomain: redirect.fullDomain,
hasSubdomain: redirect.hasSubdomain,
domainCertResolver: redirect.domainCertResolver,
preferWildcardCert:
redirect.preferWildcardCert || redirect.wildcard
});
}
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === redirect.fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for redirect domain: ${redirect.fullDomain}`
);
return null;
}
return {};
}
});
if (browserGatewayUiUrl) {
buildBrowserGatewayConfig({
config_output,
@@ -243,6 +243,14 @@ export async function handleSubscriptionCreated(
`License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}`
);
// Grace period of 5 days added on top of the current billing
// period end (usually ~1 year out) before the license expires
const currentPeriodEnd =
fullSubscription.items.data[0]?.current_period_end;
const expiresAt =
(currentPeriodEnd ?? subscription.created) +
5 * 24 * 60 * 60;
const response = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
{
@@ -258,7 +266,8 @@ export async function handleSubscriptionCreated(
paidFor: true,
users: numUsers,
sites: numSites,
tier: tier
tier: tier,
expiresAt: expiresAt
})
}
);
@@ -267,6 +276,13 @@ export async function handleSubscriptionCreated(
logger.debug(`Fossorial API response: ${JSON.stringify(data)}`);
if (!response.ok || !data.success) {
logger.error(
`Fossorial API returned ${response.status} when setting paid-for for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${JSON.stringify(data)}`
);
return;
}
if (customer.email) {
logger.debug(
`Sending license key email to ${customer.email} for subscription ${subscription.id}`
@@ -125,9 +125,7 @@ export async function handleSubscriptionDeleted(
`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
);
try {
// WARNING:
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
await fetch(
const invalidateResponse = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
{
method: "POST",
@@ -139,9 +137,18 @@ export async function handleSubscriptionDeleted(
},
body: JSON.stringify({
orgId: customer.orgId,
licenseKeyId: parseInt(
subscription.metadata.licenseKeyId
)
})
}
);
if (!invalidateResponse.ok) {
logger.error(
`Fossorial API returned ${invalidateResponse.status} when invalidating license for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${await invalidateResponse.text()}`
);
}
} catch (error) {
logger.error(
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
@@ -23,7 +23,10 @@ import {
} from "@server/db";
import { eq, and } from "drizzle-orm";
import logger from "@server/logger";
import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features";
import {
getFeatureIdByMetricId,
getFeatureIdByPriceId
} from "@server/lib/billing/features";
import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { getSubType, SubscriptionType } from "./getSubType";
@@ -66,7 +69,8 @@ export async function handleSubscriptionUpdated(
.limit(1);
const type = getSubType(fullSubscription);
const previousType = existingSubscription.type as SubscriptionType | null;
const previousType =
existingSubscription.type as SubscriptionType | null;
// If the subscription has been manually overridden, we lock the
// status down so Stripe webhooks can no longer change it.
@@ -100,7 +104,11 @@ export async function handleSubscriptionUpdated(
logger.info(
`Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}`
);
await handleTierChange(customer.orgId, type, previousType ?? undefined);
await handleTierChange(
customer.orgId,
type,
previousType ?? undefined
);
}
// Upsert subscription items
@@ -113,7 +121,8 @@ export async function handleSubscriptionUpdated(
const itemsToUpsert = fullSubscription.items.data.map((item) => {
// Try to get featureId from price
let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null;
let featureId: string | null =
getFeatureIdByPriceId(item.price.id) || null;
// If no match, try to preserve existing featureId
if (!featureId) {
@@ -302,14 +311,20 @@ export async function handleSubscriptionUpdated(
logger.info(
`Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features`
);
await handleTierChange(customer.orgId, null, previousType ?? undefined);
await handleTierChange(
customer.orgId,
null,
previousType ?? undefined
);
}
} else if (type === "license") {
if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") {
if (
effectiveStatus === "canceled" ||
effectiveStatus == "unpaid" ||
effectiveStatus == "incomplete_expired"
) {
try {
// WARNING:
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
await fetch(
const invalidateResponse = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
{
method: "POST",
@@ -320,16 +335,95 @@ export async function handleSubscriptionUpdated(
"Content-Type": "application/json"
},
body: JSON.stringify({
orgId: customer.orgId
orgId: customer.orgId,
licenseKeyId: parseInt(
subscription.metadata.licenseKeyId
)
})
}
);
if (!invalidateResponse.ok) {
logger.error(
`Fossorial API returned ${invalidateResponse.status} when invalidating license for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${await invalidateResponse.text()}`
);
}
} catch (error) {
logger.error(
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
error
);
}
} else if (effectiveStatus === "active" && previousAttributes) {
// Detect a successful renewal: the billing period rolled
// forward (the invoice was paid and the new period began
// right where the previous one ended).
const currentItem = fullSubscription.items.data[0];
const prevItems = previousAttributes.items?.data;
const prevItem = Array.isArray(prevItems)
? prevItems.find(
(pi: any) => pi.id === currentItem?.id
)
: undefined;
const renewed =
currentItem &&
prevItem?.current_period_end &&
currentItem.current_period_start ===
prevItem.current_period_end &&
currentItem.current_period_start >
prevItem.current_period_start;
if (renewed) {
const licenseKeyId =
subscription.metadata.licenseKeyId;
if (!licenseKeyId) {
logger.error(
`No licenseKeyId in metadata for subscription ${subscription.id}, cannot extend license.`
);
} else {
// Grace period of 5 days added on top of the new
// billing period end (usually ~1 year out)
const expiresAt =
currentItem.current_period_end +
5 * 24 * 60 * 60;
try {
const extendResponse = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/extend`,
{
method: "POST",
headers: {
"api-key":
privateConfig.getRawPrivateConfig()
.server.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseId: parseInt(licenseKeyId),
expiresAt: expiresAt
})
}
);
if (!extendResponse.ok) {
logger.error(
`Fossorial API returned ${extendResponse.status} when extending license ${licenseKeyId} for subscription ${subscription.id}: ${await extendResponse.text()}`
);
} else {
logger.info(
`Extended license ${licenseKeyId} for subscription ${subscription.id} to expire at ${expiresAt}.`
);
}
} catch (error) {
logger.error(
`Error notifying Fossorial API of license renewal for subscription ${subscription.id}:`,
error
);
}
}
}
}
}
}
@@ -96,6 +96,8 @@ export async function generateNewEnterpriseLicense(
);
}
const licenseKeyValue = apiResponse?.data?.licenseKey?.licenseKey;
// check if we already have a customer for this org
const [customer] = await db
.select()
@@ -129,6 +131,16 @@ export async function generateNewEnterpriseLicense(
], // Start with the standard feature set that matches the free limits
customer: customer.customerId,
mode: "subscription",
subscription_data: {
description: licenseKeyValue
? `License ${licenseKeyValue}`
: `License key ID ${keyId}`,
metadata: {
licenseKeyId: keyId.toString(),
licenseKey: licenseKeyValue ?? "",
tier: licenseData.tier
}
},
allow_promotion_codes: true,
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true`
+1
View File
@@ -20,6 +20,7 @@ Reasons:
107 - Valid SSO
108 - Connected Client
109 - Valid Virtual API Key
110 - Allowed Redirect
201 - Resource Not Found
202 - Resource Blocked
+98 -3
View File
@@ -10,6 +10,8 @@ import {
verifyVirtualApiKey
} from "@server/auth/verifyVirtualApiKey";
import {
type RedirectByHost,
getRedirectsByHost,
getResourceByDomain,
getResourceRules,
getRoleResourceAccess,
@@ -40,6 +42,8 @@ import {
import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
import { isPathAllowed } from "@server/lib/pathMatch";
import { matchesPath } from "@server/lib/traefik/rule";
import { rewriteRequestPath } from "@server/lib/traefik/middleware";
import { response } from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
@@ -67,6 +71,7 @@ import { APP_VERSION } from "@server/lib/consts";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { eq } from "drizzle-orm";
import type ResponseT from "@server/types/MessageResponse";
const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string(), z.string()).optional(),
@@ -108,6 +113,9 @@ export type VerifyUserResponse = {
valid: boolean;
headerAuthChallenged?: boolean;
redirectUrl?: string;
// Set alongside redirectUrl when the redirect is a configured Redirect
// rather than a login bounce, so badger can answer 307 instead of 302.
redirectPermanent?: boolean;
userData?: BasicUserData;
pangolinVersion?: string;
dontStripSession?: boolean;
@@ -192,6 +200,30 @@ export async function verifyResourceSession(
cleanHost = cleanHost.slice(0, -1 * matched.length);
}
// Redirects always win: they are routed ahead of resources in
// Traefik and never require auth, even when attached to a resource,
// so let a matching one through to the redirect middleware before
// any resource lookup.
const redirect = await findRedirect(cleanHost, path);
if (redirect) {
const redirectUrl = buildRedirectUrl(redirect, parsedBody.data);
logger.debug(
`Redirecting ${cleanHost}${path} to ${redirectUrl} (redirect ${redirect.redirectId})`
);
logRequestAudit(
{
action: true,
reason: 110, // redirected
orgId: redirect.orgId,
location: ipCC
},
parsedBody.data
);
return redirected(res, redirectUrl, redirect.permanent);
}
const resourceCacheKey = `resource:${cleanHost}`;
let resourceData:
| {
@@ -199,9 +231,7 @@ export async function verifyResourceSession(
pincode: ResourcePincode | ResourcePolicyPincode | null;
password: ResourcePassword | ResourcePolicyPassword | null;
headerAuth:
| ResourceHeaderAuth
| ResourcePolicyHeaderAuth
| null;
ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
applyRules: boolean | null;
sso: boolean | null;
@@ -1009,6 +1039,71 @@ function extractResourceSessionToken(
return latest.token;
}
async function findRedirect(
host: string,
path: string
): Promise<RedirectByHost | null> {
const cacheKey = `redirects:${host}`;
let candidates: RedirectByHost[] | undefined = localCache.get(cacheKey);
if (!candidates) {
candidates = await getRedirectsByHost(host);
localCache.set(cacheKey, candidates, 5);
}
// Candidates come back highest priority first, matching the order
// Traefik evaluates the routers in.
return (
candidates.find((r) =>
matchesPath(path, r.matchPath, r.pathMatchType)
) ?? null
);
}
/**
* Destination for a configured redirect: the scheme and host come from the
* redirect's destination, the request's query is kept and the path is run
* through the redirect's rewrite rules (if any).
*/
function buildRedirectUrl(
redirect: RedirectByHost,
request: VerifyResourceSessionSchema
): string {
const newPath = rewriteRequestPath(
request.path,
redirect.matchPath,
redirect.pathMatchType,
redirect.rewritePath,
redirect.rewritePathType
);
let search = "";
try {
search = new URL(request.originalRequestURL).search;
} catch {
// originalRequestURL is validated as a URL, so this is only defensive
}
return `${redirect.destinationHost}${newPath}${search}`;
}
// Like a notAllowed login bounce, but the destination is the configured
// redirect target rather than the auth page.
function redirected(res: Response, redirectUrl: string, permanent: boolean) {
const data = {
data: {
valid: true,
redirectUrl,
redirectPermanent: permanent,
pangolinVersion: APP_VERSION
},
success: true,
error: false,
message: "Redirected",
status: HttpCode.OK
} satisfies ResponseT<VerifyUserResponse>;
return response<VerifyUserResponse>(res, data);
}
async function notAllowed(
res: Response,
redirectPath?: string,
+45
View File
@@ -62,6 +62,7 @@ import { createStore } from "#dynamic/lib/rateLimitStore";
import { checkRoundTripMessage } from "./ws";
import * as labels from "@server/routers/labels";
import * as aiProvider from "@server/routers/aiProvider";
import * as redirect from "@server/routers/redirect";
import * as aiBudget from "@server/routers/aiBudget";
import * as virtualApiKey from "@server/routers/virtualApiKey";
import * as certificates from "@server/routers/certificates";
@@ -1634,6 +1635,50 @@ authenticated.delete(
aiProvider.deleteAiProvider
);
authenticated.put(
"/org/:orgId/redirect",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createRedirect),
logActionAudit(ActionsEnum.createRedirect),
redirect.createRedirect
);
authenticated.get(
"/org/:orgId/redirects",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listRedirects),
redirect.listRedirects
);
authenticated.get(
"/org/:orgId/redirects/:redirectId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getRedirect),
redirect.getRedirect
);
authenticated.get(
"/org/:orgId/redirect/:niceId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getRedirect),
redirect.getRedirect
);
authenticated.post(
"/org/:orgId/redirects/:redirectId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.updateRedirect),
logActionAudit(ActionsEnum.updateRedirect),
redirect.updateRedirect
);
authenticated.delete(
"/org/:orgId/redirects/:redirectId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteRedirect),
logActionAudit(ActionsEnum.deleteRedirect),
redirect.deleteRedirect
);
authenticated.put(
"/ai-provider/:providerId/model",
verifyAiProviderAccess,
+24
View File
@@ -0,0 +1,24 @@
import { sendToClient } from "#dynamic/routers/ws";
// Error codes for registration failures
export const NewtErrorCodes = {
NO_AVAILABLE_SUBNET: {
code: "NO_AVAILABLE_SUBNET",
message:
"No available subnet could be assigned to this site on its exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
}
} as const;
// Helper function to send registration error
export async function sendNewtError(
error: (typeof NewtErrorCodes)[keyof typeof NewtErrorCodes],
newtId: string
) {
sendToClient(newtId, {
type: "newt/error",
data: {
code: error.code,
message: error.message
}
});
}
@@ -14,6 +14,7 @@ import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
import { fetchContainers } from "./dockerSocket";
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
import { canCompress } from "@server/lib/clientVersionChecks";
import { NewtErrorCodes, sendNewtError } from "./error";
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context;
@@ -116,6 +117,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
logger.error(
`No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}`
);
sendNewtError(NewtErrorCodes.NO_AVAILABLE_SUBNET, newt.newtId);
return;
}
+5
View File
@@ -94,6 +94,11 @@ export const OlmErrorCodes = {
HOLEPUNCH_MISSING: {
code: "HOLEPUNCH_MISSING",
message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.`
},
NO_AVAILABLE_SUBNET: {
code: "NO_AVAILABLE_SUBNET",
message:
"No available subnet could be assigned to this client on the selected exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
}
} as const;
@@ -347,6 +347,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
{ orgId: client.orgId, clientId: client.clientId }
);
sendOlmError(OlmErrorCodes.NO_AVAILABLE_SUBNET, olm.olmId);
return;
}
+239
View File
@@ -0,0 +1,239 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, domains, orgDomains, redirects, resources } from "@server/db";
import type { Domain, Redirect, Resource } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import {
redirectDestinationHostSchema,
redirectMatchPathSchema,
redirectPathMatchTypeSchema,
redirectRewritePathSchema,
isValidMatchPath,
isAllowedSsl,
redirectPrioritySchema,
redirectRewritePathTypeSchema
} from "@server/routers/redirect/validation";
import { getUniqueRedirectName } from "@server/db/names";
import { createCertificate } from "../certificates";
export type CreateRedirectResponse = {
redirect: Redirect;
};
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const bodySchema = z
.strictObject({
name: z.string().nonempty(),
resourceId: z.number().int().positive().optional().nullable(),
domainId: z.string().nonempty().optional().nullable(),
subdomain: z.string().nonempty().optional().nullable(),
destinationHost: redirectDestinationHostSchema,
pathMatchType: redirectPathMatchTypeSchema.optional(),
matchPath: redirectMatchPathSchema.optional().nullable(),
rewritePath: redirectRewritePathSchema.optional().nullable(),
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
priority: redirectPrioritySchema.optional().nullable(),
permanent: z.boolean().optional(),
ssl: z.boolean().optional(),
enabled: z.boolean().optional()
})
.refine(
(data) =>
// stripPrefix removes the matched prefix and needs no replacement
// value; every other rewrite type is meaningless without one.
!data.rewritePathType ||
data.rewritePathType === "stripPrefix" ||
Boolean(data.rewritePath),
{
message:
"rewritePath is required unless rewritePathType is stripPrefix",
path: ["rewritePath"]
}
)
.refine((data) => Boolean(data.resourceId) !== Boolean(data.domainId), {
message: "Exactly one of resourceId or domainId must be provided",
path: ["resourceId"]
})
.refine((data) => isValidMatchPath(data.matchPath, data.pathMatchType), {
message: "matchPath must be a valid regular expression",
path: ["matchPath"]
})
.refine((data) => isAllowedSsl(data.ssl), {
message: "TLS cannot be disabled on this build",
path: ["ssl"]
});
registry.registerPath({
method: "put",
path: "/org/{orgId}/redirect",
description: "Create a redirect for an organization.",
tags: [OpenAPITags.Redirect],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
201: {
description: "Successful response"
}
}
});
export async function createRedirect(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const {
name,
resourceId,
domainId,
subdomain,
destinationHost,
pathMatchType,
matchPath,
rewritePath,
rewritePathType,
priority,
permanent,
ssl,
enabled
} = parsedBody.data;
let resource: Resource | null = null;
if (resourceId) {
const res = await db
.select()
.from(resources)
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
resource = res.at(0) ?? null;
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
}
let domain: Domain | null = null;
if (domainId) {
const res = await db
.select()
.from(domains)
.innerJoin(
orgDomains,
eq(orgDomains.domainId, domains.domainId)
)
.where(
and(
eq(domains.domainId, domainId),
eq(orgDomains.orgId, orgId)
)
)
.limit(1);
domain = res.at(0)?.domains ?? null;
if (!domain) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Domain with ID ${domainId} not found`
)
);
}
}
const niceId = await getUniqueRedirectName(orgId);
const [redirect] = await db
.insert(redirects)
.values({
orgId,
name,
niceId,
resourceId: resourceId ?? null,
domainId: domainId ?? null,
subdomain: subdomain ?? null,
destinationHost,
pathMatchType: pathMatchType ?? "regex",
matchPath: matchPath ?? null,
rewritePath: rewritePath ?? null,
rewritePathType: rewritePathType ?? null,
priority: priority ?? 100,
permanent: permanent ?? false,
// Resource-attached redirects follow the resource's ssl, so
// the column is only meaningful for domain-attached ones.
ssl: resource ? true : (ssl ?? true),
enabled: enabled ?? true
})
.returning();
if (domain) {
const fullDomain = [subdomain ?? null, domain.baseDomain]
.filter(Boolean)
.join(".");
await createCertificate(domain.domainId, fullDomain, db);
}
return response<CreateRedirectResponse>(res, {
data: {
redirect
},
success: true,
error: false,
message: "Redirect created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+92
View File
@@ -0,0 +1,92 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { redirects, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
redirectId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "delete",
path: "/org/{orgId}/redirects/{redirectId}",
description: "Delete a redirect.",
tags: [OpenAPITags.Redirect],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function deleteRedirect(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, redirectId } = parsedParams.data;
const [existing] = await db
.select({ redirectId: redirects.redirectId })
.from(redirects)
.where(
and(
eq(redirects.redirectId, redirectId),
eq(redirects.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Redirect with ID ${redirectId} not found`
)
);
}
await db
.delete(redirects)
.where(
and(
eq(redirects.redirectId, redirectId),
eq(redirects.orgId, orgId)
)
);
return response(res, {
data: null,
success: true,
error: false,
message: "Redirect deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+186
View File
@@ -0,0 +1,186 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { domains, redirects, resources, db } from "@server/db";
import response from "@server/lib/response";
import stoi from "@server/lib/stoi";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
export type GetRedirectResponse = {
redirect: {
redirectId: number;
orgId: string;
niceId: string;
name: string;
subdomain: string | null;
destinationHost: string;
pathMatchType: "exact" | "prefix" | "regex";
matchPath: string | null;
rewritePath: string | null;
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
priority: number | null;
permanent: boolean;
ssl: boolean;
enabled: boolean;
resourceId: number | null;
resourceName: string | null;
resourceNiceId: string | null;
resourceFullDomain: string | null;
resourceSsl: boolean | null;
resourceWildcard: boolean | null;
domainId: string | null;
baseDomain: string | null;
};
};
const redirectColumns = {
redirectId: redirects.redirectId,
orgId: redirects.orgId,
niceId: redirects.niceId,
name: redirects.name,
subdomain: redirects.subdomain,
destinationHost: redirects.destinationHost,
pathMatchType: redirects.pathMatchType,
matchPath: redirects.matchPath,
rewritePath: redirects.rewritePath,
rewritePathType: redirects.rewritePathType,
priority: redirects.priority,
permanent: redirects.permanent,
ssl: redirects.ssl,
enabled: redirects.enabled,
resourceId: redirects.resourceId,
resourceName: resources.name,
resourceNiceId: resources.niceId,
resourceFullDomain: resources.fullDomain,
resourceSsl: resources.ssl,
resourceWildcard: resources.wildcard,
domainId: redirects.domainId,
baseDomain: domains.baseDomain
};
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
redirectId: z
.string()
.optional()
.transform(stoi)
.pipe(z.int().positive().optional())
.optional(),
niceId: z.string().optional()
});
async function query(orgId: string, redirectId?: number, niceId?: string) {
if (redirectId) {
const [res] = await db
.select(redirectColumns)
.from(redirects)
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
.where(
and(
eq(redirects.redirectId, redirectId),
eq(redirects.orgId, orgId)
)
)
.limit(1);
return res;
} else if (niceId) {
const [res] = await db
.select(redirectColumns)
.from(redirects)
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
.where(
and(eq(redirects.niceId, niceId), eq(redirects.orgId, orgId))
)
.limit(1);
return res;
}
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/redirects/{redirectId}",
description: "Get a redirect by ID.",
tags: [OpenAPITags.Redirect],
request: {
params: z.object({
orgId: z.string(),
redirectId: z.string()
})
},
responses: {
200: {
description: "Successful response"
}
}
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/redirect/{niceId}",
description:
"Get a redirect by orgId and niceId. NiceId is a readable ID for the redirect and unique on a per org basis.",
tags: [OpenAPITags.Redirect],
request: {
params: z.object({
orgId: z.string(),
niceId: z.string()
})
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function getRedirect(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, redirectId, niceId } = parsedParams.data;
const redirect = await query(orgId, redirectId, niceId);
if (!redirect) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Redirect with ID ${redirectId || niceId} not found`
)
);
}
return response<GetRedirectResponse>(res, {
data: {
redirect
},
success: true,
error: false,
message: "Redirect retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./createRedirect";
export * from "./listRedirects";
export * from "./getRedirect";
export * from "./updateRedirect";
export * from "./deleteRedirect";
+203
View File
@@ -0,0 +1,203 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { domains, redirects, resources, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
import type { PaginatedResponse } from "@server/types/Pagination";
export type ListRedirectsResponse = PaginatedResponse<{
redirects: Array<{
redirectId: number;
orgId: string;
niceId: string;
name: string;
subdomain: string | null;
destinationHost: string;
pathMatchType: "exact" | "prefix" | "regex";
matchPath: string | null;
rewritePath: string | null;
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
priority: number | null;
permanent: boolean;
ssl: boolean;
enabled: boolean;
resourceId: number | null;
resourceName: string | null;
resourceNiceId: string | null;
resourceFullDomain: string | null;
resourceDomainId: string | null;
domainId: string | null;
baseDomain: string | null;
}>;
}>;
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const listSchema = z.object({
pageSize: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>()
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/redirects",
description: "List redirects for an organization.",
tags: [OpenAPITags.Redirect],
request: {
params: paramsSchema,
query: listSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listRedirects(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const { pageSize, page, query } = parsedQuery.data;
const conditions = [eq(redirects.orgId, orgId)];
if (query) {
const term = "%" + query.toLowerCase() + "%";
conditions.push(
or(
like(sql`LOWER(${redirects.name})`, term),
like(sql`LOWER(${redirects.matchPath})`, term),
like(sql`LOWER(${redirects.destinationHost})`, term)
)!
);
}
const baseQuery = db
.select({
redirectId: redirects.redirectId,
orgId: redirects.orgId,
niceId: redirects.niceId,
name: redirects.name,
subdomain: redirects.subdomain,
destinationHost: redirects.destinationHost,
pathMatchType: redirects.pathMatchType,
matchPath: redirects.matchPath,
rewritePath: redirects.rewritePath,
rewritePathType: redirects.rewritePathType,
priority: redirects.priority,
permanent: redirects.permanent,
ssl: redirects.ssl,
enabled: redirects.enabled,
resourceId: redirects.resourceId,
resourceName: resources.name,
resourceNiceId: resources.niceId,
resourceFullDomain: resources.fullDomain,
resourceDomainId: resources.domainId,
domainId: redirects.domainId,
baseDomain: domains.baseDomain
})
.from(redirects)
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
.where(and(...conditions));
const countQuery = db.$count(
db
.select()
.from(redirects)
.where(and(...conditions))
.as("filtered_redirects")
);
const [totalCount, rows] = await Promise.all([
countQuery,
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(desc(redirects.priority), desc(redirects.redirectId))
]);
return response<ListRedirectsResponse>(res, {
data: {
redirects: rows,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "Redirects retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+325
View File
@@ -0,0 +1,325 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, domains, orgDomains, redirects, resources } from "@server/db";
import type { Redirect } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, ne } from "drizzle-orm";
import {
redirectNiceIdSchema,
redirectDestinationHostSchema,
redirectMatchPathSchema,
redirectPathMatchTypeSchema,
redirectRewritePathSchema,
redirectRewritePathTypeSchema,
redirectPrioritySchema,
isValidMatchPath,
isAllowedSsl
} from "@server/routers/redirect/validation";
import { createCertificate } from "../certificates";
export type UpdateRedirectResponse = {
redirect: Redirect;
};
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
redirectId: z.coerce.number().int().positive()
});
const bodySchema = z.strictObject({
name: z.string().nonempty().optional(),
niceId: redirectNiceIdSchema.optional(),
resourceId: z.number().int().positive().optional().nullable(),
domainId: z.string().nonempty().optional().nullable(),
subdomain: z.string().nonempty().optional().nullable(),
destinationHost: redirectDestinationHostSchema.optional(),
pathMatchType: redirectPathMatchTypeSchema.optional(),
matchPath: redirectMatchPathSchema.optional().nullable(),
rewritePath: redirectRewritePathSchema.optional().nullable(),
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
priority: redirectPrioritySchema.optional(),
permanent: z.boolean().optional(),
ssl: z.boolean().optional(),
enabled: z.boolean().optional()
});
registry.registerPath({
method: "post",
path: "/org/{orgId}/redirects/{redirectId}",
description: "Update a redirect.",
tags: [OpenAPITags.Redirect],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function updateRedirect(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId, redirectId } = parsedParams.data;
const body = parsedBody.data;
const [existing] = await db
.select()
.from(redirects)
.where(
and(
eq(redirects.redirectId, redirectId),
eq(redirects.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Redirect with ID ${redirectId} not found`
)
);
}
const effectiveResourceId =
body.resourceId !== undefined
? body.resourceId
: existing.resourceId;
const effectiveDomainId =
body.domainId !== undefined ? body.domainId : existing.domainId;
if (Boolean(effectiveResourceId) === Boolean(effectiveDomainId)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Exactly one of resourceId or domainId must be provided"
)
);
}
if (body.resourceId) {
const [resource] = await db
.select({ resourceId: resources.resourceId })
.from(resources)
.where(
and(
eq(resources.resourceId, body.resourceId),
eq(resources.orgId, existing.orgId)
)
)
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${body.resourceId} not found`
)
);
}
}
let domain: { domainId: string; baseDomain: string } | null = null;
if (effectiveDomainId) {
const [d] = await db
.select({
domainId: domains.domainId,
baseDomain: domains.baseDomain
})
.from(domains)
.innerJoin(
orgDomains,
eq(orgDomains.domainId, domains.domainId)
)
.where(
and(
eq(domains.domainId, effectiveDomainId),
eq(orgDomains.orgId, existing.orgId)
)
)
.limit(1);
domain = d ?? null;
if (!domain) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Domain with ID ${effectiveDomainId} not found`
)
);
}
}
if (body.niceId) {
const [existingNiceId] = await db
.select()
.from(redirects)
.where(
and(
eq(redirects.niceId, body.niceId),
eq(redirects.orgId, existing.orgId),
ne(redirects.redirectId, existing.redirectId) // exclude the current redirect from the search
)
)
.limit(1);
if (existingNiceId) {
return next(
createHttpError(
HttpCode.CONFLICT,
`A redirect with niceId "${body.niceId}" already exists`
)
);
}
}
if (
!isValidMatchPath(
body.matchPath !== undefined
? body.matchPath
: existing.matchPath,
body.pathMatchType ?? existing.pathMatchType
)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"matchPath must be a valid regular expression"
)
);
}
if (!isAllowedSsl(body.ssl)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"TLS cannot be disabled on this build"
)
);
}
const updateData: Partial<typeof redirects.$inferInsert> = {};
if (body.name !== undefined) {
updateData.name = body.name;
}
if (body.niceId !== undefined) {
updateData.niceId = body.niceId;
}
if (body.resourceId !== undefined) {
updateData.resourceId = body.resourceId;
}
if (body.domainId !== undefined) {
updateData.domainId = body.domainId;
}
if (body.subdomain !== undefined) {
updateData.subdomain = body.subdomain;
}
if (body.destinationHost !== undefined) {
updateData.destinationHost = body.destinationHost;
}
if (body.pathMatchType !== undefined) {
updateData.pathMatchType = body.pathMatchType;
}
if (body.matchPath !== undefined) {
updateData.matchPath = body.matchPath;
}
if (body.rewritePath !== undefined) {
updateData.rewritePath = body.rewritePath;
}
if (body.rewritePathType !== undefined) {
updateData.rewritePathType = body.rewritePathType;
}
if (body.priority !== undefined) {
updateData.priority = body.priority;
}
if (body.permanent !== undefined) {
updateData.permanent = body.permanent;
}
if (effectiveResourceId) {
// Resource-attached redirects follow the resource's ssl; reset
// the column so a later move back to a domain starts from TLS on.
updateData.ssl = true;
} else if (body.ssl !== undefined) {
updateData.ssl = body.ssl;
}
if (body.enabled !== undefined) {
updateData.enabled = body.enabled;
}
const [redirect] = await db
.update(redirects)
.set(updateData)
.where(
and(
eq(redirects.redirectId, redirectId),
eq(redirects.orgId, orgId)
)
)
.returning();
if (domain) {
const effectiveSubdomain =
body.subdomain !== undefined
? body.subdomain
: existing.subdomain;
const fullDomain = [effectiveSubdomain ?? null, domain.baseDomain]
.filter(Boolean)
.join(".");
await createCertificate(domain.domainId, fullDomain, db);
}
return response<UpdateRedirectResponse>(res, {
data: {
redirect
},
success: true,
error: false,
message: "Redirect updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+75
View File
@@ -0,0 +1,75 @@
import { z } from "zod";
import { isValidDomain } from "@server/lib/validators";
import { build } from "@server/build";
export const redirectNiceIdSchema = z
.string()
.min(1)
.max(255)
.regex(
/^[a-zA-Z0-9-]+$/,
"niceId can only contain letters, numbers, and dashes"
);
export const redirectPathMatchTypeSchema = z.enum(["exact", "prefix", "regex"]);
export const redirectRewritePathTypeSchema = z.enum([
"exact",
"prefix",
"regex",
"stripPrefix"
]);
export const redirectMatchPathSchema = z.string().nonempty();
export function isValidRegex(pattern: string): boolean {
try {
new RegExp(pattern);
return true;
} catch {
return false;
}
}
/**
* A regex match path is fed straight to `new RegExp` when building routes,
* so reject patterns that would throw there.
*/
export function isValidMatchPath(
matchPath: string | null | undefined,
pathMatchType: string | null | undefined
): boolean {
return (
pathMatchType !== "regex" || !matchPath || isValidRegex(matchPath)
);
}
export const redirectRewritePathSchema = z.string().nonempty();
// Same range as target priorities; 100 means "let the system order it".
export const redirectPrioritySchema = z.int().min(1).max(1000);
/**
* A destination is `scheme://host[:port]` with no path, query or fragment;
* the request path (after any rewrite) is appended to it by badger.
*/
export function isValidDestinationHost(value: string): boolean {
const match = /^https?:\/\/([^/:?#]+)(:\d{1,5})?$/.exec(value);
return match !== null && isValidDomain(match[1]);
}
export const redirectDestinationHostSchema = z
.string()
.nonempty()
.refine(isValidDestinationHost, {
message: "Invalid destination, expected scheme://host such as https://example.com"
});
/**
* The cloud only serves HTTPS, so a domain-attached redirect may not opt out
* of TLS there. Resource-attached redirects inherit the resource's ssl
* setting and never carry their own.
*/
export function isAllowedSsl(ssl: boolean | undefined): boolean {
return build !== "saas" || ssl !== false;
}
@@ -29,8 +29,8 @@ export async function traefikConfigProvider(
const traefikConfig = await getTraefikConfig(
currentExitNodeId,
config.getRawConfig().traefik.site_types,
build == "oss", // filter out the namespace domains in open source
build != "oss", // generate the login pages on the cloud and and enterprise,
build === "oss", // filter out the namespace domains in open source
build !== "oss", // generate the login pages on the cloud and and enterprise,
config.getRawConfig().traefik.allow_raw_resources,
pangolinUIUrl,
pangolinUIUrl,
@@ -71,8 +71,7 @@ export async function traefikConfigProvider(
.resource_session_request_param,
remoteUserIdHeader:
config.getRawConfig().server.remote_headers
.user_id,
config.getRawConfig().server.remote_headers.user_id,
remoteVirtualApiKeyIdHeader:
config.getRawConfig().server.remote_headers
+2 -1
View File
@@ -5,6 +5,7 @@ import path from "path";
import semver from "semver";
import { versionMigrations } from "../db/sqlite";
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
import { formatBackupTimestamp } from "@server/lib/backupFileName";
import { SqliteError } from "better-sqlite3";
import fs from "fs";
import { build } from "@server/build";
@@ -121,7 +122,7 @@ function backupDb() {
// copy the db.sqlite file to backups
// add the date to the filename
const date = new Date();
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
const dateString = formatBackupTimestamp(date);
const dbPath = path.join(dbDir, "db.sqlite");
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
fs.copyFileSync(dbPath, backupPath);
+2 -4
View File
@@ -3,8 +3,6 @@ import { sql } from "drizzle-orm";
const version = "1.23.0";
await migration();
export default async function migration() {
console.log(`Running setup script ${version}...`);
@@ -12,11 +10,11 @@ export default async function migration() {
await db.execute(sql`BEGIN`);
await db.execute(sql`
ALTER TABLE "newt" ADD COLUMN "agent" varchar;
ALTER TABLE "newt" ADD COLUMN IF NOT EXISTS "agent" varchar;
`);
await db.execute(sql`
ALTER TABLE "newt" ADD COLUMN "agentVersion" varchar;
ALTER TABLE "newt" ADD COLUMN IF NOT EXISTS "agentVersion" varchar;
`);
await db.execute(sql`COMMIT`);
+2 -1
View File
@@ -10,6 +10,7 @@ import {
users
} from "../../db/sqlite";
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import { formatBackupTimestamp } from "@server/lib/backupFileName";
import { eq, sql } from "drizzle-orm";
import fs from "fs";
import * as yaml from "js-yaml";
@@ -34,7 +35,7 @@ export default async function migration() {
// copy the db.sqlite file to backups
// add the date to the filename
const date = new Date();
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
const dateString = formatBackupTimestamp(date);
const dbPath = path.join(dbDir, "db.sqlite");
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
fs.copyFileSync(dbPath, backupPath);
+2 -2
View File
@@ -16,13 +16,13 @@ export default async function migration() {
db.transaction(() => {
db.prepare(
`
ALTER TABLE 'newt' ADD 'agent' text;
ALTER TABLE 'newt' ADD COLUMN 'agent' text;
`
).run();
db.prepare(
`
ALTER TABLE 'newt' ADD 'agentVersion' text;
ALTER TABLE 'newt' ADD COLUMN 'agentVersion' text;
`
).run();
})();
@@ -749,12 +749,6 @@ export default function BillingPage() {
return 0;
};
// Get license key count
const getLicenseKeyCount = (): number => {
if (!licenseSubscription?.items) return 0;
return licenseSubscription.items.length;
};
// Check if downgrading to a tier would violate current usage limits
const checkLimitViolations = (
targetTier: Tier | "basic"
@@ -1545,7 +1539,7 @@ export default function BillingPage() {
</SettingsSection>
{/* Paid License Keys Section */}
{(licenseSubscription || getLicenseKeyCount() > 0) && (
{licenseSubscription && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -1561,22 +1555,6 @@ export default function BillingPage() {
<SettingsFormGrid>
<SettingsFormCell span="full">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
<div>
<div className="text-sm text-muted-foreground mb-1">
{t("billingCurrentKeys") ||
"Current Keys"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-semibold">
{getLicenseKeyCount()}
</span>
<span className="text-lg">
{getLicenseKeyCount() === 1
? "key"
: "keys"}
</span>
</div>
</div>
<Button
variant="outline"
onClick={handleModifySubscription}
@@ -1,4 +1,5 @@
import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable";
import LicenseBillingBanner from "@app/components/LicenseBillingBanner";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types";
@@ -26,5 +27,16 @@ export default async function Page({ params }: Props) {
licenseKeys = data.data.data;
} catch {}
return <GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />;
const hasNonPersonalLicenseKey = licenseKeys.some(
(key) => key.tier !== "personal"
);
return (
<>
{hasNonPersonalLicenseKey && (
<LicenseBillingBanner orgId={orgId} />
)}
<GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />
</>
);
}
@@ -281,6 +281,7 @@ export default function GeneralPage() {
// 107 - Valid SSO
// 108 - Connected Client
// 109 - Valid Virtual API Key
// 110 - Allowed Redirect
// 201 - Resource Not Found
// 202 - Resource Blocked
@@ -300,6 +301,7 @@ export default function GeneralPage() {
107: t("validSSO"),
108: t("connectedClient"),
109: t("validVirtualAPIKey"),
110: t("allowedRedirect"),
201: t("resourceNotFound"),
202: t("resourceBlocked"),
203: t("droppedByRule"),
@@ -605,6 +607,7 @@ export default function GeneralPage() {
{ value: "106", label: t("validEmail") },
{ value: "107", label: t("validSSO") },
{ value: "108", label: t("connectedClient") },
{ value: "110", label: t("allowedRedirect") },
{ value: "201", label: t("resourceNotFound") },
{ value: "202", label: t("resourceBlocked") },
{ value: "203", label: t("droppedByRule") },
@@ -0,0 +1,73 @@
import RedirectForm from "@app/components/RedirectForm";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import type { GetRedirectResponse } from "@server/routers/redirect";
import type { AxiosResponse } from "axios";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { notFound } from "next/navigation";
export const metadata: Metadata = {
title: "Edit Redirect"
};
export const dynamic = "force-dynamic";
type EditRedirectPageProps = {
params: Promise<{ orgId: string; niceId: string }>;
};
export default async function EditRedirectPage(props: EditRedirectPageProps) {
const { orgId, niceId } = await props.params;
const t = await getTranslations();
let redirect: GetRedirectResponse["redirect"];
try {
const res = await internal.get<AxiosResponse<GetRedirectResponse>>(
`/org/${orgId}/redirect/${niceId}`,
await authCookieHeader()
);
redirect = res.data.data.redirect;
} catch {
notFound();
}
// The resource selector needs the resource's display fields up front so the
// trigger shows a name instead of a bare id before the list query resolves.
const initialResource =
redirect.resourceId && redirect.resourceNiceId
? {
resourceId: redirect.resourceId,
niceId: redirect.resourceNiceId,
name: redirect.resourceName ?? redirect.resourceNiceId,
fullDomain: redirect.resourceFullDomain,
ssl: redirect.resourceSsl ?? false,
wildcard: redirect.resourceWildcard ?? false
}
: null;
return (
<>
<div className="flex gap-2 justify-between">
<SettingsSectionTitle
title={redirect.name}
description={t("redirectEditDescription")}
/>
<Button variant="outline" asChild>
<Link href={`/${orgId}/settings/redirects`}>
{t("redirectGoBack")}
</Link>
</Button>
</div>
<RedirectForm
orgId={orgId}
redirect={redirect}
initialResource={initialResource}
/>
</>
);
}
@@ -0,0 +1,39 @@
import RedirectForm from "@app/components/RedirectForm";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
export const metadata: Metadata = {
title: "Create Redirect"
};
type CreateRedirectPageProps = {
params: Promise<{ orgId: string }>;
};
export default async function CreateRedirectPage(
props: CreateRedirectPageProps
) {
const { orgId } = await props.params;
const t = await getTranslations();
return (
<>
<div className="flex gap-2 justify-between">
<SettingsSectionTitle
title={t("redirectCreate")}
description={t("redirectCreateDescription")}
/>
<Button variant="outline" asChild>
<Link href={`/${orgId}/settings/redirects`}>
{t("redirectGoBack")}
</Link>
</Button>
</div>
<RedirectForm orgId={orgId} />
</>
);
}
+121
View File
@@ -0,0 +1,121 @@
import RedirectsTable from "@app/components/RedirectsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { build } from "@server/build";
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
import type { ListRedirectsResponse } from "@server/routers/redirect";
import type { AxiosResponse } from "axios";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Redirects"
};
type RedirectIndexPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
};
export const dynamic = "force-dynamic";
export default async function RedirectIndexPage(props: RedirectIndexPageProps) {
const { orgId } = await props.params;
const searchParams = new URLSearchParams(await props.searchParams);
const t = await getTranslations();
let redirects: ListRedirectsResponse["redirects"] = [];
let pagination: ListRedirectsResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try {
const res = await internal.get<AxiosResponse<ListRedirectsResponse>>(
`/org/${orgId}/redirects?${searchParams.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
redirects = responseData.redirects;
pagination = responseData.pagination;
} catch {
// empty list on error
}
const redirectRows = redirects.map((redirect) => ({
redirectId: redirect.redirectId,
niceId: redirect.niceId,
name: redirect.name,
subdomain: redirect.subdomain,
destinationHost: redirect.destinationHost,
pathMatchType: redirect.pathMatchType,
matchPath: redirect.matchPath,
rewritePath: redirect.rewritePath,
rewritePathType: redirect.rewritePathType,
priority: redirect.priority,
permanent: redirect.permanent,
enabled: redirect.enabled,
resourceId: redirect.resourceId,
resourceName: redirect.resourceName,
resourceNiceId: redirect.resourceNiceId,
resourceFullDomain: redirect.resourceFullDomain,
resourceDomainId: redirect.resourceDomainId,
domainId: redirect.domainId,
baseDomain: redirect.baseDomain
}));
// Prefetched in one batched call so the table doesn't fire a separate
// certificate request per visible row once it mounts on the client.
const certDomains = Array.from(
new Set(
redirectRows
.map((r) => {
const domainHost = r.baseDomain
? [r.subdomain, r.baseDomain].filter(Boolean).join(".")
: null;
return r.resourceFullDomain ?? domainHost;
})
.filter((host): host is string => Boolean(host))
)
);
let initialCertificates: GetBatchedCertificateResponse | undefined;
if (build !== "oss" && certDomains.length > 0) {
try {
const certSearchParams = new URLSearchParams(
certDomains.map((domain) => ["domains", domain])
);
const certRes = await internal.get<
AxiosResponse<GetBatchedCertificateResponse>
>(
`/org/${orgId}/batched-certificates?${certSearchParams.toString()}`,
await authCookieHeader()
);
initialCertificates = certRes.data.data;
} catch {
// leave undefined so each row falls back to fetching its own
}
}
return (
<>
<SettingsSectionTitle
title={t("redirectsTitle")}
description={t("redirectsDescription")}
/>
<RedirectsTable
orgId={orgId}
redirects={redirectRows}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
initialCertificates={initialCertificates}
/>
</>
);
}
@@ -76,11 +76,13 @@ export default function PrivateResourceInferencePage() {
})
),
httpConfigSubdomain: z.string().nullish(),
httpConfigDomainId: z.string().nullish(),
httpConfigDomainId: z
.string()
.min(1, { message: t("domainRequired") }),
httpConfigFullDomain: z.string().nullish(),
ssl: z.boolean().optional()
}),
[]
[t]
);
type FormValues = z.infer<typeof formSchema>;
@@ -103,7 +105,7 @@ export default function PrivateResourceInferencePage() {
defaultValues: {
providers: [],
httpConfigSubdomain: siteResource.subdomain ?? null,
httpConfigDomainId: siteResource.domainId ?? null,
httpConfigDomainId: siteResource.domainId ?? "",
httpConfigFullDomain: siteResource.fullDomain ?? null,
ssl: siteResource.ssl ?? false
}
@@ -289,50 +291,74 @@ export default function PrivateResourceInferencePage() {
</SettingsSubsectionHeader>
</SettingsFormCell>
<SettingsFormCell span="full">
<DomainPicker
key={`inference-domain-${siteResource.id}`}
orgId={siteResource.orgId}
cols={2}
hideFreeDomain
defaultSubdomain={
httpConfigSubdomain ?? undefined
}
defaultDomainId={
httpConfigDomainId ?? undefined
}
defaultFullDomain={
httpConfigFullDomain ??
undefined
}
onDomainChange={(res) => {
if (res === null) {
form.setValue(
"httpConfigSubdomain",
null
);
form.setValue(
"httpConfigDomainId",
null
);
form.setValue(
"httpConfigFullDomain",
null
);
return;
}
form.setValue(
"httpConfigSubdomain",
res.subdomain ?? null
);
form.setValue(
"httpConfigDomainId",
res.domainId
);
form.setValue(
"httpConfigFullDomain",
res.fullDomain
);
}}
<FormField
control={form.control}
name="httpConfigDomainId"
render={() => (
<FormItem>
<DomainPicker
key={`inference-domain-${siteResource.id}`}
orgId={
siteResource.orgId
}
cols={2}
hideFreeDomain
defaultSubdomain={
httpConfigSubdomain ??
undefined
}
defaultDomainId={
httpConfigDomainId ??
undefined
}
defaultFullDomain={
httpConfigFullDomain ??
undefined
}
onDomainChange={(
res
) => {
if (res === null) {
form.setValue(
"httpConfigSubdomain",
null
);
form.setValue(
"httpConfigDomainId",
"",
{
shouldValidate:
true
}
);
form.setValue(
"httpConfigFullDomain",
null
);
return;
}
form.setValue(
"httpConfigSubdomain",
res.subdomain ??
null
);
form.setValue(
"httpConfigDomainId",
res.domainId,
{
shouldValidate:
true
}
);
form.setValue(
"httpConfigFullDomain",
res.fullDomain
);
}}
/>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
@@ -139,6 +139,22 @@ export default function GeneralForm() {
: "Port number should not be set for HTTP resources",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (
["http", "ssh", "rdp", "vnc", "inference"].includes(
resource.mode
)
) {
return !!data.domainId;
}
return true;
},
{
message: t("domainRequired"),
path: ["domainId"]
}
);
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -434,63 +450,87 @@ export default function GeneralForm() {
resource.mode
) && (
<SettingsFormCell span="full">
<div id="resource-domain-picker">
<DomainPicker
allowWildcard={
resource.mode !==
"inference"
}
key={
resource.resourceId
}
orgId={orgId as string}
cols={2}
defaultSubdomain={
form.watch(
"subdomain"
) ?? undefined
}
defaultDomainId={
form.watch(
"domainId"
) ?? undefined
}
defaultFullDomain={
resourceFullDomainName ||
undefined
}
onDomainChange={(
res
) => {
if (res === null) {
form.setValue(
"domainId",
undefined
);
form.setValue(
"subdomain",
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://`
);
return;
}
form.setValue(
"domainId",
res.domainId
);
form.setValue(
"subdomain",
res.subdomain ??
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}`
);
}}
/>
</div>
<FormField
control={form.control}
name="domainId"
render={() => (
<FormItem>
<div id="resource-domain-picker">
<DomainPicker
allowWildcard={
resource.mode !==
"inference"
}
key={
resource.resourceId
}
orgId={
orgId as string
}
cols={2}
defaultSubdomain={
form.watch(
"subdomain"
) ??
undefined
}
defaultDomainId={
form.watch(
"domainId"
) ??
undefined
}
defaultFullDomain={
resourceFullDomainName ||
undefined
}
onDomainChange={(
res
) => {
if (
res ===
null
) {
form.setValue(
"domainId",
undefined,
{
shouldValidate:
true
}
);
form.setValue(
"subdomain",
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://`
);
return;
}
form.setValue(
"domainId",
res.domainId,
{
shouldValidate:
true
}
);
form.setValue(
"subdomain",
res.subdomain ??
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}`
);
}}
/>
</div>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{!["tcp", "udp", "inference"].includes(
+11 -2
View File
@@ -10,7 +10,6 @@ import {
Cable,
ChartLine,
Coins,
Combine,
CreditCard,
Fingerprint,
Globe,
@@ -21,11 +20,11 @@ import {
LayoutGrid,
Link as LinkIcon,
Logs,
MessageSquare,
MessagesSquare,
MonitorUp,
Plug,
ReceiptText,
Repeat,
ScanEye,
Server,
Settings,
@@ -141,6 +140,11 @@ export const orgNavSections = (
href: "/{orgId}/settings/domains",
icon: <Globe className="size-4 flex-none" />
},
{
title: "sidebarRedirects",
href: "/{orgId}/settings/redirects",
icon: <Repeat className="size-4 flex-none" />
},
...(env?.flags.usePangolinDns
? [
{
@@ -467,6 +471,11 @@ export const commandBarNavSections = (
title: "commandMachineClients",
icon: <Server className="size-4 flex-none" />
},
{
title: "sidebarRedirects",
href: "/{orgId}/settings/redirects",
icon: <Repeat className="size-4 flex-none" />
},
...(env?.flags.usePangolinDns
? [
{
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Globe, CreditCard, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Button } from "@app/components/ui/button";
import DismissableBanner from "./DismissableBanner";
type LicenseBillingBannerProps = {
orgId: string;
};
export const LicenseBillingBanner = ({ orgId }: LicenseBillingBannerProps) => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="license-billing-banner-dismissed"
version={1}
title={t("licenseBillingBannerTitle")}
titleIcon={<Globe className="w-5 h-5 text-primary" />}
description={t("licenseBillingBannerDescription")}
>
<Link href={`/${orgId}/settings/billing`}>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<CreditCard className="w-4 h-4" />
{t("licenseBillingBannerButton")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
);
};
export default LicenseBillingBanner;
+1 -1
View File
@@ -330,7 +330,7 @@ export default function NewPricingLicenseForm({
cols={2}
/>
<a
href="https://pangolin.net/pricing"
href="https://pangolin.net/pricing#Self-Hosted"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
+967
View File
@@ -0,0 +1,967 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
isValidDestinationHost,
isValidRegex
} from "@server/routers/redirect/validation";
import { build } from "@server/build";
import { cn } from "@app/lib/cn";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { zodResolver } from "@hookform/resolvers/zod";
import type {
CreateRedirectResponse,
GetRedirectResponse
} from "@server/routers/redirect";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ResourceSelector, type SelectedResource } from "./resource-selector";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { Plus } from "lucide-react";
import DomainPicker from "@app/components/DomainPicker";
import Link from "next/link";
const DEFAULT_PATH_MATCH_TYPE = "regex" as const;
const DEFAULT_PRIORITY = 100;
export type ExistingRedirect = GetRedirectResponse["redirect"];
type RedirectFormProps = {
orgId: string;
/** Omit to create a new redirect. */
redirect?: ExistingRedirect;
/** Name/domain of the resource the redirect is attached to, when there is one. */
initialResource?: SelectedResource | null;
};
export default function RedirectForm({
orgId,
redirect,
initialResource = null
}: RedirectFormProps) {
const isEditing = Boolean(redirect);
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const t = useTranslations();
const [, formAction, saveLoading] = useActionState(onSubmit, null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<SelectedResource | null>(initialResource);
// DomainPicker only hands back the composed host through its callback, so
// keep it locally; seed from the saved redirect for the edit case.
const [domainFullDomain, setDomainFullDomain] = useState<string | null>(
redirect?.baseDomain
? [redirect.subdomain, redirect.baseDomain]
.filter(Boolean)
.join(".")
: null
);
const formSchema = useMemo(
() =>
z
.object({
name: z
.string()
.trim()
.min(1, { message: t("nameRequired") }),
attachTo: z.enum(["domain", "resource"]),
domainId: z.string().nullable(),
subdomain: z.string().nullable(),
resourceId: z.number().int().positive().nullable(),
destinationHost: z
.string()
.trim()
.min(1, {
message: t("redirectDestinationHostRequired")
})
.refine(isValidDestinationHost, {
message: t("redirectDestinationHostInvalid")
}),
pathMatchType: z.enum(["exact", "prefix", "regex"]),
matchPath: z.string().trim().nullable(),
rewritePath: z.string().nullable(),
rewritePathType: z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.nullable(),
priority: z
.number()
.int()
.min(1, { message: t("redirectPriorityInvalid") })
.max(1000, { message: t("redirectPriorityInvalid") }),
permanent: z.boolean(),
ssl: z.boolean(),
enabled: z.boolean()
})
.superRefine((data, ctx) => {
if (data.attachTo === "domain" && !data.domainId) {
ctx.addIssue({
code: "custom",
message: t("redirectDomainRequired"),
path: ["domainId"]
});
}
if (data.attachTo === "resource" && !data.resourceId) {
ctx.addIssue({
code: "custom",
message: t("redirectResourceRequired"),
path: ["resourceId"]
});
}
if (
data.pathMatchType === "regex" &&
data.matchPath &&
!isValidRegex(data.matchPath)
) {
ctx.addIssue({
code: "custom",
message: t("redirectMatchPathInvalidRegex"),
path: ["matchPath"]
});
}
// stripPrefix drops the matched prefix outright, so it is
// the one rewrite type that needs no replacement value.
if (
data.rewritePathType &&
data.rewritePathType !== "stripPrefix" &&
!data.rewritePath
) {
ctx.addIssue({
code: "custom",
message: t("redirectRewritePathRequired"),
path: ["rewritePath"]
});
}
}),
[t]
);
type RedirectFormValues = z.infer<typeof formSchema>;
const form = useForm<RedirectFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: redirect?.name ?? "",
attachTo: redirect?.resourceId ? "resource" : "domain",
domainId: redirect?.domainId ?? null,
subdomain: redirect?.subdomain ?? null,
resourceId: redirect?.resourceId ?? null,
destinationHost: redirect?.destinationHost ?? "",
pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE,
matchPath: redirect?.matchPath ?? null,
rewritePath: redirect?.rewritePath ?? null,
rewritePathType: redirect?.rewritePathType ?? null,
priority: redirect?.priority ?? DEFAULT_PRIORITY,
permanent: redirect?.permanent ?? false,
ssl: redirect?.ssl ?? true,
enabled: redirect?.enabled ?? true
}
});
const attachTo = form.watch("attachTo");
const ssl = form.watch("ssl");
const sourceFullDomain =
attachTo === "domain"
? domainFullDomain
: (selectedResource?.fullDomain ?? null);
// Resource-attached redirects inherit the resource's ssl setting
const sourceSsl =
attachTo === "domain" ? ssl : (selectedResource?.ssl ?? true);
const sourceHost = sourceFullDomain
? `${sourceSsl ? "https" : "http"}://${sourceFullDomain}`
: null;
// Mirror is UI-only state; on edit, infer it from whether the saved
// destination already equals the source host.
const [sameDomainAsSource, setSameDomainAsSource] = useState(
Boolean(redirect && sourceHost && redirect.destinationHost === sourceHost)
);
useEffect(() => {
if (sameDomainAsSource && sourceHost) {
form.setValue("destinationHost", sourceHost, {
shouldValidate: true
});
}
}, [sameDomainAsSource, sourceHost, form]);
const pathMatchType = form.watch("pathMatchType");
const rewritePath = form.watch("rewritePath");
const rewritePathType = form.watch("rewritePathType");
// stripPrefix is a valid rewrite with no path value, so it counts as set.
const hasRewrite =
Boolean(rewritePath) || rewritePathType === "stripPrefix";
async function onSubmit() {
if (!(await form.trigger())) return;
const values = form.getValues();
// Only one of the two attachment points is ever persisted; clear the
// other so switching between them doesn't leave a stale reference.
const body = {
name: values.name.trim(),
domainId: values.attachTo === "domain" ? values.domainId : null,
subdomain:
values.attachTo === "domain" ? values.subdomain || null : null,
resourceId:
values.attachTo === "resource" ? values.resourceId : null,
destinationHost: values.destinationHost.trim(),
pathMatchType: values.pathMatchType,
matchPath: values.matchPath?.trim() || null,
rewritePath: values.rewritePath?.trim() || null,
rewritePathType: values.rewritePathType,
priority: values.priority,
permanent: values.permanent,
// Resource-attached redirects inherit the resource's ssl setting
ssl: values.attachTo === "domain" ? values.ssl : true,
enabled: values.enabled
};
try {
if (isEditing) {
await api.post(
`/org/${orgId}/redirects/${redirect!.redirectId}`,
body
);
toast({
title: t("success"),
description: t("redirectUpdated")
});
router.refresh();
} else {
const res = await api.put<
AxiosResponse<CreateRedirectResponse>
>(`/org/${orgId}/redirect`, body);
toast({
title: t("success"),
description: t("redirectCreated")
});
router.push(`/${orgId}/settings/redirects/`);
}
} catch (e) {
toast({
variant: "destructive",
title: isEditing
? t("redirectErrorUpdate")
: t("redirectErrorCreate"),
description: formatAxiosError(
e,
isEditing
? t("redirectErrorUpdate")
: t("redirectErrorCreate")
)
});
}
}
async function onDelete() {
setDeleteLoading(true);
try {
await api.delete(`/org/${orgId}/redirects/${redirect!.redirectId}`);
toast({
title: t("success"),
description: t("redirectDeleted")
});
router.push(`/${orgId}/settings/redirects`);
} catch (e) {
toast({
variant: "destructive",
title: t("redirectErrorDelete"),
description: formatAxiosError(e, t("redirectErrorDelete"))
});
} finally {
setDeleteLoading(false);
setIsDeleteModalOpen(false);
}
}
return (
<>
{isEditing && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={setIsDeleteModalOpen}
dialog={
<div className="space-y-2">
<p>{t("redirectQuestionRemove")}</p>
<p>{t("redirectMessageRemove")}</p>
</div>
}
buttonText={t("redirectDeleteConfirm")}
onConfirm={onDelete}
string={redirect!.name}
title={t("redirectDelete")}
/>
)}
<SettingsContainer>
<SettingsSection className="pb-10">
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("redirectSource")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("redirectSourceSectionDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form action={formAction} id="redirect-form">
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="redirect-enabled"
label={t(
"enabled"
)}
description={t(
"redirectEnabledDescription"
)}
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="attachTo"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"redirectAttachedTo"
)}
</FormLabel>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="domain">
{t(
"redirectAttachDomain"
)}
</SelectItem>
<SelectItem value="resource">
{t(
"redirectAttachResource"
)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t(
"redirectAttachedToDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{attachTo === "domain" ? (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="domainId"
render={() => (
<FormItem>
<DomainPicker
orgId={orgId}
cols={1}
hideFreeDomain
defaultDomainId={
redirect?.domainId
}
allowWildcard
defaultSubdomain={
redirect?.subdomain
}
onDomainChange={(
res
) => {
form.setValue(
"domainId",
res?.domainId ??
null,
{
shouldValidate: true
}
);
form.setValue(
"subdomain",
res?.subdomain ||
null
);
setDomainFullDomain(
res?.fullDomain ??
null
);
}}
/>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
) : (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="resourceId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t(
"selectedRedirectResource"
)}
</FormLabel>
<Popover>
<PopoverTrigger
asChild
>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{selectedResource?.name ??
t(
"resourceSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<ResourceSelector
orgId={
orgId
}
selectedResource={
selectedResource
}
onSelectResource={(
resource
) => {
setSelectedResource(
resource
);
field.onChange(
resource.resourceId
);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{attachTo === "resource" && (
<SettingsFormCell span="full">
<FormItem>
<FormLabel>
{t("resourceDomain")}
</FormLabel>
<Input
disabled
readOnly
value={
selectedResource?.fullDomain ??
""
}
placeholder={
selectedResource
? t(
"redirectResourceNoDomain"
)
: t(
"resourceSelect"
)
}
/>
</FormItem>
</SettingsFormCell>
)}
{/* The cloud only serves HTTPS, so there is nothing to toggle there. */}
{build !== "saas" && (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="redirect-ssl"
label={t(
"proxyEnableSSL"
)}
description={
attachTo ===
"resource"
? t(
"redirectSslInheritedDescription"
)
: t(
"redirectSslDescription"
)
}
disabled={
attachTo ===
"resource"
}
checked={
attachTo ===
"resource"
? (selectedResource?.ssl ??
true)
: field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection className="pb-10">
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("redirectSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("redirectSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form action={formAction}>
<SettingsFormGrid>
<SettingsFormCell span="full">
<SwitchInput
id="redirect-same-domain"
label={t(
"redirectSameDomainAsSource"
)}
description={t(
"redirectSameDomainAsSourceDescription"
)}
checked={sameDomainAsSource}
onCheckedChange={
setSameDomainAsSource
}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="destinationHost"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"redirectDestinationHost"
)}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="https://example.com"
readOnly={
sameDomainAsSource
}
{...field}
/>
</FormControl>
<FormDescription>
{t(
"redirectDestinationHostDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="matchPath"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t("matchPath")}
</FormLabel>
<PathMatchModal
value={{
path: field.value,
pathMatchType:
pathMatchType
}}
onChange={(
config
) => {
// No match path
// means the
// redirect applies
// to every path;
// pathMatchType is
// NOT NULL so it
// keeps a default.
field.onChange(
config.path ||
null
);
form.setValue(
"pathMatchType",
(config.pathMatchType as
| "exact"
| "prefix"
| "regex") ||
DEFAULT_PATH_MATCH_TYPE
);
}}
trigger={
<Button
type="button"
variant="outline"
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer"
>
{field.value ? (
<PathMatchDisplay
value={{
path: field.value,
pathMatchType:
pathMatchType
}}
/>
) : (
<>
<Plus className="h-4 w-4" />
{t(
"matchPath"
)}
</>
)}
</Button>
}
/>
<FormDescription>
{t(
"redirectMatchPathDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="rewritePath"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t("rewritePath")}
</FormLabel>
<PathRewriteModal
value={{
rewritePath:
field.value,
rewritePathType:
rewritePathType
}}
onChange={(
config
) => {
field.onChange(
config.rewritePath ||
null
);
form.setValue(
"rewritePathType",
(config.rewritePathType as
| "exact"
| "prefix"
| "regex"
| "stripPrefix"
| null) ??
null
);
}}
trigger={
hasRewrite ? (
<Button
type="button"
variant="outline"
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer"
>
<PathRewriteDisplay
value={{
rewritePath:
field.value,
rewritePathType:
rewritePathType
}}
/>
</Button>
) : (
<Button
type="button"
variant="outline"
className="w-full"
>
<Plus className="h-4 w-4 mr-2" />
{t(
"rewritePath"
)}
</Button>
)
}
/>
<FormDescription>
{t(
"redirectRewritePathDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("priority")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={1000}
{...field}
onChange={(e) =>
field.onChange(
e.target
.valueAsNumber
)
}
/>
</FormControl>
<FormDescription>
{t(
"redirectPriorityDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="permanent"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="redirect-permanent"
label={t(
"redirectPermanent"
)}
description={t(
"redirectPermanentDescription"
)}
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{isEditing && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("dangerSection")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionFooter>
<Button
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
loading={deleteLoading}
disabled={deleteLoading}
>
{t("redirectDelete")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
<div className="flex justify-end space-x-2 mt-8">
<Button type="button" variant="outline" asChild>
<Link href={`/${orgId}/settings/redirects`}>
{t("cancel")}
</Link>
</Button>
<Button
type="submit"
form="redirect-form"
loading={saveLoading}
disabled={saveLoading}
>
{isEditing ? t("saveSettings") : t("redirectAdd")}
</Button>
</div>
</SettingsContainer>
</>
);
}
+508
View File
@@ -0,0 +1,508 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Switch } from "@app/components/ui/switch";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "@app/components/ui/controlled-data-table";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
import type { PaginationState } from "@tanstack/react-table";
import {
ArrowDown,
ArrowRight,
ArrowUp,
ArrowUpRight,
GlobeIcon,
MinusIcon,
MoreHorizontal,
WaypointsIcon
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
useEffect,
useMemo,
useOptimistic,
useRef,
useState,
useTransition,
type ComponentRef
} from "react";
import { useDebouncedCallback } from "use-debounce";
export type RedirectRow = {
redirectId: number;
niceId: string;
name: string;
subdomain: string | null;
destinationHost: string;
pathMatchType: "exact" | "prefix" | "regex";
matchPath: string | null;
rewritePath: string | null;
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
priority: number | null;
permanent: boolean;
enabled: boolean;
resourceId: number | null;
resourceName: string | null;
resourceNiceId: string | null;
resourceFullDomain: string | null;
resourceDomainId: string | null;
domainId: string | null;
baseDomain: string | null;
};
type RedirectsTableProps = {
redirects: RedirectRow[];
orgId: string;
pagination: PaginationState;
rowCount: number;
initialCertificates?: GetBatchedCertificateResponse;
};
export default function RedirectsTable({
redirects,
orgId,
pagination,
rowCount,
initialCertificates
}: RedirectsTableProps) {
const router = useRouter();
const t = useTranslations();
const api = createApiClient(useEnvContext());
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [rows, setRows] = useState(redirects);
const [selected, setSelected] = useState<RedirectRow | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
useEffect(() => {
setRows(redirects);
}, [redirects]);
function refreshData() {
startTransition(() => {
try {
router.refresh();
} catch {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({ searchParams });
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({ searchParams });
}, 300);
// Prefix matches/rewrites cover everything beneath the path, so show the
// implied glob rather than the bare prefix.
function withPrefixGlob(path: string) {
return path.endsWith("/") ? `${path}*` : `${path}/*`;
}
async function toggleEnabled(enabled: boolean, redirectId: number) {
try {
await api.post(`/org/${orgId}/redirects/${redirectId}`, {
enabled
});
toast({
title: t("success"),
description: t("redirectUpdated")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("redirectErrorUpdate"),
description: formatAxiosError(e, t("redirectErrorUpdate"))
});
}
}
function deleteRedirect(row: RedirectRow) {
startTransition(async () => {
try {
await api.delete(`/org/${orgId}/redirects/${row.redirectId}`);
setRows((prev) =>
prev.filter((r) => r.redirectId !== row.redirectId)
);
setIsDeleteModalOpen(false);
setSelected(null);
toast({
title: t("success"),
description: t("redirectDeleted")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("redirectErrorDelete"),
description: formatAxiosError(e, t("redirectErrorDelete"))
});
}
});
}
const columns = useMemo<ExtendedColumnDef<RedirectRow>[]>(
() => [
{
accessorKey: "name",
enableHiding: false,
header: () => <span className="p-3">{t("name")}</span>,
cell: ({ row }) => (
<Link
href={`/${orgId}/settings/redirects/${row.original.niceId}`}
className="hover:underline"
>
{row.original.name}
</Link>
)
},
{
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
header: () => <span className="p-3">{t("identifier")}</span>,
cell: ({ row }) => (
<code className="text-sm">{row.original.niceId}</code>
)
},
{
id: "attachedTo",
friendlyName: t("redirectAttachedTo"),
header: () => (
<span className="p-3">{t("redirectAttachedTo")}</span>
),
cell: ({ row }) => {
const redirect = row.original;
if (redirect.resourceId && redirect.resourceNiceId) {
return (
<Button
variant="outline"
size="sm"
asChild
className="inline-flex items-center gap-1.5"
>
<Link
href={`/${orgId}/settings/resources/public/${redirect.resourceNiceId}`}
>
<WaypointsIcon className="size-3 text-muted-foreground" />
{redirect.resourceName}
<ArrowUpRight className="size-3" />
</Link>
</Button>
);
}
if (redirect.domainId) {
return (
<Button
variant="outline"
size="sm"
asChild
className="inline-flex items-center gap-1.5"
>
<Link
href={`/${orgId}/settings/domains/${redirect.domainId}`}
>
<GlobeIcon className="size-3 text-muted-foreground" />
{redirect.baseDomain}
<ArrowUpRight className="size-3" />
</Link>
</Button>
);
}
return <span>-</span>;
}
},
{
id: "source",
friendlyName: t("redirectSource"),
header: () => (
<span className="p-3">{t("redirectSource")}</span>
),
cell: ({ row }) => {
const redirect = row.original;
// A domain-attached redirect may target a specific host
// under the base domain, e.g. old.example.com.
const domainHost = redirect.baseDomain
? [redirect.subdomain, redirect.baseDomain]
.filter(Boolean)
.join(".")
: null;
const host = redirect.resourceFullDomain ?? domainHost;
// The cert lives on whichever domain actually terminates
// TLS: the resource's domain when attached to a resource,
// otherwise the redirect's own domain.
const certDomainId =
redirect.resourceDomainId ?? redirect.domainId;
return (
<div className="flex items-center gap-2 min-w-0">
{certDomainId && host ? (
<ResourceAccessCertIndicator
orgId={orgId}
domainId={certDomainId}
fullDomain={host}
initialCertValue={
initialCertificates?.[host]
}
/>
) : null}
<code className="text-sm truncate">
{host ?? ""}
{redirect.matchPath && (
<span className="text-muted-foreground">
{redirect.pathMatchType === "prefix"
? withPrefixGlob(redirect.matchPath)
: redirect.matchPath}
</span>
)}
</code>
</div>
);
}
},
{
id: "destination",
accessorKey: "destinationHost",
friendlyName: t("redirectDestination"),
header: () => (
<span className="p-3">{t("redirectDestination")}</span>
),
cell: ({ row }) => {
const redirect = row.original;
return (
<code className="text-sm truncate">
{redirect.destinationHost}
{redirect.rewritePath && (
<span className="text-muted-foreground">
{redirect.rewritePathType === "prefix"
? withPrefixGlob(redirect.rewritePath)
: redirect.rewritePath}
</span>
)}
</code>
);
}
},
{
accessorKey: "priority",
friendlyName: t("priority"),
header: () => <span className="p-3">{t("priority")}</span>,
cell: ({ row }) => {
// 100 is the automatic default; anything else was set
// deliberately, so flag which way it deviates.
const priority = row.original.priority ?? 100;
return (
<span className="inline-flex items-center gap-1">
{priority}
{priority > 100 ? (
<ArrowUp className="size-3 text-green-500" />
) : priority < 100 ? (
<ArrowDown className="size-3 text-red-500" />
) : (
<MinusIcon className="size-3 text-muted-foreground" />
)}
</span>
);
}
},
{
accessorKey: "permanent",
friendlyName: t("redirectType"),
header: () => <span className="p-3">{t("redirectType")}</span>,
cell: ({ row }) => (
<Badge variant="secondary">
{row.original.permanent
? t("redirectTypePermanent")
: t("redirectTypeTemporary")}
</Badge>
)
},
{
accessorKey: "enabled",
friendlyName: t("enabled"),
header: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<RedirectEnabledForm
redirect={row.original}
onToggleEnabled={toggleEnabled}
/>
)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3" />,
cell: ({ row }) => (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
href={`/${orgId}/settings/redirects/${row.original.niceId}`}
>
{t("edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(row.original);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${orgId}/settings/redirects/${row.original.niceId}`}
>
<Button variant="outline">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
)
}
],
[orgId, t, initialCertificates]
);
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
if (!val) {
setSelected(null);
}
}}
dialog={
<div className="space-y-2">
<p>{t("redirectQuestionRemove")}</p>
<p>{t("redirectMessageRemove")}</p>
</div>
}
buttonText={t("redirectDeleteConfirm")}
onConfirm={async () => deleteRedirect(selected)}
string={selected.name}
title={t("redirectDelete")}
/>
)}
<ControlledDataTable
columns={columns}
rows={rows}
addButtonText={t("redirectAdd")}
onAdd={() =>
startNavigation(() =>
router.push(`/${orgId}/settings/redirects/create`)
)
}
isNavigatingToAddPage={isNavigatingToAddPage}
tableId="redirects-table"
searchPlaceholder={t("redirectsSearch")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
rowCount={rowCount}
columnVisibility={{
attachedTo: false,
niceId: false,
permanent: false,
priority: false
}}
enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}
type RedirectEnabledFormProps = {
redirect: RedirectRow;
onToggleEnabled: (val: boolean, redirectId: number) => Promise<void>;
};
function RedirectEnabledForm({
redirect,
onToggleEnabled
}: RedirectEnabledFormProps) {
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(
redirect.enabled
);
const formRef = useRef<ComponentRef<"form">>(null);
async function submitAction(formData: FormData) {
const newEnabled = !(formData.get("enabled") === "on");
setOptimisticEnabled(newEnabled);
await onToggleEnabled(newEnabled, redirect.redirectId);
}
return (
<form action={submitAction} ref={formRef}>
<Switch
checked={optimisticEnabled}
disabled={optimisticEnabled !== redirect.enabled}
name="enabled"
onCheckedChange={() => formRef.current?.requestSubmit()}
/>
</form>
);
}
+68 -16
View File
@@ -1,23 +1,46 @@
import { cn } from "@app/lib/cn";
export function SettingsContainer({ children }: { children: React.ReactNode }) {
return <div className="space-y-6">{children}</div>;
export function SettingsContainer({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("space-y-6", className)}>{children}</div>;
}
export function SettingsSection({ children }: { children: React.ReactNode }) {
export function SettingsSection({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">
<div
className={cn(
"border rounded-lg bg-card p-5 flex flex-col min-h-[200px]",
className
)}
>
{children}
</div>
);
}
export function SettingsSectionHeader({
children
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className="text-lg space-y-0.5 pb-6">{children}</div>;
return (
<div className={cn("text-lg space-y-0.5 pb-6", className)}>
{children}
</div>
);
}
export function SettingsSectionForm({
@@ -77,7 +100,7 @@ export function SettingsFormCell({
"min-w-0",
span === "quarter" && "md:col-span-1",
span === "half" && "md:col-span-2",
span === "full" && "md:col-span-4",
span === "full" && "col-span-full",
className
)}
>
@@ -87,23 +110,36 @@ export function SettingsFormCell({
}
export function SettingsSectionTitle({
children
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
<h2
className={cn(
"text-1xl font-semibold tracking-tight flex items-center gap-2",
className
)}
>
{children}
</h2>
);
}
export function SettingsSectionDescription({
children
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <p className="text-muted-foreground text-sm">{children}</p>;
return (
<p className={cn("text-muted-foreground text-sm", className)}>
{children}
</p>
);
}
export function SettingsSubsectionHeader({
@@ -141,11 +177,15 @@ export function SettingsSubsectionDescription({
}
export function SettingsSectionBody({
children
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className="space-y-5 flex-grow">{children}</div>;
return (
<div className={cn("space-y-5 flex-grow", className)}>{children}</div>
);
}
export function SettingsSectionFooter({
@@ -169,10 +209,22 @@ export function SettingsSectionFooter({
export function SettingsSectionGrid({
children,
cols
cols = 4,
className
}: {
children: React.ReactNode;
cols: number;
cols?: number;
className?: string;
}) {
return <div className={`grid md:grid-cols-${cols} gap-6`}>{children}</div>;
return (
<div
style={{
// @ts-expect-error
"--cols": `repeat(${cols}, minmax(0, 1fr))`
}}
className={cn(`grid md:grid-cols-(--cols) gap-6`, className)}
>
{children}
</div>
);
}
+12 -7
View File
@@ -63,6 +63,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
) : null;
if (site.type === "newt") {
// agent and agentVersion were added after newtVersion, so a
// site still running an older Newt reports only newtVersion.
// Without these fallbacks the badge renders with no label and
// no version at all.
const agentLabel = site.agent == "cli" ? "Pangolin CLI" : "Newt";
const agentVersion = site.agentVersion ?? site.newtVersion;
return (
<Alert>
<AlertDescription>
@@ -79,13 +85,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
<InfoSection>
<InfoSectionTitle>{t("agent")}</InfoSectionTitle>
<InfoSectionContent>
{site.agent == "newt" ? "Newt" : null}
{site.agent == "cli"
? "Pangolin CLI"
: null}{" "}
{site.agentVersion
? `v${site.agentVersion}`
: "-"}
<div className="flex items-center space-x-1">
<span>{agentLabel}</span>
{agentVersion && (
<span>v{agentVersion}</span>
)}
</div>
</InfoSectionContent>
</InfoSection>
{endpointSection}
+16 -14
View File
@@ -373,7 +373,7 @@ export default function SitesTable({
accessorKey: "type",
friendlyName: t("agent"),
header: () => {
return <span className="p-3">{t("type")}</span>;
return <span className="p-3">{t("agent")}</span>;
},
cell: ({ row }) => {
const originalRow = row.original;
@@ -386,25 +386,27 @@ export default function SitesTable({
);
if (originalRow.type === "newt") {
if (!originalRow.agent) {
if (!originalRow.agent && !originalRow.newtVersion) {
// it has not checked in yet
return <span>-</span>;
}
// agent and agentVersion were added after newtVersion, so a
// site still running an older Newt reports only newtVersion.
// Without these fallbacks the badge renders with no label and
// no version at all.
const agentLabel =
originalRow.agent == "cli"
? "Pangolin CLI"
: "Newt";
const agentVersion =
originalRow.agentVersion ?? originalRow.newtVersion;
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-1">
<span>
{originalRow.agent == "newt"
? "Newt"
: null}
{originalRow.agent == "cli"
? "Pangolin CLI"
: null}
</span>
{originalRow.agentVersion && (
<span>
v{originalRow.agentVersion}
</span>
<span>{agentLabel}</span>
{agentVersion && (
<span>v{agentVersion}</span>
)}
</div>
</Badge>
+1 -1
View File
@@ -150,7 +150,7 @@ Type=simple
User=root
Group=root
EnvironmentFile=/etc/pangolin/pangolin-site.env
ExecStart=/home/owen/fossorial/cli/bin/pangolin up site
ExecStart=/usr/local/bin/pangolin up site
Restart=always
RestartSec=2
UMask=0077
+1
View File
@@ -44,6 +44,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"read-only:opacity-50",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"focus-visible:outline-none focus-visible:border-ring focus-visible:ring-offset-0",
className
+7
View File
@@ -523,6 +523,13 @@ export function createCreateFormSchema(t: TranslateFn) {
});
}
}
if (data.mode === "inference" && !data.httpConfigDomainId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("domainRequired"),
path: ["httpConfigDomainId"]
});
}
});
}