Compare commits

...

68 Commits

Author SHA1 Message Date
Fred KISSIE ca18320ca3 Merge branch 'dev' into feat/redirect-tables 2026-09-22 22:36:53 +02:00
Fred KISSIE 404594ffd7 🚸 redirect to list after update 2026-09-22 19:15:36 +02:00
Fred KISSIE 3e29e110f2 ⚰️ remove useless useffect 2026-09-22 19:07:03 +02:00
Owen 5fd4383396 Show information about how to get more licenses and when you have them 2026-09-22 10:52:39 -04:00
Owen 70f677a277 Don't allow personal and non-personal keys at the same time 2026-09-22 10:40:47 -04:00
Owen d38281b9f7 Update language and add debug 2026-09-22 10:37:52 -04:00
Owen 0de5c763b3 Order by valid until 2026-09-22 09:53:05 -04:00
Owen 0364257e68 Pick key based on tier ranked 2026-09-22 09:45:11 -04:00
Owen 9de91e2f81 Display the quantities again 2026-09-22 09:39:17 -04:00
Owen d134304159 Add alpine install commands 2026-09-22 09:33:43 -04:00
Owen 750f267451 Try to link email to org when creating for first time 2026-09-21 16:25:58 -04:00
Owen 0c752c9c29 Merge branch 'main' into dev 2026-09-21 15:21:12 -04:00
Owen Schwartz 3392120855 Merge pull request #3172 from Gulianrdgd/feature-response-headers
Feature response headers
2026-09-21 15:16:24 -04:00
Owen Schwartz 74d10acad9 Merge pull request #3704 from Blacks-Army/feat/http-method-rules
Add HTTP method matching to resource rules
2026-09-21 11:44:54 -04:00
Owen 887c6e4e8b Remove linting until 7.1 release 2026-09-21 11:10:37 -04:00
Owen f71b9e7c54 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-09-21 10:48:32 -04:00
Owen Schwartz 5dd4c54ad0 Merge pull request #3750 from kah-ja/fix/resource-auth-redirect-scheme
Only accept http(s) targets for the resource auth redirect
2026-09-21 10:45:03 -04:00
Owen 6f3e0cf5a6 Time is un utc 2026-09-21 10:18:45 -04:00
miloschwartz d4488ec125 update ios identifiers 2026-09-21 09:57:03 -04:00
Owen Schwartz b8572524a4 Merge pull request #3790 from Bolex80/fix/resource-auth-idp-variant
fix: use idp variant for resource auth login page icons
2026-09-21 09:45:41 -04:00
Owen b054f90149 Merge branch 'main' into dev 2026-09-21 09:14:51 -04:00
Owen c290239894 Use postgresql
Fix #3794
2026-09-21 09:14:16 -04:00
Blacks-Army 8e2f9ea5ef Add HTTP method matching to resource rules
Resolves #1408.

A rule with match "METHOD" carries a comma-separated list of HTTP
methods in its value, e.g. "POST,PUT", and applies when the request
method is in that list. This makes it possible to leave GET public
while sending POST and PUT to auth, which rules could not express
before because both share the same path.

No new columns: the methods live in the existing rule value, so this
needs no migration and every existing rule keeps working unchanged.

The UI offers the ten registered methods. Blueprints and the API
accept any method token, so extension methods such as the WebDAV verbs
can be targeted too, and the UI preserves them when a rule set that
way is edited later.
2026-09-19 20:00:01 +02:00
Alex Benthem 032eeb2656 fix: use idp variant for resource auth login page icons
The resource auth login page (auth/resource/[resourceGuid]) loads IdPs via
the global /idp list in the non-saas/non-org path and passed idp.type as the
icon variant. Since type is always 'oidc' for OIDC-backed providers (Google,
Azure), the branded logos were never selected, showing the generic OIDC icon
instead.

Use idp.variant (with type as fallback), matching the fix already applied to
the main login page (auth/login) and org login page (auth/org/[orgId]).

Fixes #3631
2026-09-19 12:28:16 +02:00
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
Jan Kahmen a7d4745f93 Only accept http(s) targets for the resource auth redirect
The resource auth page copies the redirect query parameter into
redirectUrl when its host matches the resource host
(src/app/auth/resource/[resourceGuid]/page.tsx:121-150). URL parses a
host out of every scheme that uses "//", so a target such as
javascript://resource-host/... passes that comparison. The value is
handed to ResourceAuthPortal as the redirect prop and assigned to
window.location.href after a successful login
(src/components/ResourceAuthPortal.tsx:213,247,281).

Parse the target once and require http: or https: before the host
comparisons. The three branches that assigned the same value are folded
into one condition; the accepted set of http(s) targets is unchanged.
2026-09-15 10:46:05 +00: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
Julian van der Horst 6ad09adefa Merge upstream/dev into feature-response-headers
Resolve conflicts against upstream's refactors:

- server/db/sqlite/schema/schema.ts: adopt upstream's reindented
  sqliteTable(name, cols, indexes) form for sites/resources, re-applying
  the headers -> requestHeaders/responseHeaders split. Kept in sync with
  the Postgres schema.
- server/lib/traefik/headersMiddleware.ts: extend upstream's extracted
  buildCustomHeadersMiddleware helper to take requestHeaders and
  responseHeaders and emit both customRequestHeaders and
  customResponseHeaders.
- server/lib/traefik/getTraefikConfig.ts and
  server/private/lib/traefik/getTraefikConfig.ts: keep upstream's helper
  extraction and appendPathMatch refactor, dropping the superseded inline
  blocks.

Also carry the feature forward onto code that moved upstream:

- The resource settings UI moved from resources/proxy/[niceId]/proxy to
  resources/public/[niceId]/http, which dropped this branch's changes in
  the previous merge. Re-add the request/response header inputs there and
  rename the vestigial headers field on the tcp page.
- messages/da-DK.json is new upstream and still had the old customHeaders
  key; rename it in line with the other locales.

Per the contributing docs, versioned migrations are intentionally omitted
so maintainers can write them at release time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 13:40:49 +02:00
Julian van der Horst a8c1ddb448 Merge should be complete 2026-07-07 11:57:43 +02:00
Julian van der Horst de67f06b43 Merge remote-tracking branch 'upstream/dev' into feature-response-headers
# Conflicts:
#	server/db/pg/schema/schema.ts
#	server/lib/blueprints/proxyResources.ts
#	server/routers/resource/getResource.ts
#	src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx
#	src/components/HealthCheckCredenza.tsx
2026-07-07 11:13:15 +02:00
Julian van der Horst 713e24503d Removed migrations 2026-05-27 20:37:56 +02:00
Julian van der Horst 6952d3bee7 Fixed copilot issue. 2026-05-27 20:37:56 +02:00
Julian van der Horst 8dee505eb2 Fixed copilot issue. 2026-05-27 20:37:56 +02:00
Julian van der Horst 382f5d5218 Fixed copilot issue. 2026-05-27 20:37:56 +02:00
Julian van der Horst 9d6062619c Forgot about blueprints 2026-05-27 20:37:56 +02:00
Julian van der Horst 6c7d345f03 Reverted package-lock.json 2026-05-27 20:37:56 +02:00
Julian van der Horst c8357e8653 tested and translated! 2026-05-27 20:37:56 +02:00
Julian van der Horst 9804c0db28 Ready for testing 2026-05-27 20:37:56 +02:00
82 changed files with 5328 additions and 385 deletions
+18 -11
View File
@@ -3,18 +3,25 @@ name: ESLint
permissions:
contents: read
# Disabled from running on PRs: typescript-eslint does not yet support
# TypeScript 7 (which this repo is on), so eslint currently crashes on
# every run. Kept as workflow_dispatch so it can still be triggered
# manually, and re-enabled on pull_request once upstream support lands.
# https://github.com/typescript-eslint/typescript-eslint/issues/10940
# on:
# pull_request:
# paths:
# - '**/*.js'
# - '**/*.jsx'
# - '**/*.ts'
# - '**/*.tsx'
# - '.eslintrc*'
# - 'package.json'
# - 'yarn.lock'
# - 'pnpm-lock.yaml'
# - 'package-lock.json'
on:
pull_request:
paths:
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
- '**/*.tsx'
- '.eslintrc*'
- 'package.json'
- 'yarn.lock'
- 'pnpm-lock.yaml'
- 'package-lock.json'
workflow_dispatch:
jobs:
Linter:
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "Опити за повторно",
"expectedResponseCodes": "Очаквани кодове за отговор",
"expectedResponseCodesDescription": "HTTP статус код, указващ здравословно състояние. Ако бъде оставено празно, между 200-300 се счита за здравословно.",
"customHeaders": "Персонализирани заглавия",
"customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value",
"customRequestHeaders": "Персонализирани заглавия на заявката",
"customRequestHeadersDescription": "Заглавия на заявката, изпратени до целевите сървъри. По едно на ред: Заглавие-Име: стойност",
"customResponseHeaders": "Персонализирани заглавия на отговора",
"customResponseHeadersDescription": "Заглавия на отговора, върнати на клиента. По едно на ред: Заглавие-Име: стойност",
"headersValidationError": "Заглавията трябва да бъдат във формат: Име на заглавието: стойност.",
"saveHealthCheck": "Запазване на проверка на здравето",
"healthCheckSaved": "Проверка на здравето е запазена",
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "Opakovat pokusy",
"expectedResponseCodes": "Očekávané kódy odezvy",
"expectedResponseCodesDescription": "HTTP kód stavu, který označuje zdravý stav. Ponecháte-li prázdné, 200-300 je považováno za zdravé.",
"customHeaders": "Vlastní záhlaví",
"customHeadersDescription": "Záhlaví oddělená nová řádka: hodnota",
"customRequestHeaders": "Vlastní záhlaví požadavku",
"customRequestHeadersDescription": "Záhlaví požadavku odeslaná do cílů. Jedno na řádek: Název-záhlaví: hodnota",
"customResponseHeaders": "Vlastní záhlaví odpovědi",
"customResponseHeadersDescription": "Záhlaví odpovědi vrácená klientovi. Jedno na řádek: Název-záhlaví: hodnota",
"headersValidationError": "Headers must be in the format: Header-Name: value.",
"saveHealthCheck": "Uložit kontrolu stavu",
"healthCheckSaved": "Kontrola stavu uložena",
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "Forsøg igen",
"expectedResponseCodes": "Forventede svarkoder",
"expectedResponseCodesDescription": "HTTP-statuskode som indikerer sund status. Hvis den bliver stående tom, regnes 200-300 som sund.",
"customHeaders": "Brugerdefinerede headers",
"customHeadersDescription": "Headers som er adskilt med linje: Overskriftsnavn: værdi",
"customRequestHeaders": "Brugerdefinerede anmodnings-headers",
"customRequestHeadersDescription": "Anmodnings-headers som sendes til destinationerne. Én pr. linje: Header-navn: værdi",
"customResponseHeaders": "Brugerdefinerede svar-headers",
"customResponseHeadersDescription": "Svar-headers som sendes tilbage til klienten. Én pr. linje: Header-navn: værdi",
"headersValidationError": "Header skal være i formatet: header-navn: værdi.",
"saveHealthCheck": "Gem Sundhedstjek",
"healthCheckSaved": "Sundhedstjek Gemt",
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "Wiederholungsversuche",
"expectedResponseCodes": "Erwartete Antwortcodes",
"expectedResponseCodesDescription": "HTTP-Statuscode, der einen gesunden Zustand anzeigt. Wenn leer gelassen, wird 200-300 als gesund angesehen.",
"customHeaders": "Eigene Kopfzeilen",
"customHeadersDescription": "Header neue Zeile getrennt: Header-Name: Wert",
"customRequestHeaders": "Eigene Anfrage-Header",
"customRequestHeadersDescription": "Anfrage-Header, die an die Ziele gesendet werden. Eine pro Zeile: Header-Name: Wert",
"customResponseHeaders": "Eigene Antwort-Header",
"customResponseHeadersDescription": "Antwort-Header, die an den Client zurückgesendet werden. Eine pro Zeile: Header-Name: Wert",
"headersValidationError": "Header müssen im Format Header-Name: Wert sein.",
"saveHealthCheck": "Gesundheits-Check speichern",
"healthCheckSaved": "Gesundheits-Check gespeichert",
+82 -10
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",
@@ -235,7 +235,7 @@
"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.",
"licenseBillingBannerDescription": "To manage billing for your license keys, including payment methods and invoices, visit the billing page. You can generate additional licenses below to increase server capacity or deploy more instances.",
"licenseBillingBannerButton": "Go to Billing",
"resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource",
@@ -558,6 +558,10 @@
"licenseBannerDescription": "Unlock enterprise features for your self-hosted Pangolin instance. Purchase a license key to activate premium capabilities, then add it below.",
"licenseBannerGetLicense": "Get a License",
"licenseBannerViewDocs": "View Documentation",
"licenseUpgradeBannerTitle": "Need More Capacity?",
"licenseUpgradeBannerDescription": "If you need to increase your site or user capacity, you can buy more licenses from the app.pangolin.net portal and add them below to upgrade your instance.",
"licenseUpgradeBannerButton": "Buy More Licenses",
"licenseMultipleKeysDescription": "You have multiple licenses activated on this server. The highest tier is used.",
"communityEdition": "Community Edition",
"licenseAboutDescription": "This is for business and enterprise users who are using Pangolin in a commercial environment. If you are using Pangolin for personal use, you can ignore this section.",
"licenseKeyActivated": "License key activated",
@@ -576,10 +580,10 @@
"licenseQuestionRemove": "Are you sure you want to delete the license key ?",
"licenseKeyDelete": "Delete License Key",
"licenseKeyDeleteConfirm": "Confirm Delete License Key",
"licenseTitle": "Manage License Status",
"licenseTitle": "Manage Licenses",
"licenseTitleDescription": "View and manage license keys in the system",
"licenseHost": "Host License",
"licenseHostDescription": "Manage the main license key for the host.",
"licenseHost": "License",
"licenseHostDescription": "See the license tier and host information",
"licensedNot": "Not Licensed",
"hostId": "Host ID",
"licenseReckeckAll": "Recheck All Keys",
@@ -589,7 +593,16 @@
"licensePurchase": "Purchase License",
"licensePurchaseSites": "Purchase Additional Sites",
"licenseSitesUsedMax": "{usedSites} of {maxSites} sites used",
"licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} in system.",
"licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}}",
"licenseUsage": "Usage",
"licenseUsageDescription": "View the number of users and sites licensed for this host",
"licenseUsageSites": "Sites",
"licenseUsageUsers": "Users",
"licenseNoUserLimit": "There is no limit on the number of users using an unlicensed hot",
"licenseUsersUsedMax": "{usedUsers} of {maxUsers} users used",
"licenseUsersUsed": "{count, plural, =0 {# users} one {# user} other {# users}}",
"licenseUnlimited": "Unlimited",
"licenseTierLabel": "Tier",
"licensePurchaseDescription": "Choose how many sites you want to {selectedMode, select, license {purchase a license for. You can always add more sites later.} other {add to your existing license.}}",
"licenseFee": "License fee",
"licensePriceSite": "Price per site",
@@ -834,7 +847,7 @@
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
"rulesErrorValidation": "Invalid rules",
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).",
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, ASN, or method).",
"rulesErrorValueRequired": "Enter a value for this rule.",
"rulesErrorInvalidCountry": "Invalid country",
"rulesErrorInvalidCountryDescription": "Select a valid country.",
@@ -2123,6 +2136,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",
@@ -2829,8 +2843,10 @@
"retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
"customHeaders": "Custom Request Headers",
"customHeadersDescription": "Request headers sent to the downstream targets. Headers new line separated: Header-Name: value",
"customRequestHeaders": "Custom Request Headers",
"customRequestHeadersDescription": "Request headers sent to the downstream targets. One per line: Header-Name: value",
"customResponseHeaders": "Custom Response Headers",
"customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value",
"headersValidationError": "Headers must be in the format: Header-Name: value",
"saveHealthCheck": "Save Health Check",
"healthCheckSaved": "Health Check Saved",
@@ -3568,6 +3584,7 @@
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"validVirtualAPIKey": "Valid Virtual API Key",
"allowedRedirect": "Allowed Redirect",
"view": "View",
"configManaged": "Config Managed",
"connectedClient": "Connected Client",
@@ -4400,5 +4417,60 @@
"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.",
"rulesErrorInvalidMethod": "Invalid HTTP method",
"rulesErrorInvalidMethodDescription": "Select at least one HTTP method.",
"rulesSelectMethods": "Select methods"
}
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "Intentos de Reintento",
"expectedResponseCodes": "Códigos de respuesta esperados",
"expectedResponseCodesDescription": "Código de estado HTTP que indica un estado saludable. Si se deja en blanco, se considera saludable de 200 a 300.",
"customHeaders": "Cabeceras personalizadas",
"customHeadersDescription": "Nueva línea de cabeceras separada: Nombre de cabecera: valor",
"customRequestHeaders": "Cabeceras de solicitud personalizadas",
"customRequestHeadersDescription": "Cabeceras de solicitud enviadas a los destinos. Una por línea: Nombre-Cabecera: valor",
"customResponseHeaders": "Cabeceras de respuesta personalizadas",
"customResponseHeadersDescription": "Cabeceras de respuesta devueltas al cliente. Una por línea: Nombre-Cabecera: valor",
"headersValidationError": "Los encabezados deben estar en el formato: Nombre de cabecera: valor.",
"saveHealthCheck": "Guardar Chequeo de Salud",
"healthCheckSaved": "Chequeo de Salud Guardado",
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "Tentatives de réessai",
"expectedResponseCodes": "Codes de réponse attendus",
"expectedResponseCodesDescription": "Code de statut HTTP indiquant un état de santé satisfaisant. Si non renseigné, 200-300 est considéré comme satisfaisant.",
"customHeaders": "En-têtes personnalisés",
"customHeadersDescription": "En-têtes séparés par une nouvelle ligne: En-nom: valeur",
"customRequestHeaders": "En-têtes de requête personnalisés",
"customRequestHeadersDescription": "En-têtes de requête envoyés aux cibles. Un par ligne : Nom-En-tête : valeur",
"customResponseHeaders": "En-têtes de réponse personnalisés",
"customResponseHeadersDescription": "En-têtes de réponse renvoyés au client. Un par ligne : Nom-En-tête : valeur",
"headersValidationError": "Les entêtes doivent être au format : Header-Name: valeur.",
"saveHealthCheck": "Sauvegarder la vérification de l'état de santé",
"healthCheckSaved": "Vérification de l'état de santé enregistrée",
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "Tentativi di Riprova",
"expectedResponseCodes": "Codici di Risposta Attesi",
"expectedResponseCodesDescription": "Codice di stato HTTP che indica lo stato di salute. Se lasciato vuoto, considerato sano è compreso tra 200-300.",
"customHeaders": "Intestazioni Personalizzate",
"customHeadersDescription": "Intestazioni nuova riga separate: Intestazione-Nome: valore",
"customRequestHeaders": "Intestazioni di richiesta personalizzate",
"customRequestHeadersDescription": "Intestazioni di richiesta inviate ai target. Una per riga: Nome-Intestazione: valore",
"customResponseHeaders": "Intestazioni di risposta personalizzate",
"customResponseHeadersDescription": "Intestazioni di risposta restituite al client. Una per riga: Nome-Intestazione: valore",
"headersValidationError": "Le intestazioni devono essere nel formato: Intestazione-Nome: valore.",
"saveHealthCheck": "Salva Controllo Salute",
"healthCheckSaved": "Controllo Salute Salvato",
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "재시도 횟수",
"expectedResponseCodes": "예상 응답 코드",
"expectedResponseCodesDescription": "정상 상태를 나타내는 HTTP 상태 코드입니다. 비워 두면 200-300이 정상으로 간주됩니다.",
"customHeaders": "사용자 정의 헤더",
"customHeadersDescription": "헤더는 새 줄로 구분됨: Header-Name: value",
"customRequestHeaders": "사용자 정의 요청 헤더",
"customRequestHeadersDescription": "다운스트림 대상으로 전송되는 요청 헤더. 한 줄에 하나씩: 헤더-이름: 값",
"customResponseHeaders": "사용자 정의 응답 헤더",
"customResponseHeadersDescription": "클라이언트로 반환되는 응답 헤더. 한 줄에 하나씩: 헤더-이름: 값",
"headersValidationError": "헤더는 형식이어야 합니다: 헤더명: 값.",
"saveHealthCheck": "상태 확인 저장",
"healthCheckSaved": "상태 확인이 저장되었습니다.",
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "Forsøk på nytt",
"expectedResponseCodes": "Forventede svarkoder",
"expectedResponseCodesDescription": "HTTP-statuskode som indikerer sunn status. Hvis den blir stående tom, regnes 200-300 som sunn.",
"customHeaders": "Egendefinerte topptekster",
"customHeadersDescription": "Overskrifter som er adskilt med linje: Overskriftsnavn: verdi",
"customRequestHeaders": "Egendefinerte forespørselshoder",
"customRequestHeadersDescription": "Forespørselshoder sendt til målene. Én per linje: Header-Navn: verdi",
"customResponseHeaders": "Egendefinerte svarhoder",
"customResponseHeadersDescription": "Svarhoder sendt tilbake til klienten. Én per linje: Header-Navn: verdi",
"headersValidationError": "Topptekst må være i formatet: header-navn: verdi.",
"saveHealthCheck": "Lagre Helsekontroll",
"healthCheckSaved": "Helsekontroll Lagret",
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "Herhaal Pogingen",
"expectedResponseCodes": "Verwachte Reactiecodes",
"expectedResponseCodesDescription": "HTTP-statuscode die gezonde status aangeeft. Indien leeg wordt 200-300 als gezond beschouwd.",
"customHeaders": "Aangepaste headers",
"customHeadersDescription": "Kopregeleinde: Header-Naam: waarde",
"customRequestHeaders": "Aangepaste verzoekheaders",
"customRequestHeadersDescription": "Verzoekheaders die worden doorgestuurd naar het doel. Per regel één: Header-Naam: waarde",
"customResponseHeaders": "Aangepaste antwoordheaders",
"customResponseHeadersDescription": "Antwoordheaders die worden teruggestuurd naar de client. Per regel één: Header-Naam: waarde",
"headersValidationError": "Headers moeten in het formaat zijn: Header-Naam: waarde.",
"saveHealthCheck": "Opslaan Gezondheidscontrole",
"healthCheckSaved": "Gezondheidscontrole Opgeslagen",
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "Próby Ponowienia",
"expectedResponseCodes": "Oczekiwane Kody Odpowiedzi",
"expectedResponseCodesDescription": "Kod statusu HTTP, który wskazuje zdrowy status. Jeśli pozostanie pusty, uznaje się 200-300 za zdrowy.",
"customHeaders": "Niestandardowe nagłówki",
"customHeadersDescription": "Nagłówki oddzielone: Nazwa nagłówka: wartość",
"customRequestHeaders": "Niestandardowe nagłówki żądania",
"customRequestHeadersDescription": "Nagłówki żądania wysyłane do celów. Jeden w wierszu: Nazwa-Nagłówka: wartość",
"customResponseHeaders": "Niestandardowe nagłówki odpowiedzi",
"customResponseHeadersDescription": "Nagłówki odpowiedzi zwracane do klienta. Jeden w wierszu: Nazwa-Nagłówka: wartość",
"headersValidationError": "Nagłówki muszą być w formacie: Nazwa nagłówka: wartość.",
"saveHealthCheck": "Zapisz Kontrolę Zdrowia",
"healthCheckSaved": "Kontrola Zdrowia Zapisana",
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "Tentativas de Repetição",
"expectedResponseCodes": "Códigos de Resposta Esperados",
"expectedResponseCodesDescription": "Código de status HTTP que indica estado saudável. Se deixado em branco, 200-300 é considerado saudável.",
"customHeaders": "Cabeçalhos Personalizados",
"customHeadersDescription": "Separados por cabeçalhos da nova linha: Nome do Cabeçalho: valor",
"customRequestHeaders": "Cabeçalhos de Pedido Personalizados",
"customRequestHeadersDescription": "Cabeçalhos de pedido enviados para os destinos. Um por linha: Nome-Cabeçalho: valor",
"customResponseHeaders": "Cabeçalhos de Resposta Personalizados",
"customResponseHeadersDescription": "Cabeçalhos de resposta enviados de volta ao cliente. Um por linha: Nome-Cabeçalho: valor",
"headersValidationError": "Cabeçalhos devem estar no formato: Nome do Cabeçalho: valor.",
"saveHealthCheck": "Salvar Verificação de Saúde",
"healthCheckSaved": "Verificação de Saúde Salva",
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "Количество попыток повторного запроса",
"expectedResponseCodes": "Ожидаемые коды ответов",
"expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.",
"customHeaders": "Пользовательские заголовки",
"customHeadersDescription": "Заголовки новой строки, разделённые: название заголовка: значение",
"customRequestHeaders": "Пользовательские заголовки запроса",
"customRequestHeadersDescription": "Заголовки запроса, отправляемые целевым серверам. По одному в строке: Имя-Заголовка: значение",
"customResponseHeaders": "Пользовательские заголовки ответа",
"customResponseHeadersDescription": "Заголовки ответа, возвращаемые клиенту. По одному в строке: Имя-Заголовка: значение",
"headersValidationError": "Заголовки должны быть в формате: Название заголовка: значение.",
"saveHealthCheck": "Сохранить проверку здоровья",
"healthCheckSaved": "Проверка здоровья сохранена",
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "Tekrar Deneme Girişimleri",
"expectedResponseCodes": "Beklenen Yanıt Kodları",
"expectedResponseCodesDescription": "Sağlıklı durumu gösteren HTTP durum kodu. Boş bırakılırsa, 200-300 arası sağlıklı kabul edilir.",
"customHeaders": "Özel Başlıklar",
"customHeadersDescription": "Başlıklar yeni satırla ayrılmış: Başlık-Adı: değer",
"customRequestHeaders": "Özel İstek Başlıkları",
"customRequestHeadersDescription": "Hedeflere gönderilen istek başlıkları. Satır başına bir tane: Başlık-Adı: değer",
"customResponseHeaders": "Özel Yanıt Başlıkları",
"customResponseHeadersDescription": "İstemciye geri gönderilen yanıt başlıkları. Satır başına bir tane: Başlık-Adı: değer",
"headersValidationError": "Başlıklar şu formatta olmalıdır: Başlık-Adı: değer.",
"saveHealthCheck": "Sağlık Kontrolünü Kaydet",
"healthCheckSaved": "Sağlık Kontrolü Kaydedildi",
+4 -2
View File
@@ -2826,8 +2826,10 @@
"retryAttempts": "重试次数",
"expectedResponseCodes": "期望响应代码",
"expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。",
"customHeaders": "自定义标题",
"customHeadersDescription": "头部新行分隔:头部名称:值",
"customRequestHeaders": "自定义请求标头",
"customRequestHeadersDescription": "转发到目标的请求标头。每行一个:标头名称:值",
"customResponseHeaders": "自定义响应标头",
"customResponseHeadersDescription": "返回给客户端的响应标头。每行一个:标头名称:值",
"headersValidationError": "头部必须是格式:头部名称:值。",
"saveHealthCheck": "保存健康检查",
"healthCheckSaved": "健康检查已保存",
+4 -2
View File
@@ -1551,8 +1551,10 @@
"retryAttempts": "重試次數",
"expectedResponseCodes": "期望響應代碼",
"expectedResponseCodesDescription": "HTTP 狀態碼表示健康狀態。如留空,200-300 被視為健康。",
"customHeaders": "自訂 Headers",
"customHeadersDescription": "Header 斷行分隔Header 名稱:值",
"customRequestHeaders": "自訂請求 Headers",
"customRequestHeadersDescription": "轉發至目標的請求標頭。每行一個Header-名稱:值",
"customResponseHeaders": "自訂回應 Headers",
"customResponseHeadersDescription": "回傳給客戶端的回應標頭。每行一個:Header-名稱:值",
"headersValidationError": "Header 必須是格式:Header 名稱:值。",
"saveHealthCheck": "保存健康檢查",
"healthCheckSaved": "健康檢查已保存",
+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(
+77 -48
View File
@@ -4,42 +4,18 @@
"iPad2,2": "iPad 2",
"iPad2,3": "iPad 2",
"iPad2,4": "iPad 2",
"iPad3,1": "iPad 3rd Gen",
"iPad3,3": "iPad 3rd Gen",
"iPad3,2": "iPad 3rd Gen",
"iPad3,4": "iPad 4th Gen",
"iPad3,5": "iPad 4th Gen",
"iPad3,6": "iPad 4th Gen",
"iPad6,11": "iPad 9.7 5th Gen",
"iPad6,12": "iPad 9.7 5th Gen",
"iPad7,5": "iPad 9.7 6th Gen",
"iPad7,6": "iPad 9.7 6th Gen",
"iPad7,11": "iPad 10.2 7th Gen",
"iPad7,12": "iPad 10.2 7th Gen",
"iPad11,6": "iPad 10.2 8th Gen",
"iPad11,7": "iPad 10.2 8th Gen",
"iPad12,1": "iPad 10.2 9th Gen",
"iPad12,2": "iPad 10.2 9th Gen",
"iPad13,18": "iPad 10.9 10th Gen",
"iPad13,19": "iPad 10.9 10th Gen",
"iPad4,1": "iPad Air",
"iPad4,2": "iPad Air",
"iPad4,3": "iPad Air",
"iPad5,3": "iPad Air 2",
"iPad5,4": "iPad Air 2",
"iPad11,3": "iPad Air 3rd Gen",
"iPad11,4": "iPad Air 3rd Gen",
"iPad13,1": "iPad Air 4th Gen",
"iPad13,2": "iPad Air 4th Gen",
"iPad13,16": "iPad Air 5th Gen",
"iPad13,17": "iPad Air 5th Gen",
"iPad14,8": "iPad Air M2 11",
"iPad14,9": "iPad Air M2 11",
"iPad14,10": "iPad Air M2 13",
"iPad14,11": "iPad Air M2 13",
"iPad2,5": "iPad mini",
"iPad2,6": "iPad mini",
"iPad2,7": "iPad mini",
"iPad3,1": "iPad 3rd Gen",
"iPad3,2": "iPad 3rd Gen",
"iPad3,3": "iPad 3rd Gen",
"iPad3,4": "iPad 4th Gen",
"iPad3,5": "iPad 4th Gen",
"iPad3,6": "iPad 4th Gen",
"iPad4,1": "iPad Air",
"iPad4,2": "iPad Air",
"iPad4,3": "iPad Air",
"iPad4,4": "iPad mini 2",
"iPad4,5": "iPad mini 2",
"iPad4,6": "iPad mini 2",
@@ -48,18 +24,22 @@
"iPad4,9": "iPad mini 3",
"iPad5,1": "iPad mini 4",
"iPad5,2": "iPad mini 4",
"iPad11,1": "iPad mini 5th Gen",
"iPad11,2": "iPad mini 5th Gen",
"iPad14,1": "iPad mini 6th Gen",
"iPad14,2": "iPad mini 6th Gen",
"iPad6,7": "iPad Pro 12.9",
"iPad6,8": "iPad Pro 12.9",
"iPad5,3": "iPad Air 2",
"iPad5,4": "iPad Air 2",
"iPad6,3": "iPad Pro 9.7",
"iPad6,4": "iPad Pro 9.7",
"iPad7,3": "iPad Pro 10.5",
"iPad7,4": "iPad Pro 10.5",
"iPad6,7": "iPad Pro 12.9",
"iPad6,8": "iPad Pro 12.9",
"iPad6,11": "iPad 9.7 5th Gen",
"iPad6,12": "iPad 9.7 5th Gen",
"iPad7,1": "iPad Pro 12.9",
"iPad7,2": "iPad Pro 12.9",
"iPad7,3": "iPad Pro 10.5",
"iPad7,4": "iPad Pro 10.5",
"iPad7,5": "iPad 9.7 6th Gen",
"iPad7,6": "iPad 9.7 6th Gen",
"iPad7,11": "iPad 10.2 7th Gen",
"iPad7,12": "iPad 10.2 7th Gen",
"iPad8,1": "iPad Pro 11",
"iPad8,2": "iPad Pro 11",
"iPad8,3": "iPad Pro 11",
@@ -72,6 +52,16 @@
"iPad8,10": "iPad Pro 11",
"iPad8,11": "iPad Pro 12.9",
"iPad8,12": "iPad Pro 12.9",
"iPad11,1": "iPad mini 5th Gen",
"iPad11,2": "iPad mini 5th Gen",
"iPad11,3": "iPad Air 3rd Gen",
"iPad11,4": "iPad Air 3rd Gen",
"iPad11,6": "iPad 10.2 8th Gen",
"iPad11,7": "iPad 10.2 8th Gen",
"iPad12,1": "iPad 10.2 9th Gen",
"iPad12,2": "iPad 10.2 9th Gen",
"iPad13,1": "iPad Air 4th Gen",
"iPad13,2": "iPad Air 4th Gen",
"iPad13,4": "iPad Pro 11",
"iPad13,5": "iPad Pro 11",
"iPad13,6": "iPad Pro 11",
@@ -80,14 +70,40 @@
"iPad13,9": "iPad Pro 12.9",
"iPad13,10": "iPad Pro 12.9",
"iPad13,11": "iPad Pro 12.9",
"iPad13,16": "iPad Air M1 5th Gen",
"iPad13,17": "iPad Air M1 5th Gen",
"iPad13,18": "iPad 10.9 10th Gen",
"iPad13,19": "iPad 10.9 10th Gen",
"iPad14,1": "iPad mini 6th Gen",
"iPad14,2": "iPad mini 6th Gen",
"iPad14,3": "iPad Pro 11",
"iPad14,4": "iPad Pro 11",
"iPad14,5": "iPad Pro 12.9",
"iPad14,6": "iPad Pro 12.9",
"iPad14,8": "iPad Air M2 11",
"iPad14,9": "iPad Air M2 11",
"iPad14,10": "iPad Air M2 13",
"iPad14,11": "iPad Air M2 13",
"iPad15,3": "iPad Air M3 11",
"iPad15,4": "iPad Air M3 11",
"iPad15,5": "iPad Air M3 13",
"iPad15,6": "iPad Air M3 13",
"iPad15,7": "iPad A16 - 11th Gen",
"iPad15,8": "iPad A16 - 11th Gen",
"iPad16,1": "iPad mini A17 Pro - 7th Gen",
"iPad16,2": "iPad mini A17 Pro - 7th Gen",
"iPad16,3": "iPad Pro M4 11",
"iPad16,4": "iPad Pro M4 11",
"iPad16,5": "iPad Pro M4 13",
"iPad16,6": "iPad Pro M4 13",
"iPad16,8": "iPad Air M4 11",
"iPad16,9": "iPad Air M4 11",
"iPad16,10": "iPad Air M4 13",
"iPad16,11": "iPad Air M4 13",
"iPad17,1": "iPad Pro M5 11",
"iPad17,2": "iPad Pro M5 11",
"iPad17,3": "iPad Pro M5 13",
"iPad17,4": "iPad Pro M5 13",
"iPhone1,1": "iPhone",
"iPhone1,2": "iPhone 3G",
"iPhone2,1": "iPhone 3GS",
@@ -101,20 +117,20 @@
"iPhone5,4": "iPhone 5c",
"iPhone6,1": "iPhone 5s",
"iPhone6,2": "iPhone 5s",
"iPhone7,2": "iPhone 6",
"iPhone7,1": "iPhone 6 Plus",
"iPhone7,2": "iPhone 6",
"iPhone8,1": "iPhone 6s",
"iPhone8,2": "iPhone 6s Plus",
"iPhone8,4": "iPhone SE",
"iPhone9,1": "iPhone 7",
"iPhone9,3": "iPhone 7",
"iPhone9,2": "iPhone 7 Plus",
"iPhone9,3": "iPhone 7",
"iPhone9,4": "iPhone 7 Plus",
"iPhone10,1": "iPhone 8",
"iPhone10,4": "iPhone 8",
"iPhone10,2": "iPhone 8 Plus",
"iPhone10,5": "iPhone 8 Plus",
"iPhone10,3": "iPhone X",
"iPhone10,4": "iPhone 8",
"iPhone10,5": "iPhone 8 Plus",
"iPhone10,6": "iPhone X",
"iPhone11,2": "iPhone Xs",
"iPhone11,6": "iPhone Xs Max",
@@ -127,10 +143,10 @@
"iPhone13,2": "iPhone 12",
"iPhone13,3": "iPhone 12 Pro",
"iPhone13,4": "iPhone 12 Pro Max",
"iPhone14,4": "iPhone 13 mini",
"iPhone14,5": "iPhone 13",
"iPhone14,2": "iPhone 13 Pro",
"iPhone14,3": "iPhone 13 Pro Max",
"iPhone14,4": "iPhone 13 mini",
"iPhone14,5": "iPhone 13",
"iPhone14,6": "iPhone SE",
"iPhone14,7": "iPhone 14",
"iPhone14,8": "iPhone 14 Plus",
@@ -140,6 +156,19 @@
"iPhone15,5": "iPhone 15 Plus",
"iPhone16,1": "iPhone 15 Pro",
"iPhone16,2": "iPhone 15 Pro Max",
"iPhone17,1": "iPhone 16 Pro",
"iPhone17,2": "iPhone 16 Pro Max",
"iPhone17,3": "iPhone 16",
"iPhone17,4": "iPhone 16 Plus",
"iPhone17,5": "iPhone 16e",
"iPhone18,1": "iPhone 17 Pro",
"iPhone18,2": "iPhone 17 Pro Max",
"iPhone18,3": "iPhone 17",
"iPhone18,4": "iPhone Air",
"iPhone18,5": "iPhone 17e",
"iPhone19,2": "iPhone 18 Pro",
"iPhone19,3": "iPhone 18 Pro Max",
"iPhone19,7": "iPhone 18 Pro Max",
"iPod1,1": "iPod touch Original",
"iPod2,1": "iPod touch 2nd",
"iPod3,1": "iPod touch 3rd Gen",
@@ -147,4 +176,4 @@
"iPod5,1": "iPod touch 5th",
"iPod7,1": "iPod touch 6th Gen",
"iPod9,1": "iPod touch 7th Gen"
}
}
+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> {
+40 -1
View File
@@ -186,7 +186,8 @@ export const resources = pgTable(
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "set null"
}),
headers: text("headers"), // comma-separated list of headers to add to the request
requestHeaders: text("requestHeaders"),
responseHeaders: text("responseHeaders"),
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
@@ -227,6 +228,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",
{
@@ -1137,6 +1173,7 @@ export const resourceRules = pgTable("resourceRules", {
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
| "METHOD"
>()
.notNull(), // CIDR, PATH, IP
value: varchar("value").notNull()
@@ -1161,6 +1198,7 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
| "METHOD"
>()
.notNull(),
value: varchar("value").notNull()
@@ -2067,6 +2105,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
*/
+42 -1
View File
@@ -202,7 +202,8 @@ export const resources = sqliteTable(
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "set null"
}),
headers: text("headers"), // comma-separated list of headers to add to the request
requestHeaders: text("requestHeaders"),
responseHeaders: text("responseHeaders"),
proxyProtocol: integer("proxyProtocol", { mode: "boolean" })
.notNull()
.default(false),
@@ -243,6 +244,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",
{
@@ -1409,6 +1447,7 @@ export const resourceRules = sqliteTable("resourceRules", {
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
| "METHOD"
>()
.notNull(), // CIDR, PATH, IP
value: text("value").notNull()
@@ -1465,6 +1504,7 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
| "METHOD"
>()
.notNull(),
value: text("value").notNull()
@@ -2106,6 +2146,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>;
@@ -235,7 +235,7 @@ export const AlertNotification = (props: AlertNotificationProps) => {
}
]
: []),
{ label: "Time", value: new Date().toUTCString() },
{ label: "Time (UTC)", value: new Date().toUTCString() },
...dataItems
];
@@ -265,8 +265,8 @@ export const AlertNotification = (props: AlertNotificationProps) => {
</EmailText>
{isTestAlert && (
<EmailText>
This is a test alert. No action is required,
and no real event has occurred.
This is a test alert. No action is required, and
no real event has occurred.
</EmailText>
)}
+1
View File
@@ -3,3 +3,4 @@ export * from "./features";
export * from "./limitsService";
export * from "./getOrgTierData";
export * from "./createCustomer";
export * from "./linkEmailOrg";
+6
View File
@@ -0,0 +1,6 @@
export async function linkEmailOrg(
orgId: string,
email: string | null | undefined
): Promise<void> {
return;
}
+33 -8
View File
@@ -48,7 +48,13 @@ import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { pickPort } from "@server/routers/target/helpers";
import { and, asc, eq, isNotNull, ne } from "drizzle-orm";
import { tierMatrix } from "../billing/tierMatrix";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import {
isValidCIDR,
isValidHttpMethodList,
isValidIP,
isValidUrlGlobPattern,
parseHttpMethodList
} from "../validators";
import { Config, isTargetsOnlyResource, TargetData } from "./types";
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
import { findOrgUsersByIdentifier } from "./findOrgUser";
@@ -255,10 +261,18 @@ export async function updatePublicResources(
resourceData.ssl == undefined || resourceData.ssl == null
? true
: resourceData.ssl;
let headers = "";
if (resourceData.headers) {
headers = JSON.stringify(resourceData.headers);
}
// `headers` is a deprecated alias for `requestHeaders`
const mergedRequestHeaders = [
...(resourceData.headers ?? []),
...(resourceData.requestHeaders ?? [])
];
const requestHeaders =
mergedRequestHeaders.length > 0
? JSON.stringify(mergedRequestHeaders)
: null;
const responseHeaders = resourceData.responseHeaders?.length
? JSON.stringify(resourceData.responseHeaders)
: null;
if (resourceData.policy) {
const isLicensed = await isLicensedOrSubscribed(
@@ -397,7 +411,8 @@ export async function updatePublicResources(
? resourceData.auth["whitelist-users"].length >
0
: false,
headers: headers || null,
requestHeaders,
responseHeaders,
applyRules:
resourceData.rules &&
resourceData.rules.length > 0,
@@ -593,7 +608,8 @@ export async function updatePublicResources(
setHostHeader: resourceData["host-header"] || null,
tlsServerName:
resourceData["tls-server-name"] || null,
headers: headers || null,
requestHeaders,
responseHeaders,
maintenanceModeEnabled:
resourceData.maintenance?.enabled,
maintenanceModeType: resourceData.maintenance?.type,
@@ -1216,7 +1232,8 @@ export async function updatePublicResources(
setHostHeader: resourceData["host-header"] || null,
tlsServerName: resourceData["tls-server-name"] || null,
ssl: resourceSsl,
headers: headers || null,
requestHeaders,
responseHeaders,
applyRules:
resourceData.rules && resourceData.rules.length > 0,
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
@@ -1453,6 +1470,10 @@ function getRuleValue(match: string, value: string) {
if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
return value.toUpperCase();
}
// normalize the method list so it is stored as "POST,PUT"
if (match === "METHOD") {
return parseHttpMethodList(value).join(",");
}
return value;
}
@@ -1473,6 +1494,10 @@ function validateRule(rule: any) {
if (!isValidRegionId(rule.value)) {
throw new Error(`Invalid region ID provided: ${rule.value}`);
}
} else if (rule.match === "method") {
if (!isValidHttpMethodList(rule.value)) {
throw new Error(`Invalid HTTP method provided: ${rule.value}`);
}
}
}
+16 -12
View File
@@ -19,7 +19,13 @@ import logger from "@server/logger";
import { getUniqueResourcePolicyName } from "@server/db/names";
import { hashPassword } from "@server/auth/password";
import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import {
isValidCIDR,
isValidHttpMethodList,
isValidIP,
isValidUrlGlobPattern,
ResourceRuleMatchType
} from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
import { findOrgUsersByIdentifier } from "./findOrgUser";
@@ -66,6 +72,13 @@ export async function updateResourcePolicies(
throw new Error(
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
);
} else if (
rule.match === "method" &&
!isValidHttpMethodList(rule.value)
) {
throw new Error(
`Invalid HTTP method provided in resource policy '${policyNiceId}': ${rule.value}`
);
}
}
@@ -339,17 +352,8 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
return "PASS";
}
function getRuleMatch(
input: string
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
return input.toUpperCase() as
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION";
function getRuleMatch(input: string): ResourceRuleMatchType {
return input.toUpperCase() as ResourceRuleMatchType;
}
async function syncRolePolicies(
+27 -2
View File
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { portRangeStringSchema } from "@server/lib/ip";
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
import { isValidRegionId } from "@server/db/regions";
import { isValidHttpMethodList } from "@server/lib/validators";
import { wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
import {
@@ -127,7 +128,16 @@ export const AuthSchema = z.object({
export const RuleSchema = z
.object({
action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "country_is_not", "asn", "region"]),
match: z.enum([
"cidr",
"path",
"ip",
"country",
"country_is_not",
"asn",
"region",
"method"
]),
value: z.coerce.string(),
priority: z.int().optional(),
enabled: z.boolean().optional().default(true)
@@ -207,6 +217,19 @@ export const RuleSchema = z
message:
"Value must be a valid UN M.49 region or subregion ID when match is 'region'"
}
)
.refine(
(rule) => {
if (rule.match === "method") {
return isValidHttpMethodList(rule.value);
}
return true;
},
{
path: ["value"],
message:
"Value must be a comma-separated list of HTTP methods when match is 'method', e.g. 'POST,PUT'"
}
);
export const HeaderSchema = z.object({
@@ -303,7 +326,9 @@ export const PublicResourceSchema = z
auth: AuthSchema.optional(),
"host-header": z.string().optional(),
"tls-server-name": z.string().optional(),
headers: z.array(HeaderSchema).optional(),
headers: z.array(HeaderSchema).optional(), // deprecated alias for requestHeaders
requestHeaders: z.array(HeaderSchema).optional(),
responseHeaders: z.array(HeaderSchema).optional(),
rules: z.array(RuleSchema).optional(),
maintenance: MaintenanceSchema.optional(),
"auth-daemon": AuthDaemonSchema.optional(),
+133 -38
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";
@@ -96,7 +97,8 @@ export async function getTraefikConfig(
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy,
headers: resources.headers,
requestHeaders: resources.requestHeaders,
responseHeaders: resources.responseHeaders,
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
wildcard: resources.wildcard,
@@ -128,6 +130,7 @@ export async function getTraefikConfig(
siteOnline: sites.online,
subnet: sites.exitNodeSubnet,
exitNodeId: sites.exitNodeId,
// Domain cert resolver fields
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert
@@ -225,7 +228,8 @@ export async function getTraefikConfig(
setHostHeader: row.setHostHeader,
enableProxy: row.enableProxy,
targets: [],
headers: row.headers,
requestHeaders: row.requestHeaders,
responseHeaders: row.responseHeaders,
proxyProtocol: row.proxyProtocol,
proxyProtocolVersion: row.proxyProtocolVersion ?? 1,
path: row.path, // the targets will all have the same path
@@ -326,12 +330,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 {};
}
@@ -405,7 +484,8 @@ export async function getTraefikConfig(
// Handle custom headers middleware
const customHeadersMiddleware = buildCustomHeadersMiddleware(
resource.headers,
resource.requestHeaders,
resource.responseHeaders,
resource.setHostHeader,
resource.resourceId
);
@@ -518,6 +598,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,
+57 -25
View File
@@ -1,46 +1,78 @@
import logger from "@server/logger";
function parseHeaders(
headers: string,
label: string,
resourceId: number
): { name: string; value: string }[] {
try {
return JSON.parse(headers) as {
name: string;
value: string;
}[];
} catch (e) {
logger.warn(
`Failed to parse ${label} for resource ${resourceId}: ${e}`
);
return [];
}
}
/**
* Build the customRequestHeaders middleware definition for a resource's
* custom headers + setHostHeader config. Returns null when there are no
* headers to set, so the caller can skip attaching the middleware.
* Build the custom headers middleware definition for a resource's
* custom request/response headers + setHostHeader config. Returns null when
* there are no headers to set, so the caller can skip attaching the
* middleware.
*/
export function buildCustomHeadersMiddleware(
headers: string | null | undefined,
requestHeaders: string | null | undefined,
responseHeaders: string | null | undefined,
setHostHeader: string | null | undefined,
resourceId: number
): { headers: { customRequestHeaders: { [key: string]: string } } } | null {
const headersObj: { [key: string]: string } = {};
): {
headers: {
customRequestHeaders?: { [key: string]: string };
customResponseHeaders?: { [key: string]: string };
};
} | null {
const requestHeadersObj: { [key: string]: string } = {};
const responseHeadersObj: { [key: string]: string } = {};
if (headers) {
let headersArr: { name: string; value: string }[] = [];
try {
headersArr = JSON.parse(headers) as {
name: string;
value: string;
}[];
} catch (e) {
logger.warn(
`Failed to parse headers for resource ${resourceId}: ${e}`
);
}
headersArr.forEach((header) => {
headersObj[header.name] = header.value;
});
if (requestHeaders) {
parseHeaders(requestHeaders, "requestHeaders", resourceId).forEach(
(header) => {
requestHeadersObj[header.name] = header.value;
}
);
}
if (setHostHeader) {
headersObj["Host"] = setHostHeader;
requestHeadersObj["Host"] = setHostHeader;
}
if (Object.keys(headersObj).length === 0) {
if (responseHeaders) {
parseHeaders(responseHeaders, "responseHeaders", resourceId).forEach(
(header) => {
responseHeadersObj[header.name] = header.value;
}
);
}
const hasRequestHeaders = Object.keys(requestHeadersObj).length > 0;
const hasResponseHeaders = Object.keys(responseHeadersObj).length > 0;
if (!hasRequestHeaders && !hasResponseHeaders) {
return null;
}
return {
headers: {
customRequestHeaders: headersObj
...(hasRequestHeaders && {
customRequestHeaders: requestHeadersObj
}),
...(hasResponseHeaders && {
customResponseHeaders: responseHeadersObj
})
}
};
}
+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[];
};
+41 -2
View File
@@ -1,9 +1,10 @@
import {
getResourceRuleValueValidationError,
isValidDomain,
isValidUrlGlobPattern
isValidUrlGlobPattern,
parseHttpMethodList
} from "./validators";
import { assertEquals } from "@test/assert";
import { assertEquals, assertEqualsObj } from "@test/assert";
function runTests() {
console.log("Running domain validation tests...");
@@ -295,6 +296,44 @@ function runTests() {
"Invalid ASN should return an error"
);
// HTTP method validation tests
assertEquals(
getResourceRuleValueValidationError("METHOD", "POST"),
null,
"Single HTTP method should be valid"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", " post , Put "),
null,
"Method list should be valid with mixed case and whitespace"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", "PROPFIND"),
null,
"Extension methods such as the WebDAV verbs should be valid"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", ""),
"Invalid HTTP method provided",
"Empty method list should return an error"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", ",,"),
"Invalid HTTP method provided",
"Method list of only separators should return an error"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", "GET POST"),
"Invalid HTTP method provided",
"Space separated methods should return an error"
);
assertEqualsObj(
parseHttpMethodList(" get ,post, "),
["GET", "POST"],
"Method list should be normalized to uppercase without empty entries"
);
console.log("All tests passed!");
}
+42 -1
View File
@@ -76,9 +76,46 @@ export const RESOURCE_RULE_MATCH_TYPES = [
"COUNTRY",
"COUNTRY_IS_NOT",
"ASN",
"REGION"
"REGION",
"METHOD"
] as const;
// The methods offered in the UI: the eight from RFC 9110 plus PATCH (RFC 5789)
// and QUERY (RFC 10008). A METHOD rule is not limited to these, since
// isValidHttpMethodList accepts any method token, so blueprints and the API can
// also target extension methods such as the WebDAV verbs.
export const HTTP_METHODS = [
"GET",
"HEAD",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
"TRACE",
"CONNECT",
"QUERY"
] as const;
// RFC 9110 token, minus the characters that would collide with the
// comma-separated list encoding.
const HTTP_METHOD_REGEX = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
export function parseHttpMethodList(value: string): string[] {
return value
.split(",")
.map((method) => method.trim().toUpperCase())
.filter((method) => method.length > 0);
}
export function isValidHttpMethodList(value: string): boolean {
const methods = parseHttpMethodList(value);
return (
methods.length > 0 &&
methods.every((method) => HTTP_METHOD_REGEX.test(method))
);
}
export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number];
export function getResourceRuleValueValidationError(
@@ -101,6 +138,10 @@ export function getResourceRuleValueValidationError(
return COUNTRIES.some((country) => country.code === value)
? null
: "Invalid country code provided";
case "METHOD":
return isValidHttpMethodList(value)
? null
: "Invalid HTTP method provided";
case "ASN":
const normalizedValue = value.trim().toUpperCase();
return /^AS\d+$/.test(normalizedValue) ||
+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
+1
View File
@@ -13,3 +13,4 @@
export * from "./getOrgTierData";
export * from "./createCustomer";
export * from "./linkEmailOrg";
@@ -0,0 +1,56 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import logger from "@server/logger";
import privateConfig from "#private/lib/config";
import { build } from "@server/build";
export async function linkEmailOrg(
orgId: string,
email: string | null | undefined
): Promise<void> {
if (build !== "saas") {
return;
}
if (!email) {
return;
}
try {
const response = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/link-email-org`,
{
method: "POST",
headers: {
"api-key":
privateConfig.getRawPrivateConfig().server
.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify({ email, orgId })
}
);
if (!response.ok && response.status !== 404) {
logger.error(
`Fossorial API returned ${response.status} when linking email ${email} to orgId ${orgId}: ${await response.text()}`
);
}
} catch (error) {
logger.error(
`Error notifying Fossorial API of email/org link for orgId ${orgId}:`,
error
);
}
}
+139 -9
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";
@@ -133,7 +138,8 @@ export async function getTraefikConfig(
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy,
headers: resources.headers,
requestHeaders: resources.requestHeaders,
responseHeaders: resources.responseHeaders,
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
wildcard: resources.wildcard,
@@ -214,7 +220,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 +248,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(
@@ -275,7 +281,8 @@ export async function getTraefikConfig(
setHostHeader: row.setHostHeader,
enableProxy: row.enableProxy,
targets: [],
headers: row.headers,
requestHeaders: row.requestHeaders,
responseHeaders: row.responseHeaders,
proxyProtocol: row.proxyProtocol,
proxyProtocolVersion: row.proxyProtocolVersion ?? 1,
path: row.path, // the targets will all have the same path
@@ -296,7 +303,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 +403,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 +525,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 +563,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 +693,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;
});
@@ -712,7 +813,8 @@ export async function getTraefikConfig(
);
const customHeadersMiddleware = buildCustomHeadersMiddleware(
resource.headers,
resource.requestHeaders,
resource.responseHeaders,
resource.setHostHeader,
resource.resourceId
);
@@ -781,6 +883,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,
+79 -2
View File
@@ -50,6 +50,27 @@ type ValidateLicenseAPIResponse = {
status: number;
};
// Ranks license tiers so that when multiple license keys are active, the
// highest tier among them wins. Order: personal < tier1 < tier2 < ... <
// tier[n] < enterprise. Tier numbers are parsed so this scales to any
// tier[n] without needing updates here.
function tierRank(tier?: LicenseKeyTier): number {
if (!tier) {
return -1;
}
if (tier === "enterprise") {
return Number.MAX_SAFE_INTEGER;
}
if (tier === "personal") {
return 0;
}
const match = /^tier(\d+)$/.exec(tier);
if (match) {
return parseInt(match[1], 10);
}
return 0;
}
type TokenPayload = {
valid: boolean;
type: LicenseKeyType;
@@ -276,6 +297,11 @@ LQIDAQAB
if (!apiResponse?.success) {
throw new Error(apiResponse?.error);
}
logger.debug(
`License server response: ${JSON.stringify(apiResponse)}`
);
// Reset failure count on success
this.phoneHomeFailureCount = 0;
} catch (e) {
@@ -338,6 +364,11 @@ LQIDAQAB
licenseKeyRes,
this.publicKey
);
logger.debug(
`Decoded license key ${key.licenseKey}: ${JSON.stringify(payload)}`
);
cached.valid = payload.valid;
cached.type = payload.type;
cached.tier = payload.tier;
@@ -370,13 +401,52 @@ LQIDAQAB
}
}
// Personal-tier licenses cannot coexist with a paid tier: if any
// valid host key is above personal, personal-tier keys are
// invalidated so they don't contribute to the totals below.
const hasHigherTierValidKey = keys.some((key) => {
const cached = newCache.get(key.licenseKey)!;
return (
cached.type === "host" &&
cached.valid &&
tierRank(cached.tier) > tierRank("personal")
);
});
if (hasHigherTierValidKey) {
for (const key of keys) {
const cached = newCache.get(key.licenseKey)!;
if (
cached.type === "host" &&
cached.valid &&
cached.tier === "personal"
) {
logger.debug(
`Invalidating personal license key ${key.licenseKey} because a higher tier license is present`
);
cached.valid = false;
newCache.set(key.licenseKey, cached);
}
}
}
// Compute host status: quantity = users, quantity_2 = sites
// When multiple host keys are active, prefer a valid key over an
// invalid one, and among equally-valid keys prefer the highest tier.
let selectedHostKey: LicenseKeyCache | undefined;
for (const key of keys) {
const cached = newCache.get(key.licenseKey)!;
if (cached.type === "host") {
status.isLicenseValid = cached.valid;
status.tier = cached.tier;
if (
!selectedHostKey ||
(cached.valid && !selectedHostKey.valid) ||
(cached.valid === selectedHostKey.valid &&
tierRank(cached.tier) >
tierRank(selectedHostKey.tier))
) {
selectedHostKey = cached;
}
}
if (!cached.valid) {
@@ -393,6 +463,11 @@ LQIDAQAB
}
}
if (selectedHostKey) {
status.isLicenseValid = selectedHostKey.valid;
status.tier = selectedHostKey.tier;
}
// Invalidate license if over user or site limits
if (
(status.maxSites !== undefined &&
@@ -415,6 +490,8 @@ LQIDAQAB
this.checkInProgress = false;
}
logger.debug(`Computed license status: ${JSON.stringify(status)}`);
this.statusCache.set(this.statusKey, status, 0);
return status;
}
+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
+117 -6
View File
@@ -10,6 +10,8 @@ import {
verifyVirtualApiKey
} from "@server/auth/verifyVirtualApiKey";
import {
type RedirectByHost,
getRedirectsByHost,
getResourceByDomain,
getResourceRules,
getRoleResourceAccess,
@@ -39,7 +41,10 @@ import {
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
import { isPathAllowed } from "@server/lib/pathMatch";
import { isPathAllowed, type isPathAllowed } from "@server/lib/pathMatch";
import { matchesPath } from "@server/lib/traefik/rule";
import { rewriteRequestPath } from "@server/lib/traefik/middleware";
import { parseHttpMethodList } from "@server/lib/validators";
import { response } from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
@@ -67,6 +72,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 +114,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;
@@ -163,6 +172,7 @@ export async function verifyResourceSession(
path,
headers,
query,
method,
badgerVersion
} = parsedBody.data;
@@ -192,6 +202,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 +233,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;
@@ -293,7 +325,8 @@ export async function verifyResourceSession(
clientIp,
path,
ipCC,
ipAsn
ipAsn,
method
);
if (action == "ACCEPT") {
@@ -1009,6 +1042,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,
@@ -1429,7 +1527,8 @@ async function checkRules(
clientIp: string | undefined,
path: string | undefined,
ipCC?: string,
ipAsn?: number
ipAsn?: number,
method?: string
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
const ruleCacheKey = `rules:${resourceId}`;
@@ -1504,12 +1603,24 @@ async function checkRules(
(await isIpInRegion(ipCC, rule.value))
) {
return rule.action as any;
} else if (
method &&
rule.match == "METHOD" &&
isMethodAllowed(rule.value, method)
) {
return rule.action as any;
}
}
return;
}
// rule.value holds a comma-separated list of HTTP methods, e.g. "POST,PUT".
function isMethodAllowed(ruleValue: string, method: string): boolean {
const requestMethod = method.toUpperCase();
return parseHttpMethodList(ruleValue).includes(requestMethod);
}
export { isPathAllowed };
async function isIpInGeoIP(
+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;
}
+2 -1
View File
@@ -25,7 +25,7 @@ import { fromError } from "zod-validation-error";
import { defaultRoleAllowedActions } from "../role";
import { OpenAPITags, registry } from "@server/openApi";
import { isValidCIDR } from "@server/lib/validators";
import { createCustomer } from "#dynamic/lib/billing";
import { createCustomer, linkEmailOrg } from "#dynamic/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
import { LimitId, limitsService, freeLimitSet } from "@server/lib/billing";
import { build } from "@server/build";
@@ -425,6 +425,7 @@ export async function createOrg(
customerId
); // Only 1 because we are creating the org
}
await linkEmailOrg(orgId, req.user?.email);
}
if (numOrgs) {
+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;
}
+9 -5
View File
@@ -53,9 +53,10 @@ async function queryInlinePolicy(resourcePolicyId: number) {
export type GetResourceResponse = Omit<
NonNullable<Awaited<ReturnType<typeof query>>>,
"headers"
"requestHeaders" | "responseHeaders"
> & {
headers: { name: string; value: string }[] | null;
requestHeaders: { name: string; value: string }[] | null;
responseHeaders: { name: string; value: string }[] | null;
};
registry.registerPath({
@@ -186,9 +187,12 @@ export async function getResource(
return response<GetResourceResponse>(res, {
data: {
...returnData,
headers: returnData.headers
? JSON.parse(returnData.headers)
: returnData.headers
requestHeaders: returnData.requestHeaders
? JSON.parse(returnData.requestHeaders)
: returnData.requestHeaders,
responseHeaders: returnData.responseHeaders
? JSON.parse(returnData.responseHeaders)
: returnData.responseHeaders
},
success: true,
error: false,
+59 -31
View File
@@ -100,6 +100,14 @@ const updateHttpResourceBodySchema = z
"When no shared resource policy is assigned (resourcePolicyId is null), updates the resource's inline policy. When a shared policy is assigned, this value overrides the shared policy for this resource."
),
headers: z
.array(z.strictObject({ name: z.string(), value: z.string() }))
.nullable()
.optional(), // deprecated alias for requestHeaders
requestHeaders: z
.array(z.strictObject({ name: z.string(), value: z.string() }))
.nullable()
.optional(),
responseHeaders: z
.array(z.strictObject({ name: z.string(), value: z.string() }))
.nullable()
.optional(),
@@ -163,12 +171,13 @@ const updateHttpResourceBodySchema = z
)
.refine(
(data) => {
if (data.headers) {
// HTTP header names must be valid token characters (RFC 7230)
const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
return data.headers.every((h) => validHeaderName.test(h.name));
}
return true;
const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
const allHeaders = [
...(data.headers ?? []),
...(data.requestHeaders ?? []),
...(data.responseHeaders ?? [])
];
return allHeaders.every((h) => validHeaderName.test(h.name));
},
{
error: "Header names may only contain valid HTTP token characters (letters, digits, and !#$%&'*+-.^_`|~)."
@@ -176,14 +185,13 @@ const updateHttpResourceBodySchema = z
)
.refine(
(data) => {
if (data.headers) {
// HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230)
const validHeaderValue = /^[\t\x20-\x7E]*$/;
return data.headers.every((h) =>
validHeaderValue.test(h.value)
);
}
return true;
const validHeaderValue = /^[\t\x20-\x7E]*$/;
const allHeaders = [
...(data.headers ?? []),
...(data.requestHeaders ?? []),
...(data.responseHeaders ?? [])
];
return allHeaders.every((h) => validHeaderValue.test(h.value));
},
{
error: "Header values may only contain printable ASCII characters and horizontal whitespace."
@@ -191,16 +199,17 @@ const updateHttpResourceBodySchema = z
)
.refine(
(data) => {
if (data.headers) {
// Reject Traefik template syntax {{word}} in names or values
const templatePattern = /\{\{[^}]+\}\}/;
return data.headers.every(
(h) =>
!templatePattern.test(h.name) &&
!templatePattern.test(h.value)
);
}
return true;
const templatePattern = /\{\{[^}]+\}\}/;
const allHeaders = [
...(data.headers ?? []),
...(data.requestHeaders ?? []),
...(data.responseHeaders ?? [])
];
return allHeaders.every(
(h) =>
!templatePattern.test(h.name) &&
!templatePattern.test(h.value)
);
},
{
error: "Header names and values must not contain template expressions such as {{value}}."
@@ -696,13 +705,32 @@ async function updateHttpResource(
await createCertificate(domainId, fullDomain, db);
}
let headers = undefined;
if (updateData.headers) {
headers = JSON.stringify(updateData.headers);
} else if (updateData.headers === null) {
headers = null;
let requestHeaders = undefined;
const mergedRequestHeaders = [
...(updateData.headers ?? []),
...(updateData.requestHeaders ?? [])
];
if (
updateData.headers !== undefined ||
updateData.requestHeaders !== undefined
) {
requestHeaders =
mergedRequestHeaders.length > 0
? JSON.stringify(mergedRequestHeaders)
: null;
}
let responseHeaders = undefined;
if (updateData.responseHeaders) {
responseHeaders = JSON.stringify(updateData.responseHeaders);
} else if (updateData.responseHeaders === null) {
responseHeaders = null;
}
updateData.headers = undefined;
updateData.requestHeaders = undefined;
updateData.responseHeaders = undefined;
if (!isLicensed) {
updateData.maintenanceModeEnabled = undefined;
updateData.maintenanceModeType = undefined;
@@ -755,7 +783,7 @@ async function updateHttpResource(
const updatedResource = await db
.update(resources)
.set({ ...resourceOnlyData, headers })
.set({ ...resourceOnlyData, requestHeaders, responseHeaders })
.where(eq(resources.resourceId, resource.resourceId))
.returning();
@@ -779,7 +807,7 @@ async function updateHttpResource(
const updatedResource = await db
.update(resources)
.set({ ...updateData, headers })
.set({ ...updateData, requestHeaders, responseHeaders })
.where(eq(resources.resourceId, resource.resourceId))
.returning();
@@ -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
@@ -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}
/>
</>
);
}
@@ -119,7 +119,10 @@ function ProxyResourceHttpForm({
message: t("proxyErrorInvalidHeader")
}
),
headers: z
requestHeaders: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable(),
responseHeaders: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable()
});
@@ -131,19 +134,21 @@ function ProxyResourceHttpForm({
ssl: resource.ssl,
tlsServerName: resource.tlsServerName || "",
setHostHeader: resource.setHostHeader || "",
headers: resource.headers
requestHeaders: resource.requestHeaders,
responseHeaders: resource.responseHeaders
},
mode: "onChange"
});
const [, formAction, saveLoading] = useActionState(onSubmit, null);
const [headersValid, setHeadersValid] = useState(true);
const [requestHeadersValid, setRequestHeadersValid] = useState(true);
const [responseHeadersValid, setResponseHeadersValid] = useState(true);
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
if (!headersValid) {
if (!requestHeadersValid || !responseHeadersValid) {
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
@@ -162,7 +167,8 @@ function ProxyResourceHttpForm({
ssl: data.ssl,
tlsServerName: data.tlsServerName || null,
setHostHeader: data.setHostHeader || null,
headers: data.headers || null
requestHeaders: data.requestHeaders || null,
responseHeaders: data.responseHeaders || null
}
)
.catch((err) => {
@@ -183,7 +189,8 @@ function ProxyResourceHttpForm({
ssl: data.ssl,
tlsServerName: data.tlsServerName || null,
setHostHeader: data.setHostHeader || null,
headers: data.headers || null
requestHeaders: data.requestHeaders || null,
responseHeaders: data.responseHeaders || null
});
toast({
@@ -324,11 +331,11 @@ function ProxyResourceHttpForm({
<SettingsFormCell span="full">
<FormField
control={form.control}
name="headers"
name="requestHeaders"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("customHeaders")}
{t("customRequestHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
@@ -337,14 +344,46 @@ function ProxyResourceHttpForm({
field.onChange
}
onValidityChange={
setHeadersValid
setRequestHeadersValid
}
rows={4}
/>
</FormControl>
<FormDescription>
{t(
"customHeadersDescription"
"customRequestHeadersDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="responseHeaders"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("customResponseHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
value={field.value}
onChange={
field.onChange
}
onValidityChange={
setResponseHeadersValid
}
rows={4}
/>
</FormControl>
<FormDescription>
{t(
"customResponseHeadersDescription"
)}
</FormDescription>
<FormMessage />
@@ -362,7 +401,11 @@ function ProxyResourceHttpForm({
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading || !headersValid}
disabled={
saveLoading ||
!requestHeadersValid ||
!responseHeadersValid
}
form="http-settings-form"
>
{t("saveSettings")}
@@ -113,7 +113,7 @@ function ProxyResourceProtocolForm({
message: t("proxyErrorInvalidHeader")
}
),
headers: z
requestHeaders: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable(),
proxyProtocol: z.boolean().optional(),
@@ -124,7 +124,7 @@ function ProxyResourceProtocolForm({
resolver: zodResolver(proxySettingsSchema),
defaultValues: {
setHostHeader: resource.setHostHeader || "",
headers: resource.headers,
requestHeaders: resource.requestHeaders,
proxyProtocol: resource.proxyProtocol || false,
proxyProtocolVersion: resource.proxyProtocolVersion || 1
}
+204 -64
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { LicenseKeyCache } from "@server/license/license";
import { LicenseKeyCache, LicenseKeyTier } from "@server/license/license";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
@@ -41,6 +41,7 @@ import {
SettingsSectionHeader,
SettingsSectionFooter
} from "@app/components/Settings";
import { Progress } from "@app/components/ui/progress";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
ArrowRight,
@@ -48,6 +49,7 @@ import {
ExternalLink,
Heart,
InfoIcon,
ShoppingCart,
TicketCheck
} from "lucide-react";
import Link from "next/link";
@@ -63,12 +65,24 @@ import { useTranslations } from "next-intl";
const ENTERPRISE_DOCS_URL =
"https://docs.pangolin.net/self-host/enterprise-edition";
const ENTERPRISE_PRICING_URL = "https://pangolin.net/pricing#Self-Hosted";
const LICENSE_PORTAL_URL =
"https://app.pangolin.net/auth/login?internal_redirect=/settings/license?generate";
function obfuscateLicenseKey(key: string): string {
if (key.length <= 8) return key;
const firstPart = key.substring(0, 4);
const lastPart = key.substring(key.length - 4);
return `${firstPart}••••••••••••••••••••${lastPart}`;
function getTierLabel(
tier: LicenseKeyTier | undefined,
t: (key: string) => string
): string {
switch (tier) {
case "enterprise":
return t("licenseTierEnterprise");
case "tier1":
return t("licenseTierTier1");
case "tier2":
return t("licenseTierTier2");
case "personal":
default:
return t("licenseTierPersonal");
}
}
export default function LicensePage() {
@@ -80,7 +94,6 @@ export default function LicensePage() {
useState<LicenseKeyCache | null>(null);
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
const [hostLicense, setHostLicense] = useState<string | null>(null);
const [isPurchaseModalOpen, setIsPurchaseModalOpen] = useState(false);
const [purchaseMode, setPurchaseMode] = useState<"license">("license");
@@ -128,12 +141,6 @@ export default function LicensePage() {
);
const keys = response.data.data;
setRows(keys);
const hostKey = keys.find((key) => key.type === "host");
if (hostKey) {
setHostLicense(hostKey.licenseKey);
} else {
setHostLicense(null);
}
} catch (e) {
toast({
title: t("licenseErrorKeyLoad"),
@@ -388,6 +395,33 @@ export default function LicensePage() {
</DismissableBanner>
)}
{licenseStatus?.isLicenseValid && rows.length > 0 && (
<DismissableBanner
storageKey="license-upgrade-banner-dismissed"
version={1}
title={t("licenseUpgradeBannerTitle")}
titleIcon={
<ShoppingCart className="w-5 h-5 text-primary" />
}
description={t("licenseUpgradeBannerDescription")}
>
<Link
href={LICENSE_PORTAL_URL}
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("licenseUpgradeBannerButton")}
<ExternalLink className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
)}
{/* <Alert variant="neutral" className="mb-6"> */}
{/* <InfoIcon className="h-4 w-4" /> */}
{/* <AlertTitle className="font-semibold"> */}
@@ -399,62 +433,168 @@ export default function LicensePage() {
{/* </Alert> */}
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>{t("licenseHost")}</SSTitle>
<SettingsSectionDescription>
{t("licenseHostDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="flex items-center space-x-4">
{licenseStatus?.isLicenseValid ? (
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
{t("licensed") +
`${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`}
</div>
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>{t("licenseUsage")}</SSTitle>
<SettingsSectionDescription>
{t("licenseUsageDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-6">
<div className="space-y-2">
<div className="text-sm font-medium">
{t("licenseUsageSites")}
</div>
) : (
<div className="text-2xl">
{t("unlicensed")}
{t("licenseSitesUsed", {
count: licenseStatus?.usedSites || 0
})}
</div>
{licenseStatus?.maxSites ? (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t("licenseSitesUsedMax", {
usedSites:
licenseStatus.usedSites ||
0,
maxSites:
licenseStatus.maxSites
})}
</span>
<span className="text-muted-foreground">
{Math.round(
((licenseStatus.usedSites ||
0) /
licenseStatus.maxSites) *
100
)}
%
</span>
</div>
<Progress
value={
((licenseStatus.usedSites ||
0) /
licenseStatus.maxSites) *
100
}
className="h-5"
/>
</div>
) : (
<div className="text-sm text-muted-foreground">
{t("licenseNoSiteLimit")}
</div>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-medium">
{t("licenseUsageUsers")}
</div>
<div className="text-2xl">
{t("licenseUsersUsed", {
count: licenseStatus?.usedUsers || 0
})}
</div>
{licenseStatus?.maxUsers ? (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t("licenseUsersUsedMax", {
usedUsers:
licenseStatus.usedUsers ||
0,
maxUsers:
licenseStatus.maxUsers
})}
</span>
<span className="text-muted-foreground">
{Math.round(
((licenseStatus.usedUsers ||
0) /
licenseStatus.maxUsers) *
100
)}
%
</span>
</div>
<Progress
value={
((licenseStatus.usedUsers ||
0) /
licenseStatus.maxUsers) *
100
}
className="h-5"
/>
</div>
) : (
<div className="text-sm text-muted-foreground">
{t("licenseNoUserLimit")}
</div>
)}
</div>
</div>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>{t("licenseHost")}</SSTitle>
<SettingsSectionDescription>
{t("licenseHostDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="flex items-center space-x-4">
{licenseStatus?.isLicenseValid ? (
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
{t("licensed") +
`${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`}
</div>
</div>
) : (
<div className="text-2xl">
{t("unlicensed")}
</div>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-medium">
{t("licenseTierLabel")}
</div>
<div className="text-lg">
{getTierLabel(licenseStatus?.tier, t)}
</div>
{rows.length > 1 && (
<div className="text-sm text-muted-foreground">
{t("licenseMultipleKeysDescription")}
</div>
)}
</div>
{licenseStatus?.hostId && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("hostId")}
</div>
<CopyTextBox text={licenseStatus.hostId} />
</div>
)}
</div>
{licenseStatus?.hostId && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("hostId")}
</div>
<CopyTextBox text={licenseStatus.hostId} />
</div>
)}
{hostLicense && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("licenseKey")}
</div>
<CopyTextBox
text={hostLicense}
displayText={obfuscateLicenseKey(
hostLicense
)}
/>
</div>
)}
</div>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={recheck}
disabled={isRecheckingLicense}
loading={isRecheckingLicense}
>
{t("licenseReckeckAll")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={recheck}
disabled={isRecheckingLicense}
loading={isRecheckingLicense}
>
{t("licenseReckeckAll")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsSectionGrid>
<LicenseKeysDataTable
licenseKeys={rows}
onDelete={(key) => {
+22 -11
View File
@@ -122,11 +122,20 @@ export default async function ResourceAuthPage(props: {
if (searchParams.redirect) {
try {
const redirectTarget = new URL(searchParams.redirect);
const serverResourceHost = new URL(authInfo.url).host;
const redirectHost = new URL(searchParams.redirect).host;
const redirectPort = new URL(searchParams.redirect).port;
const redirectHost = redirectTarget.host;
const redirectPort = redirectTarget.port;
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
// URL parses a host out of any scheme that uses "//", so a target
// like javascript://resource-host/... matches the comparisons
// below. The target is later assigned to window.location, so only
// http(s) is accepted here.
const isHttpTarget =
redirectTarget.protocol === "http:" ||
redirectTarget.protocol === "https:";
const wildcardMatchesRedirect = (
wildcardDomain: string,
host: string
@@ -136,14 +145,16 @@ export default async function ResourceAuthPage(props: {
return host.endsWith(suffix) && host.length > suffix.length;
};
if (serverResourceHost === redirectHost) {
redirectUrl = searchParams.redirect;
} else if (serverResourceHostWithPort === redirectHost) {
redirectUrl = searchParams.redirect;
} else if (
authInfo.wildcard &&
authInfo.fullDomain &&
wildcardMatchesRedirect(authInfo.fullDomain, redirectHost)
if (
isHttpTarget &&
(serverResourceHost === redirectHost ||
serverResourceHostWithPort === redirectHost ||
(authInfo.wildcard &&
authInfo.fullDomain &&
wildcardMatchesRedirect(
authInfo.fullDomain,
redirectHost
)))
) {
redirectUrl = searchParams.redirect;
}
@@ -283,7 +294,7 @@ export default async function ResourceAuthPage(props: {
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.type
variant: idp.variant ?? idp.type
})) as LoginFormIDP[];
}
+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
? [
{
+2 -2
View File
@@ -1355,7 +1355,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
<FormItem>
<FormLabel>
{t(
"customHeaders"
"customRequestHeaders"
)}
</FormLabel>
<FormControl>
@@ -1379,7 +1379,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
</FormControl>
<FormDescription>
{t(
"customHeadersDescription"
"customRequestHeadersDescription"
)}
</FormDescription>
<FormMessage />
+2 -2
View File
@@ -733,7 +733,7 @@ export function HealthCheckFormFields({
render={({ field }) => (
<FormItem>
<FormLabel>
{t("customHeaders")}
{t("customRequestHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
@@ -750,7 +750,7 @@ export function HealthCheckFormFields({
</FormControl>
<FormDescription>
{t(
"customHeadersDescription"
"customRequestHeadersDescription"
)}
</FormDescription>
<FormMessage />
+49
View File
@@ -112,6 +112,54 @@ export function LicenseKeysDataTable({
}
}
},
{
accessorKey: "quantity",
friendlyName: t("users"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("users")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const quantity = row.original.quantity;
if (quantity === undefined) {
return "-";
}
return quantity < 0 ? t("licenseUnlimited") : quantity;
}
},
{
accessorKey: "quantity_2",
friendlyName: t("sites"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("sites")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const quantity = row.original.quantity_2;
if (quantity === undefined) {
return "-";
}
return quantity < 0 ? t("licenseUnlimited") : quantity;
}
},
{
accessorKey: "terminateAt",
friendlyName: t("licenseTableValidUntil"),
@@ -158,6 +206,7 @@ export function LicenseKeysDataTable({
title={t("licenseKeys")}
searchPlaceholder={t("licenseKeySearch")}
searchColumn="licenseKey"
defaultSort={{ id: "terminateAt", desc: false }}
onAdd={onCreate}
addButtonText={t("licenseKeyAdd")}
enableColumnVisibility={true}
+968
View File
@@ -0,0 +1,968 @@
"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")
});
} 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>
</>
);
}
+500
View File
@@ -0,0 +1,500 @@
"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 {
useMemo,
useOptimistic,
useRef,
useState,
useTransition,
type ComponentRef
} from "react";
import { useDebouncedCallback } from "use-debounce";
import { wait } from "@app/lib/wait";
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 [selected, setSelected] = useState<RedirectRow | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
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}`);
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={redirects}
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>
);
}
+80 -2
View File
@@ -20,7 +20,7 @@ import {
FaWindows
} from "react-icons/fa";
import { Download, ExternalLink } from "lucide-react";
import { SiKubernetes, SiNixos } from "react-icons/si";
import { SiAlpinelinux, SiKubernetes, SiNixos } from "react-icons/si";
import { useEnvContext } from "@app/hooks/useEnvContext";
export type CommandItem =
@@ -30,6 +30,7 @@ export type CommandItem =
const PLATFORMS = [
"linux",
"alpine",
"macos",
"docker",
"kubernetes",
@@ -65,7 +66,8 @@ export function NewtSiteInstallCommands({
);
const showSiteConfiguration = platform !== "advantech";
const supportsSshOption = platform === "linux" || platform === "nixos";
const supportsSshOption =
platform === "linux" || platform === "nixos" || platform === "alpine";
const acceptClientsFlag = !acceptClients ? " --disable-clients" : "";
const acceptClientsEnv = !acceptClients
@@ -168,6 +170,76 @@ sudo systemctl enable --now pangolin-site`
}
]
},
alpine: {
Run: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
},
{
title: t("run"),
command: `${runAsRootPrefix}pangolin up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
}
],
"Manual OpenRC Service": [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
},
{
title: t("envFile"),
command: `sudo tee /etc/conf.d/pangolin-site > /dev/null << 'EOF'
export SITE_ID=${id}
export SITE_SECRET=${secret}
export PANGOLIN_ENDPOINT=${endpoint}${
!acceptClients
? `
export DISABLE_CLIENTS=true`
: ""
}${
!allowPangolinSsh
? `
export DISABLE_SSH=true`
: ""
}
EOF
sudo chmod 600 /etc/conf.d/pangolin-site`
},
{
title: t("serviceFile"),
command: `sudo tee /etc/init.d/pangolin-site > /dev/null << 'EOF'
#!/sbin/openrc-run
name="pangolin-site"
description="Pangolin Site"
command="/usr/local/bin/pangolin"
command_args="up site"
command_background="yes"
supervisor="supervise-daemon"
pidfile="/run/pangolin-site.pid"
output_log="/var/log/pangolin-site.log"
error_log="/var/log/pangolin-site.err"
depend() {
need net
after firewall
}
EOF
sudo chmod +x /etc/init.d/pangolin-site`
},
{
title: t("enableAndStart"),
command: `sudo rc-update add pangolin-site default
sudo rc-service pangolin-site start`
},
{
title: t("check"),
command: `sudo rc-service pangolin-site status`
}
]
},
macos: {
Run: [
{
@@ -478,6 +550,8 @@ function getPlatformIcon(platformName: Platform) {
return <FaWindows className="h-4 w-4 mr-2" />;
case "linux":
return <FaLinux className="h-4 w-4 mr-2" />;
case "alpine":
return <SiAlpinelinux className="h-4 w-4 mr-2" />;
case "macos":
return <FaApple className="h-4 w-4 mr-2" />;
case "docker":
@@ -501,6 +575,8 @@ function getPlatformName(platformName: Platform) {
return "Windows";
case "linux":
return "Linux";
case "alpine":
return "Alpine Linux";
case "macos":
return "macOS";
case "docker":
@@ -522,6 +598,8 @@ function getArchitectures(platform: Platform) {
switch (platform) {
case "linux":
return ["Run", "Systemd Service", "Manual Systemd Service"];
case "alpine":
return ["Run", "Manual OpenRC Service"];
case "macos":
return ["Run", "Service"];
case "windows":
@@ -37,6 +37,7 @@ import { cn } from "@app/lib/cn";
import { MAJOR_ASNS } from "@server/db/asns";
import { COUNTRIES } from "@server/db/countries";
import { REGIONS, getRegionNameById } from "@server/db/regions";
import { HTTP_METHODS, parseHttpMethodList } from "@server/lib/validators";
import {
ColumnDef,
flexRender,
@@ -63,7 +64,8 @@ import {
} from "react";
import {
validatePolicyRulePriority,
validatePolicyRuleValue
validatePolicyRuleValue,
type PolicyRuleMatchType
} from "./policy-access-rule-validation";
import {
buildDisplayPrioritiesForResourceOverlay,
@@ -112,6 +114,80 @@ function getColumnClassName(columnId: string) {
return "";
}
// A METHOD rule stores its methods as a comma-separated list in rule.value,
// e.g. "POST,PUT". Only the common methods are offered here; a value set
// through a blueprint or the API may contain other methods (the WebDAV verbs,
// for instance), so those are kept and shown rather than dropped on edit.
function RuleMethodSelect({
value,
disabled,
placeholder,
onChange
}: {
value: string;
disabled: boolean;
placeholder: string;
onChange: (value: string) => void;
}) {
const selected = parseHttpMethodList(value);
const knownMethods: readonly string[] = HTTP_METHODS;
const options = [
...knownMethods,
...selected.filter((method) => !knownMethods.includes(method))
];
function toggle(method: string) {
const next = selected.includes(method)
? selected.filter((m) => m !== method)
: [...selected, method];
// keep a stable order so the stored value does not churn on every edit
onChange(options.filter((m) => next.includes(m)).join(","));
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={disabled}
className="w-full min-w-0 justify-between"
>
<span className="truncate">
{selected.length > 0 ? selected.join(", ") : placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-50 p-0">
<Command>
<CommandList>
<CommandGroup>
{options.map((method) => (
<CommandItem
key={method}
value={method}
onSelect={() => toggle(method)}
>
<Check
className={`mr-2 h-4 w-4 ${
selected.includes(method)
? "opacity-100"
: "opacity-0"
}`}
/>
{method}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function PolicyAccessRulesTable({
rules,
onRulesChange,
@@ -233,7 +309,8 @@ export function PolicyAccessRulesTable({
COUNTRY: t("country"),
COUNTRY_IS_NOT: t("countryIsNot"),
ASN: "ASN",
REGION: t("region")
REGION: t("region"),
METHOD: t("method")
}),
[t]
);
@@ -438,16 +515,7 @@ export function PolicyAccessRulesTable({
<Select
defaultValue={row.original.match}
disabled={readonly || isRuleLocked(row.original)}
onValueChange={(
value:
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
) =>
onValueChange={(value: PolicyRuleMatchType) =>
updateRule(row.original.ruleId, {
match: value,
value:
@@ -458,7 +526,9 @@ export function PolicyAccessRulesTable({
? "AS15169"
: value === "REGION"
? "021"
: row.original.value
: value === "METHOD"
? "GET"
: row.original.value
})
}
>
@@ -473,6 +543,9 @@ export function PolicyAccessRulesTable({
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
<SelectItem value="METHOD">
{RuleMatch.METHOD}
</SelectItem>
{isMaxmindAvailable && (
<>
<SelectItem value="COUNTRY">
@@ -779,6 +852,15 @@ export function PolicyAccessRulesTable({
</Command>
</PopoverContent>
</Popover>
) : row.original.match === "METHOD" ? (
<RuleMethodSelect
value={row.original.value}
disabled={readonly || isRuleLocked(row.original)}
placeholder={t("rulesSelectMethods")}
onChange={(value) =>
updateRule(row.original.ruleId, { value })
}
/>
) : (
<Input
defaultValue={row.original.value}
@@ -2,6 +2,7 @@ import { COUNTRIES } from "@server/db/countries";
import { isValidRegionId } from "@server/db/regions";
import {
isValidCIDR,
isValidHttpMethodList,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
@@ -19,7 +20,8 @@ export const POLICY_RULE_MATCH_TYPES = [
"COUNTRY",
"COUNTRY_IS_NOT",
"ASN",
"REGION"
"REGION",
"METHOD"
] as const;
export type PolicyRuleMatchType = (typeof POLICY_RULE_MATCH_TYPES)[number];
@@ -84,6 +86,10 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
(value) => COUNTRIES.some((country) => country.code === value),
{ message: t("rulesErrorInvalidCountryDescription") }
);
case "METHOD":
return required.refine(isValidHttpMethodList, {
message: t("rulesErrorInvalidMethodDescription")
});
case "ASN":
return required.refine(
(value) => {
+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