Compare commits

...

52 Commits

Author SHA1 Message Date
Owen Schwartz babd90ae71 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-06-22 15:22:46 -07:00
Owen Schwartz f7050ef989 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-06-22 15:22:44 -07:00
Owen Schwartz b2778a2c49 New translations en-us.json (Turkish)
[ci skip]
2026-06-22 15:22:42 -07:00
Owen Schwartz 096940a152 New translations en-us.json (Russian)
[ci skip]
2026-06-22 15:22:40 -07:00
Owen Schwartz 73eb07de71 New translations en-us.json (Portuguese)
[ci skip]
2026-06-22 15:22:38 -07:00
Owen Schwartz 74ef844e27 New translations en-us.json (Polish)
[ci skip]
2026-06-22 15:22:36 -07:00
Owen Schwartz c58968536d New translations en-us.json (Dutch)
[ci skip]
2026-06-22 15:22:34 -07:00
Owen Schwartz 228efacfe0 New translations en-us.json (Korean)
[ci skip]
2026-06-22 15:22:32 -07:00
Owen Schwartz 1262030abb New translations en-us.json (Italian)
[ci skip]
2026-06-22 15:22:30 -07:00
Owen Schwartz b07fe6d18b New translations en-us.json (German)
[ci skip]
2026-06-22 15:22:28 -07:00
Owen Schwartz 63c3ee623b New translations en-us.json (Czech)
[ci skip]
2026-06-22 15:22:26 -07:00
Owen Schwartz 18ec6c8d92 New translations en-us.json (Bulgarian)
[ci skip]
2026-06-22 15:22:24 -07:00
Owen Schwartz d8acccbde4 New translations en-us.json (Spanish)
[ci skip]
2026-06-22 15:22:22 -07:00
Owen Schwartz 37eaf34e4d New translations en-us.json (French)
[ci skip]
2026-06-22 15:22:20 -07:00
Owen Schwartz 72fa1d6a14 Merge pull request #3325 from fosrl/queue
Improve performance of rebuild functions
2026-06-22 13:49:20 -07:00
Owen c3820a4e70 Add missing queuing 2026-06-22 16:47:52 -04:00
Owen 6b56c00782 Pull the listing out of the queue 2026-06-22 15:24:31 -04:00
Owen 60c1b572ba Add drizzle indexes to match db 2026-06-22 15:12:07 -04:00
Owen 604dee9aa5 Batch get olm ids 2026-06-22 15:12:07 -04:00
Owen ee42846c90 Add batch messaging functions to rebuild function 2026-06-22 15:12:07 -04:00
copilot-swe-agent[bot] 22ac711dc6 refactor: tighten ws batch typing and queue cleanup logging 2026-06-22 15:12:07 -04:00
copilot-swe-agent[bot] d09668b20b feat: batch redis ws direct messages and dedupe rebuild queue jobs 2026-06-22 15:12:07 -04:00
Owen 16abe98fd9 Add queue 2026-06-22 15:12:07 -04:00
copilot-swe-agent[bot] d240201361 Initial plan 2026-06-22 15:12:07 -04:00
Josh Voyles b7081aff11 fix: remove no-op autoFinalizeStatement wrapper and redundant busy_timeout (#2120)
better-sqlite3 11.x exposes no Statement.finalize() — the wrapper threw and
swallowed a TypeError on every query (verified: 'Statement.finalize exists:
undefined' in the runner image) while adding +122% per-statement overhead
(3.90 -> 8.66 us/op, 200k-op in-container microbench) and freeing nothing.
Statement lifecycle is GC-managed by the driver; drizzle-orm prepares fresh
per query, so nothing accumulates unbounded.

busy_timeout=5000 duplicates better-sqlite3's default timeout option, which
already arms sqlite3_busy_timeout(db, 5000) at open (lib/database.js).

With ENABLE_SQLITE_WAL_MODE unset the driver is now runtime-identical to
pre-1.18.3 (zero pragmas). The env-gated WAL block stays: journal_mode is
sticky in the DB file, so removing it would strand opted-in databases on
WAL+synchronous=FULL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 15:11:51 -04:00
Josh Voyles a55fb21e53 fix(sqlite): remove cache_size and mmap_size PRAGMAs (#2120)
A 64 MB page cache plus a 256 MB memory-mapped region inflate RSS and
cause page-cache thrashing on small (~1 GB) instances. The PRAGMAs were
added to reduce event-loop blocking on TraefikConfigManager JOINs but
the memory cost outweighs the I/O benefit on the deployment shapes that
hit #2120. Leave SQLite on its conservative defaults.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 15:11:51 -04:00
copilot-swe-agent[bot] e5e7b79712 test: add normalized ASN validation coverage 2026-06-22 15:11:51 -04:00
copilot-swe-agent[bot] de48a0529e refactor: normalize ASN validation value once 2026-06-22 15:11:51 -04:00
copilot-swe-agent[bot] 3f37408dae fix: allow ALL ASN values in policy rule validation 2026-06-22 15:11:51 -04:00
copilot-swe-agent[bot] a2882857ff Initial plan 2026-06-22 15:11:51 -04:00
Owen 476d92b3ac Convert things to regional cache 2026-06-22 15:11:51 -04:00
Owen bf604f25e9 Show the input validation in the error report 2026-06-22 15:11:50 -04:00
Owen 34a0d2a68b Remove NoNewPrivileges
Fixes https://github.com/fosrl/newt/issues/383
2026-06-22 15:11:50 -04:00
Owen Schwartz 62c7e0a13e Merge pull request #3251 from kshitijshresth/fix-path-rule-regex-escaping
Fix unescaped regex metacharacters in PATH rule matching causing request failures
2026-06-22 07:40:00 -07:00
Owen Schwartz c859393418 Merge pull request #3225 from fosrl/chore/dependabot-single-pr-groups
chore(dependabot): group dependency updates into single PRs per ecosystem
2026-06-22 07:31:25 -07:00
Owen Schwartz 16c0f4eef4 Merge pull request #3277 from fosrl/dev
Fix middleware and suppoter footer
2026-06-14 14:44:33 -07:00
Owen a08c6d70fe Comment out 2026-06-14 14:44:08 -07:00
miloschwartz a6568692b7 force set supporter status to true in server info endpoint 2026-06-14 14:40:37 -07:00
Owen a1196d3da6 Remove supporter warning 2026-06-14 14:34:39 -07:00
Owen 70bc4c0b30 Remove the path rewrite from the next route 2026-06-14 14:30:16 -07:00
Owen Schwartz a0fef89031 Merge pull request #3276 from fosrl/dev
Rewrite headers
2026-06-14 14:13:54 -07:00
Owen ea1badf4e0 Add middleware for rewriting host headers 2026-06-14 12:04:02 -07:00
Owen Schwartz f15654ed11 Merge pull request #3275 from fosrl/dev
Fill in missing ui urls from the passed params
2026-06-14 11:36:01 -07:00
Owen 4435a669a6 Fill in missing ui urls from the passed params 2026-06-14 11:35:27 -07:00
Owen Schwartz 0b41fe3d49 Merge pull request #3268 from fosrl/dev
Send browser gateway rsources to remote nodes
2026-06-14 11:11:06 -07:00
Owen 90eceb457a Clean up url passing 2026-06-14 11:10:05 -07:00
Owen f39cbc9bf4 Add same signature to oss 2026-06-14 11:03:14 -07:00
Owen 50da863bb7 Add maintence page support for remote nodes 2026-06-13 21:45:52 -07:00
Owen c6ddd5c402 Open up holepunch requirements 2026-06-13 14:14:34 -07:00
Owen 0fb5ace9c7 Support the browser gateways on the remote nodes 2026-06-13 14:08:03 -07:00
kshitijshresth b136bd2246 Escape regex metacharacters in PATH rule wildcard matching
isValidUrlGlobPattern accepts characters like ( ) [ ] { } | . + ^ $ in PATH rule values, but isPathAllowed converted wildcard segments to regex without escaping them. A rule value such as /(api* produced an invalid regex and threw on every request to the resource, surfacing as a 500 from verifySession. Literal characters like . and + also changed matching semantics. isPathAllowed is extracted to server/lib/pathMatch.ts as a pure module, metacharacters are escaped before wildcard substitution, compiled segment regexes are cached, and the test suite now imports the real implementation instead of a stale copy, with added coverage for special characters.
2026-06-12 11:21:21 +03:00
Marc Schäfer 92d611df9a chore(dependabot): group dependency updates into single PRs per ecosystem 2026-06-07 11:10:53 +02:00
59 changed files with 2399 additions and 1082 deletions
+19 -29
View File
@@ -1,52 +1,42 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 1
groups:
dev-patch-updates:
dependency-type: "development"
update-types:
- "patch"
dev-minor-updates:
dependency-type: "development"
update-types:
- "minor"
prod-patch-updates:
dependency-type: "production"
update-types:
- "patch"
prod-minor-updates:
dependency-type: "production"
update-types:
- "minor"
npm-dependencies:
patterns:
- "*"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 1
groups:
patch-updates:
update-types:
- "patch"
minor-updates:
update-types:
- "minor"
docker-dependencies:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 1
groups:
github-actions-dependencies:
patterns:
- "*"
- package-ecosystem: "gomod"
directory: "/install"
schedule:
interval: "daily"
open-pull-requests-limit: 1
groups:
patch-updates:
update-types:
- "patch"
minor-updates:
update-types:
- "minor"
go-install-dependencies:
patterns:
- "*"
+1
View File
@@ -2967,6 +2967,7 @@
"orgOrDomainIdMissing": "Липсва идентификатор на организация или домейн",
"loadingDNSRecords": "Зареждане на DNS записи...",
"olmUpdateAvailableInfo": "Налична е актуализирана версия на Olm. Моля, актуализирайте до най-новата версия за най-добро преживяване.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"client": "Клиент",
"proxyProtocol": "Настройки на прокси протокол",
"proxyProtocolDescription": "Конфигурирайте Proxy Protocol, за да запазите IP адресите на клиентите за TCP услуги.",
+1
View File
@@ -2967,6 +2967,7 @@
"orgOrDomainIdMissing": "Chybí ID organizace nebo domény",
"loadingDNSRecords": "Načítání DNS záznamů...",
"olmUpdateAvailableInfo": "Je k dispozici aktualizovaná verze Olm. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"client": "Zákazník",
"proxyProtocol": "Nastavení proxy protokolu",
"proxyProtocolDescription": "Konfigurace Proxy protokolu pro zachování klientských IP adres pro služby TCP.",
+1
View File
@@ -2967,6 +2967,7 @@
"orgOrDomainIdMissing": "Organisation oder Domänen-ID fehlt",
"loadingDNSRecords": "Lade DNS-Einträge...",
"olmUpdateAvailableInfo": "Eine aktualisierte Version von Olm ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für die beste Erfahrung.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"client": "Client",
"proxyProtocol": "Proxy-Protokoll-Einstellungen",
"proxyProtocolDescription": "Konfigurieren Sie das Proxy-Protokoll, um die IP-Adressen des Clients für TCP-Dienste zu erhalten.",
+1
View File
@@ -2967,6 +2967,7 @@
"orgOrDomainIdMissing": "Falta el ID de organización o dominio",
"loadingDNSRecords": "Cargando registros DNS...",
"olmUpdateAvailableInfo": "Una versión actualizada de Olm está disponible. Por favor, actualice a la última versión para obtener la mejor experiencia.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"client": "Cliente",
"proxyProtocol": "Configuración del Protocolo Proxy",
"proxyProtocolDescription": "Configurar el protocolo de proxy para preservar las direcciones IP del cliente para los servicios TCP.",
+1
View File
@@ -2967,6 +2967,7 @@
"orgOrDomainIdMissing": "L'organisation ou l'identifiant de domaine est manquant",
"loadingDNSRecords": "Chargement des enregistrements DNS...",
"olmUpdateAvailableInfo": "Une version mise à jour de Olm est disponible. Veuillez mettre à jour vers la dernière version pour la meilleure expérience.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"client": "Client",
"proxyProtocol": "Paramètres du protocole proxy",
"proxyProtocolDescription": "Configurer le protocole Proxy pour préserver les adresses IP du client pour les services TCP.",
+1
View File
@@ -2967,6 +2967,7 @@
"orgOrDomainIdMissing": "Manca l'ID dell'organizzazione o del dominio",
"loadingDNSRecords": "Caricamento record DNS...",
"olmUpdateAvailableInfo": "È disponibile una versione aggiornata di Olm. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"client": "Client",
"proxyProtocol": "Impostazioni Protocollo Proxy",
"proxyProtocolDescription": "Configurare il protocollo proxy per preservare gli indirizzi IP client per i servizi TCP.",
+1
View File
@@ -2967,6 +2967,7 @@
"orgOrDomainIdMissing": "조직 ID 또는 도메인 ID가 누락되었습니다",
"loadingDNSRecords": "DNS 레코드를 로드하는 중...",
"olmUpdateAvailableInfo": "올름의 새 버전이 이용 가능합니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"client": "클라이언트",
"proxyProtocol": "프록시 프로토콜 설정",
"proxyProtocolDescription": "TCP 서비스에 대한 클라이언트 IP 주소를 유지하도록 프록시 프로토콜을 구성하세요.",
+1
View File
@@ -2967,6 +2967,7 @@
"orgOrDomainIdMissing": "ID for organisasjon eller domene mangler",
"loadingDNSRecords": "Laster DNS-poster...",
"olmUpdateAvailableInfo": "En oppdatert versjon av Olm er tilgjengelig. Oppdater til den nyeste versjonen for å få den beste opplevelsen.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"client": "Klient",
"proxyProtocol": "Protokoll innstillinger for Protokoll",
"proxyProtocolDescription": "Konfigurer Proxy-protokoll for å bevare klientens IP-adresser til TCP-tjenester.",
+1
View File
@@ -2967,6 +2967,7 @@
"orgOrDomainIdMissing": "Organisatie of domein ID ontbreekt",
"loadingDNSRecords": "DNS-records laden...",
"olmUpdateAvailableInfo": "Er is een bijgewerkte versie van Olm beschikbaar. Update alstublieft naar de nieuwste versie voor de beste ervaring.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"client": "Klant",
"proxyProtocol": "Proxy Protocol Instellingen",
"proxyProtocolDescription": "Proxyprotocol configureren om de IP-adressen van de client voor TCP-diensten te bewaren.",
+1
View File
@@ -2967,6 +2967,7 @@
"orgOrDomainIdMissing": "Brakuje identyfikatora organizacji lub domeny",
"loadingDNSRecords": "Ładowanie rekordów DNS...",
"olmUpdateAvailableInfo": "Dostępna jest zaktualizowana wersja Olm. Zaktualizuj do najnowszej wersji, aby uzyskać najlepsze doświadczenia.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"client": "Klient",
"proxyProtocol": "Ustawienia protokołu proxy",
"proxyProtocolDescription": "Skonfiguruj protokół Proxy aby zachować adresy IP klienta dla usług TCP.",
+1
View File
@@ -2967,6 +2967,7 @@
"orgOrDomainIdMissing": "ID da organização ou domínio está faltando",
"loadingDNSRecords": "Carregando registros DNS...",
"olmUpdateAvailableInfo": "Uma versão atualizada do Olm está disponível. Atualize para a versão mais recente para ter a melhor experiência.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"client": "Cliente",
"proxyProtocol": "Configurações de Protocolo Proxy",
"proxyProtocolDescription": "Configurar o protocolo proxy para preservar endereços IP do cliente para serviços TCP.",
+1
View File
@@ -2967,6 +2967,7 @@
"orgOrDomainIdMissing": "Отсутствует организация или ID домена",
"loadingDNSRecords": "Загрузка записей DNS...",
"olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"client": "Клиент",
"proxyProtocol": "Настройки протокола прокси",
"proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.",
+1
View File
@@ -2967,6 +2967,7 @@
"orgOrDomainIdMissing": "Organizasyon veya Alan Adı Kimliği eksik",
"loadingDNSRecords": "DNS kayıtları yükleniyor...",
"olmUpdateAvailableInfo": "Olm'nin güncellenmiş bir sürümü mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"client": "İstemci",
"proxyProtocol": "Proxy Protokol Ayarları",
"proxyProtocolDescription": "TCP hizmetleri için istemci IP adreslerini korumak amacıyla Proxy Protokolünü yapılandırın.",
+60 -59
View File
@@ -17,7 +17,7 @@
"componentsErrorNoMemberCreate": "您目前不是任何组织的成员。创建组织以开始操作。",
"componentsErrorNoMember": "您目前不是任何组织的成员。",
"welcome": "欢迎使用 Pangolin",
"welcomeTo": "欢迎来到",
"welcomeTo": "欢迎使用",
"componentsCreateOrg": "创建组织",
"componentsMember": "您属于{count, plural, =0 {没有组织} one {一个组织} other {# 个组织}}。",
"componentsInvalidKey": "检测到无效或过期的许可证密钥。按照许可证条款操作以继续使用所有功能。",
@@ -35,7 +35,7 @@
"trialDaysRemaining": "{count, plural, other {# 天剩余}}",
"trialDaysLeftShort": "试用期剩余 {days} 天",
"trialGoToBilling": "转到账单页面",
"subscriptionViolationViewBilling": "查看计费",
"subscriptionViolationViewBilling": "查看账单",
"componentsLicenseViolation": "许可证超限:该服务器使用了 {usedSites} 个站点,已超过授权的 {maxSites} 个。请遵守许可证条款以继续使用全部功能。",
"componentsSupporterMessage": "感谢您的支持!您现在是 Pangolin 的 {tier} 用户。",
"inviteErrorNotValid": "很抱歉,但看起来你试图访问的邀请尚未被接受或不再有效。",
@@ -58,21 +58,21 @@
"name": "名称",
"online": "在线",
"offline": "离线的",
"site": "点",
"site": "点",
"dataIn": "数据输入",
"dataOut": "数据输出",
"connectionType": "连接类型",
"tunnelType": "隧道类型",
"local": "本地的",
"edit": "编辑",
"siteConfirmDelete": "确认删除点",
"siteDelete": "删除点",
"siteMessageRemove": "一旦移除,点将无法访问。与点相关的所有目标也将被移除。",
"siteQuestionRemove": "您确定要从组织中删除该点吗?",
"siteConfirmDelete": "确认删除点",
"siteDelete": "删除点",
"siteMessageRemove": "一旦移除,点将无法访问。与点相关的所有目标也将被移除。",
"siteQuestionRemove": "您确定要从组织中删除该点吗?",
"siteManageSites": "管理站点",
"siteDescription": "创建和管理站点,启用与私人网络的连接",
"sitesBannerTitle": "连接任何网络",
"sitesBannerDescription": "站点是连接到远程网络的接,允许Pangolin用户提供资源访问,无论是公共还是私人。可以在任何可以运行二进制文件或容器的地方安装站点网络连接器(Newt)以建立连接。",
"sitesBannerDescription": "站点是到远程网络的接,使 Pangolin 能够向任何位置的用户提公共或私有的资源访问。你可以在任何能够运行二进制文件或容器的地方安装站点网络连接器(Newt)以建立连接。",
"sitesBannerButtonText": "安装站点",
"approvalsBannerTitle": "批准或拒绝设备访问",
"approvalsBannerDescription": "审核、批准或拒绝用户的设备访问请求。 当需要设备批准时,用户必须先获得管理员批准,然后他们的设备才能连接到您的组织资源。",
@@ -134,7 +134,7 @@
"siteResourcesHowToAccess": "如何访问",
"siteResourcesTargetsOnSite": "此站点上的目标",
"siteSetting": "{siteName} 设置",
"siteNewtTunnel": "新点 (推荐)",
"siteNewtTunnel": "新点 (推荐)",
"siteNewtTunnelDescription": "最简单的方式来创建任何网络的入口。没有额外的设置。",
"siteWg": "基本 WireGuard",
"siteWgDescription": "使用任何 WireGuard 客户端来建立隧道。需要手动配置 NAT。",
@@ -143,23 +143,23 @@
"siteLocalDescriptionSaas": "仅本地资源。没有隧道。仅在远程节点上可用。",
"siteSeeAll": "查看所有站点",
"siteTunnelDescription": "确定如何连接到站点",
"siteNewtCredentials": "全权证书",
"siteNewtCredentialsDescription": "点如何通过服务器进行身份验证",
"siteNewtCredentials": "凭证",
"siteNewtCredentialsDescription": "点如何服务器进行身份验证",
"remoteNodeCredentialsDescription": "这是远程节点如何与服务器进行身份验证",
"siteCredentialsSave": "保存证书",
"siteCredentialsSaveDescription": "您只能看到一次。请确保将其复制并保存到一个安全的地方。",
"siteInfo": "站点信息",
"status": "状态",
"shareTitle": "管理共享链接",
"shareTitle": "管理共享链接",
"shareDescription": "创建可共享的链接,允许临时或永久访问代理资源",
"shareSearch": "搜索共享链接……",
"shareCreate": "创建共享链接",
"shareSearch": "搜索共享链接……",
"shareCreate": "创建共享链接",
"shareErrorDelete": "删除链接失败",
"shareErrorDeleteMessage": "删除链接时出错",
"shareDeleted": "链接已删除",
"shareDeletedDescription": "链接已删除",
"shareDelete": "删除共享链接",
"shareDeleteConfirm": "确认删除共享链接",
"shareDelete": "删除共享链接",
"shareDeleteConfirm": "确认删除共享链接",
"shareQuestionRemove": "您确定要删除这个共享链接吗?",
"shareMessageRemove": "删除后,该链接将不再可用,使用它的任何人将失去对资源的访问权限。",
"shareTokenDescription": "访问令牌可以通过两种方式传递:作为查询参数或请求标题。 每次验证访问请求都必须从客户端传递。",
@@ -204,11 +204,11 @@
"proxyResourceTitle": "管理公共资源",
"proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源",
"publicResourcesBannerTitle": "基于 Web 的公共访问",
"publicResourcesBannerDescription": "公共资源是 HTTPS 代理,可以通过网络浏览器在互联网上的任何人访问。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知的访问策略。",
"publicResourcesBannerDescription": "公共资源是 HTTPS 代理,可供互联网上的任何人通过 Web 浏览器访问。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知的访问策略。",
"clientResourceTitle": "管理私有资源",
"clientResourceDescription": "创建和管理只能通过连接客户端访问的资源",
"privateResourcesBannerTitle": "零信任的私人访问",
"privateResourcesBannerDescription": "私资源使用零信任安全,确保只允许明确授的用户和机器访问资源。可以连接用户设备或机器客户端通过安全的虚拟专用网络访问这些资源。",
"privateResourcesBannerTitle": "零信任私有访问",
"privateResourcesBannerDescription": "私资源用零信任安全机制,确保只有获得明确授的用户和机器才能访问。用户设备或机器客户端连接后,即可通过安全的虚拟专用网络访问这些资源。",
"resourcesSearch": "搜索资源...",
"resourceAdd": "添加资源",
"resourceErrorDelte": "删除资源时出错",
@@ -327,7 +327,7 @@
"passToAuth": "传递至认证",
"orgSettingsDescription": "配置组织设置",
"orgGeneralSettings": "组织设置",
"orgGeneralSettingsDescription": "管理机构的详细信息和配置",
"orgGeneralSettingsDescription": "管理组织的详细信息和配置",
"saveGeneralSettings": "保存常规设置",
"saveSettings": "保存设置",
"orgDangerZone": "危险区域",
@@ -381,7 +381,7 @@
"accessApprovalsDescription": "查看和管理待审批的组织访问权限",
"description": "描述",
"inviteTitle": "打开邀请",
"inviteDescription": "管理其他用户加入机构的邀请",
"inviteDescription": "管理其他用户加入组织的邀请",
"inviteSearch": "搜索邀请...",
"minutes": "分钟",
"hours": "小时",
@@ -425,24 +425,24 @@
"apiKeysDelete": "删除 API 密钥",
"apiKeysManage": "管理 API 密钥",
"apiKeysDescription": "API 密钥用于认证集成 API",
"provisioningKeysTitle": "置备密钥",
"provisioningKeysManage": "管理置备键",
"provisioningKeysTitle": "预配密钥",
"provisioningKeysManage": "管理预配密钥",
"provisioningKeysDescription": "置备密钥用于验证您组织的自动站点配置。",
"provisioningManage": "置备中",
"provisioningDescription": "管理预配键和审查等待批准的站点。",
"pendingSites": "待站点",
"provisioningManage": "预配",
"provisioningDescription": "管理预配密钥,并审核待批准的站点。",
"pendingSites": "待审批站点",
"siteApproveSuccess": "站点批准成功",
"siteApproveError": "批准站点出错",
"provisioningKeys": "置备键",
"searchProvisioningKeys": "搜索配备密钥...",
"provisioningKeysAdd": "生成置备键",
"provisioningKeysAdd": "生成预配密钥",
"provisioningKeysErrorDelete": "删除预配键时出错",
"provisioningKeysErrorDeleteMessage": "删除预配键时出错",
"provisioningKeysQuestionRemove": "您确定要从组织中删除此预配键吗?",
"provisioningKeysMessageRemove": "一旦移除,密钥不能再用于站点预配。",
"provisioningKeysDeleteConfirm": "确认删除置备键",
"provisioningKeysDelete": "删除置备键",
"provisioningKeysCreate": "生成置备键",
"provisioningKeysCreate": "生成预配密钥",
"provisioningKeysCreateDescription": "为组织生成一个新的预置密钥",
"provisioningKeysSeeAll": "查看所有预配键",
"provisioningKeysSave": "保存预配键",
@@ -462,16 +462,16 @@
"provisioningKeysNeverUsed": "永不过期",
"provisioningKeysEdit": "编辑置备键",
"provisioningKeysEditDescription": "更新此密钥的最大批量大小和过期时间。",
"provisioningKeysApproveNewSites": "批准新点",
"provisioningKeysApproveNewSitesDescription": "自动批准使用此密钥注册的点。",
"provisioningKeysApproveNewSites": "批准新点",
"provisioningKeysApproveNewSitesDescription": "自动批准使用此密钥注册的点。",
"provisioningKeysUpdateError": "更新预配键时出错",
"provisioningKeysUpdated": "置备密钥已更新",
"provisioningKeysUpdatedDescription": "您的更改已保存。",
"provisioningKeysBannerTitle": "站点置备密钥",
"provisioningKeysBannerDescription": "生成一个供应密钥,并将其与 Newt 连接器一起使用,在首次启动时自动创建站点 - 无需为每个站点设置单独的凭据。",
"provisioningKeysBannerTitle": "站点预配密钥",
"provisioningKeysBannerDescription": "生成预配密钥,并将其与 Newt 连接器配合使用,即可在首次启动时自动创建站点无需为每个站点单独配置凭据。",
"provisioningKeysBannerButtonText": "了解更多",
"pendingSitesBannerTitle": "待站点",
"pendingSitesBannerDescription": "使用供应密钥连接的站点将在此显示以供审核。",
"pendingSitesBannerTitle": "待审批站点",
"pendingSitesBannerDescription": "使用预配密钥连接的网站会在这里以供审核。",
"pendingSitesBannerButtonText": "了解更多",
"apiKeysSettings": "{apiKeyName} 设置",
"userTitle": "管理所有用户",
@@ -883,11 +883,11 @@
"resourcesErrorUpdateDescription": "更新资源时出错",
"access": "访问权限",
"accessControl": "访问控制",
"shareLink": "{resource} 共享链接",
"shareLink": "{resource} 共享链接",
"resourceSelect": "选择资源",
"shareLinks": "共享链接",
"shareLinks": "共享链接",
"share": "分享链接",
"shareDescription2": "创建资源的共享链接。链接提供了对您资源的临时或无限制访问。 当您创建链接时,您可以配置链接的到期时间。",
"shareDescription2": "创建资源的共享链接。链接提供了对您资源的临时或无限制访问。 当您创建链接时,您可以配置链接的到期时间。",
"shareEasyCreate": "轻松创建和分享",
"shareConfigurableExpirationDuration": "可配置的过期时间",
"shareSecureAndRevocable": "安全和可撤销的",
@@ -1059,7 +1059,7 @@
"network": "网络",
"manage": "管理",
"sitesNotFound": "未找到站点。",
"pangolinServerAdmin": "服务器管理 - Pangolin",
"pangolinServerAdmin": "服务器管理 - Pangolin",
"licenseTierProfessional": "专业许可证",
"licenseTierEnterprise": "企业许可证",
"licenseTierPersonal": "个人许可证",
@@ -1366,7 +1366,7 @@
"supportKeyBuy": "购买支持者密钥",
"logoutError": "注销错误",
"signingAs": "登录为",
"serverAdmin": "服务器管理",
"serverAdmin": "服务器管理",
"managedSelfhosted": "托管自托管",
"otpEnable": "启用双因子认证",
"otpDisable": "禁用双因子认证",
@@ -1536,8 +1536,8 @@
"sidebarSites": "站点",
"sidebarApprovals": "审批请求",
"sidebarResources": "资源",
"sidebarProxyResources": "公开",
"sidebarClientResources": "非公开的",
"sidebarProxyResources": "公开资源",
"sidebarClientResources": "私有资源",
"sidebarPolicies": "共享策略",
"sidebarResourcePolicies": "公共资源",
"sidebarAccessControl": "访问控制",
@@ -1547,17 +1547,17 @@
"sidebarAdmin": "管理员",
"sidebarInvitations": "邀请",
"sidebarRoles": "角色",
"sidebarShareableLinks": "共享链接",
"sidebarShareableLinks": "共享链接",
"sidebarApiKeys": "API密钥",
"sidebarProvisioning": "置备中",
"sidebarProvisioning": "预配",
"sidebarSettings": "设置",
"sidebarAllUsers": "所有用户",
"sidebarIdentityProviders": "身份提供商",
"sidebarLicense": "证书",
"sidebarClients": "客户端",
"sidebarUserDevices": "用户设备",
"sidebarMachineClients": "机",
"sidebarDomains": "域",
"sidebarMachineClients": "机器身份",
"sidebarDomains": "域",
"sidebarGeneral": "管理",
"sidebarLogAndAnalytics": "日志与分析",
"sidebarBluePrints": "蓝图",
@@ -1689,8 +1689,8 @@
"alertingTabHealthChecks": "健康检查",
"alertingRulesBannerTitle": "获取通知",
"alertingRulesBannerDescription": "每条规则都连接要监视的对象(站点、健康检查或资源),触发时间(例如离线或不健康),以及如何通过电子邮件、Webhooks 或集成将通知发送给团队。使用此列表创建、启用和管理这些规则。",
"alertingHealthChecksBannerTitle": "监视健康和资源",
"alertingHealthChecksBannerDescription": "健康检查是您一次定义的 HTTP 或 TCP 监控。然后可以将它们用作告警规则中的来源,以便目标变得正常或不正常时得到通知。资源的健康检查也会出现在此处。",
"alertingHealthChecksBannerTitle": "资源与健康监控",
"alertingHealthChecksBannerDescription": "通过 HTTP 或 TCP 检查目标状态,并在服务异常或恢复时发送通知。资源中配置的健康检查也会显示在这里。",
"standaloneHcTableTitle": "健康检查",
"standaloneHcSearchPlaceholder": "搜索健康检查…",
"standaloneHcAddButton": "创建健康检查",
@@ -1791,17 +1791,17 @@
"theme": "主题",
"subnetRequired": "子网是必填项",
"initialSetupTitle": "初始服务器设置",
"initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。",
"initialSetupDescription": "创建初始管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。",
"createAdminAccount": "创建管理员帐户",
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
"setupErrorCreateAdmin": "创建管理员账户时发生错误。",
"certificateStatus": "证书",
"certificateStatusAutoRefreshHint": "状态自动刷新。",
"loading": "加载中",
"loadingEllipsis": "加载中……",
"loadingAnalytics": "加载分析",
"restart": "重启",
"domains": "域",
"domainsDescription": "创建和管理组织中可用的域",
"domains": "域",
"domainsDescription": "创建和管理组织中可用的域",
"domainsSearch": "搜索域...",
"domainAdd": "添加域",
"domainAddDescription": "注册一个新域名到组织",
@@ -2165,12 +2165,12 @@
"sshSudoMode": "Sudo 访问",
"sshSudoModeNone": "无",
"sshSudoModeNoneDescription": "用户不能用sudo运行命令。",
"sshSudoModeFull": "全苏多",
"sshSudoModeFull": "完整 Sudo 权限",
"sshSudoModeFullDescription": "用户可以用 sudo 运行任何命令。",
"sshSudoModeCommands": "命令",
"sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。",
"sshSudo": "允许Sudo",
"sshSudoCommands": "Sudo 命令",
"sshSudoCommands": "可用 Sudo 命令",
"sshSudoCommandsDescription": "用户可以使用 sudo 运行的命令列表,以逗号、空格或新行分隔。必须使用绝对路径。",
"sshCreateHomeDir": "创建主目录",
"sshUnixGroups": "Unix 组",
@@ -2183,7 +2183,7 @@
"roleTextImportAppend": "附加到现有",
"roleTextImportMode": "导入模式",
"roleTextImportPreview": "预览",
"roleTextImportItemCount": "{count, plural, =0 {No items to import} one {1 item to import} other {# items to import}}",
"roleTextImportItemCount": "{count, plural, =0 {没有可导入的项目} one {1 个可导入项目} other {# 个可导入项目}}",
"roleTextImportTotalCount": "{existing} 个现有 + {imported} 个导入 = {total} 个总计",
"roleTextImportConfirm": "导入",
"roleTextImportInvalidFile": "不支持的文件类型",
@@ -2235,8 +2235,8 @@
"resourceEditDomain": "编辑域名",
"siteName": "站点名称",
"proxyPort": "端口",
"resourcesTableProxyResources": "公开的",
"resourcesTableClientResources": "非公开的",
"resourcesTableProxyResources": "",
"resourcesTableClientResources": "私有资源",
"resourcesTableNoProxyResourcesFound": "未找到代理资源。",
"resourcesTableNoInternalResourcesFound": "未找到内部资源。",
"resourcesTableDestination": "目标",
@@ -2925,7 +2925,7 @@
"logRetentionRequestDescription": "保留请求日志的时间",
"logRetentionAccessLabel": "访问日志保留",
"logRetentionAccessDescription": "保留访问日志的时间",
"logRetentionActionLabel": "动作日志保留",
"logRetentionActionLabel": "审计日志保留",
"logRetentionActionDescription": "保留操作日志的时间",
"logRetentionConnectionLabel": "连接日志保留",
"logRetentionConnectionDescription": "保留连接日志的时间",
@@ -2938,11 +2938,11 @@
"logRetentionForever": "永远的",
"logRetentionEndOfFollowingYear": "下一年结束",
"actionLogsDescription": "查看此机构执行的操作历史",
"accessLogsDescription": "查看此机构资源的访问认证请求",
"accessLogsDescription": "查看此组织资源的访问认证请求",
"connectionLogs": "连接日志",
"connectionLogsDescription": "查看此机构隧道的连接日志",
"sidebarLogsConnection": "连接日志",
"sidebarLogsStreaming": "流",
"sidebarLogsStreaming": "事件流",
"sourceAddress": "源地址",
"destinationAddress": "目的地址",
"duration": "期限",
@@ -2967,6 +2967,7 @@
"orgOrDomainIdMissing": "缺少机构或域 ID",
"loadingDNSRecords": "正在载入DNS记录...",
"olmUpdateAvailableInfo": "有最新版本的 Olm 可用。请更新到最新版本以获取最佳体验。",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"client": "客户端:",
"proxyProtocol": "代理协议设置",
"proxyProtocolDescription": "配置代理协议以保留TCP服务的客户端 IP 地址。",
+23 -12
View File
@@ -11,7 +11,7 @@ import {
primaryKey,
uniqueIndex
} from "drizzle-orm/pg-core";
import { InferSelectModel } from "drizzle-orm";
import { InferSelectModel, sql } from "drizzle-orm";
import {
domains,
orgs,
@@ -207,17 +207,28 @@ export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
});
export const loginPage = pgTable("loginPage", {
loginPageId: serial("loginPageId").primaryKey(),
subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain"),
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
})
});
export const loginPage = pgTable(
"loginPage",
{
loginPageId: serial("loginPageId").primaryKey(),
subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain"),
exitNodeId: integer("exitNodeId").references(
() => exitNodes.exitNodeId,
{
onDelete: "set null"
}
),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
})
},
(t) => [
index("idx_loginpage_fulldomain")
.on(t.fullDomain)
.where(sql`${t.fullDomain} IS NOT NULL`)
]
);
export const loginPageOrg = pgTable("loginPageOrg", {
loginPageId: integer("loginPageId")
+391 -281
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "crypto";
import { InferSelectModel } from "drizzle-orm";
import { InferSelectModel, sql } from "drizzle-orm";
import {
bigint,
boolean,
@@ -82,107 +82,130 @@ export const orgDomains = pgTable("orgDomains", {
.references(() => domains.domainId, { onDelete: "cascade" })
});
export const sites = pgTable("sites", {
siteId: serial("siteId").primaryKey(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
niceId: varchar("niceId").notNull(),
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet"),
megabytesIn: real("bytesIn").default(0),
megabytesOut: real("bytesOut").default(0),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
type: varchar("type").notNull(), // "newt" or "wireguard"
online: boolean("online").notNull().default(false),
lastPing: integer("lastPing"),
address: varchar("address"),
endpoint: varchar("endpoint"),
publicKey: varchar("publicKey"),
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"),
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
autoUpdateEnabled: boolean("autoUpdateEnabled").notNull().default(false),
autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg")
.notNull()
.default(false),
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
});
export const sites = pgTable(
"sites",
{
siteId: serial("siteId").primaryKey(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
niceId: varchar("niceId").notNull(),
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet"),
megabytesIn: real("bytesIn").default(0),
megabytesOut: real("bytesOut").default(0),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
type: varchar("type").notNull(), // "newt" or "wireguard"
online: boolean("online").notNull().default(false),
lastPing: integer("lastPing"),
address: varchar("address"),
endpoint: varchar("endpoint"),
publicKey: varchar("publicKey"),
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"),
dockerSocketEnabled: boolean("dockerSocketEnabled")
.notNull()
.default(true),
autoUpdateEnabled: boolean("autoUpdateEnabled")
.notNull()
.default(false),
autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg")
.notNull()
.default(false),
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
},
(t) => [
index("idx_sites_exitnodeid").on(t.exitNodeId),
index("idx_sites_exitnode_type_siteid").on(
t.exitNodeId,
t.type,
t.siteId
)
]
);
export const resources = pgTable("resources", {
resourceId: serial("resourceId").primaryKey(),
resourcePolicyId: integer("resourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{ onDelete: "set null" }
),
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{
onDelete: "restrict"
}
),
resourceGuid: varchar("resourceGuid", { length: 36 })
.unique()
.notNull()
.$defaultFn(() => randomUUID()),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
niceId: text("niceId").notNull(),
name: varchar("name").notNull(),
subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain"),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
}),
ssl: boolean("ssl").notNull().default(false),
blockAccess: boolean("blockAccess").notNull().default(false),
proxyPort: integer("proxyPort"),
sso: boolean("sso"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled"),
applyRules: boolean("applyRules"),
enabled: boolean("enabled").notNull().default(true),
stickySession: boolean("stickySession").notNull().default(false),
tlsServerName: varchar("tlsServerName"),
setHostHeader: varchar("setHostHeader"),
enableProxy: boolean("enableProxy").default(true),
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "set null"
}),
headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
.notNull()
.default(false),
maintenanceModeType: text("maintenanceModeType", {
enum: ["forced", "automatic"]
}).default("forced"), // "forced" = always show, "automatic" = only when down
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
});
export const resources = pgTable(
"resources",
{
resourceId: serial("resourceId").primaryKey(),
resourcePolicyId: integer("resourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{ onDelete: "set null" }
),
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{
onDelete: "restrict"
}
),
resourceGuid: varchar("resourceGuid", { length: 36 })
.unique()
.notNull()
.$defaultFn(() => randomUUID()),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
niceId: text("niceId").notNull(),
name: varchar("name").notNull(),
subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain"),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
}),
ssl: boolean("ssl").notNull().default(false),
blockAccess: boolean("blockAccess").notNull().default(false),
proxyPort: integer("proxyPort"),
sso: boolean("sso"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled"),
applyRules: boolean("applyRules"),
enabled: boolean("enabled").notNull().default(true),
stickySession: boolean("stickySession").notNull().default(false),
tlsServerName: varchar("tlsServerName"),
setHostHeader: varchar("setHostHeader"),
enableProxy: boolean("enableProxy").default(true),
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "set null"
}),
headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
.notNull()
.default(false),
maintenanceModeType: text("maintenanceModeType", {
enum: ["forced", "automatic"]
}).default("forced"), // "forced" = always show, "automatic" = only when down
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
},
(t) => [
index("idx_resources_fulldomain")
.on(t.fullDomain)
.where(sql`${t.fullDomain} IS NOT NULL`)
]
);
export const labels = pgTable("labels", {
labelId: serial("labelId").primaryKey(),
@@ -267,71 +290,84 @@ export const clientLabels = pgTable(
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
);
export const targets = pgTable("targets", {
targetId: serial("targetId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
ip: varchar("ip").notNull(),
method: varchar("method"),
port: integer("port").notNull(),
internalPort: integer("internalPort"),
enabled: boolean("enabled").notNull().default(true),
path: text("path"),
pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100),
mode: varchar("mode")
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
.notNull()
.default("http"),
authToken: varchar("authToken")
});
export const targets = pgTable(
"targets",
{
targetId: serial("targetId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
ip: varchar("ip").notNull(),
method: varchar("method"),
port: integer("port").notNull(),
internalPort: integer("internalPort"),
enabled: boolean("enabled").notNull().default(true),
path: text("path"),
pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100),
mode: varchar("mode")
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
.notNull()
.default("http"),
authToken: varchar("authToken")
},
(t) => [
index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId),
index("idx_targets_site_enabled_priority_target_resource")
.on(t.siteId, t.priority.desc(), t.targetId, t.resourceId)
.where(sql`${t.enabled} = true`)
]
);
export const targetHealthCheck = pgTable("targetHealthCheck", {
targetHealthCheckId: serial("targetHealthCheckId").primaryKey(),
targetId: integer("targetId").references(() => targets.targetId, {
onDelete: "cascade"
}),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
export const targetHealthCheck = pgTable(
"targetHealthCheck",
{
targetHealthCheckId: serial("targetHealthCheckId").primaryKey(),
targetId: integer("targetId").references(() => targets.targetId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
name: varchar("name"),
hcEnabled: boolean("hcEnabled").notNull().default(false),
hcPath: varchar("hcPath"),
hcScheme: varchar("hcScheme"),
hcMode: varchar("hcMode").default("http"),
hcHostname: varchar("hcHostname"),
hcPort: integer("hcPort"),
hcInterval: integer("hcInterval").default(30), // in seconds
hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds
hcTimeout: integer("hcTimeout").default(5), // in seconds
hcHeaders: varchar("hcHeaders"),
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
hcMethod: varchar("hcMethod").default("GET"),
hcStatus: integer("hcStatus"), // http code
hcHealth: text("hcHealth")
.$type<"unknown" | "healthy" | "unhealthy">()
.default("unknown"), // "unknown", "healthy", "unhealthy"
hcTlsServerName: text("hcTlsServerName"),
hcHealthyThreshold: integer("hcHealthyThreshold").default(1),
hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1)
});
}),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
name: varchar("name"),
hcEnabled: boolean("hcEnabled").notNull().default(false),
hcPath: varchar("hcPath"),
hcScheme: varchar("hcScheme"),
hcMode: varchar("hcMode").default("http"),
hcHostname: varchar("hcHostname"),
hcPort: integer("hcPort"),
hcInterval: integer("hcInterval").default(30), // in seconds
hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds
hcTimeout: integer("hcTimeout").default(5), // in seconds
hcHeaders: varchar("hcHeaders"),
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
hcMethod: varchar("hcMethod").default("GET"),
hcStatus: integer("hcStatus"), // http code
hcHealth: text("hcHealth")
.$type<"unknown" | "healthy" | "unhealthy">()
.default("unknown"), // "unknown", "healthy", "unhealthy"
hcTlsServerName: text("hcTlsServerName"),
hcHealthyThreshold: integer("hcHealthyThreshold").default(1),
hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1)
},
(t) => [index("idx_targethealthcheck_targetid").on(t.targetId)]
);
export const exitNodes = pgTable("exitNodes", {
exitNodeId: serial("exitNodeId").primaryKey(),
@@ -406,43 +442,74 @@ export const networks = pgTable("networks", {
.notNull()
});
export const siteNetworks = pgTable("siteNetworks", {
siteId: integer("siteId")
.notNull()
.references(() => sites.siteId, {
onDelete: "cascade"
}),
networkId: integer("networkId")
.notNull()
.references(() => networks.networkId, { onDelete: "cascade" })
});
export const siteNetworks = pgTable(
"siteNetworks",
{
siteId: integer("siteId")
.notNull()
.references(() => sites.siteId, {
onDelete: "cascade"
}),
networkId: integer("networkId")
.notNull()
.references(() => networks.networkId, { onDelete: "cascade" })
},
(t) => [
index("idx_sitenetworks_siteid").on(t.siteId),
index("idx_sitenetworks_networkid").on(t.networkId)
]
);
export const clientSiteResources = pgTable("clientSiteResources", {
clientId: integer("clientId")
.notNull()
.references(() => clients.clientId, { onDelete: "cascade" }),
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, { onDelete: "cascade" })
});
export const clientSiteResources = pgTable(
"clientSiteResources",
{
clientId: integer("clientId")
.notNull()
.references(() => clients.clientId, { onDelete: "cascade" }),
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
},
(t) => [
index("idx_clientsiteresources_clientid").on(t.clientId),
index("idx_clientsiteresources_siteresourceid").on(t.siteResourceId)
]
);
export const roleSiteResources = pgTable("roleSiteResources", {
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" }),
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, { onDelete: "cascade" })
});
export const roleSiteResources = pgTable(
"roleSiteResources",
{
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" }),
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
},
(t) => [index("idx_rolesiteresources_siteresourceid").on(t.siteResourceId)]
);
export const userSiteResources = pgTable("userSiteResources", {
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, { onDelete: "cascade" })
});
export const userSiteResources = pgTable(
"userSiteResources",
{
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
},
(t) => [
index("idx_usersiteresources_userid").on(t.userId),
index("idx_usersiteresources_siteresourceid").on(t.siteResourceId)
]
);
export const users = pgTable("user", {
userId: varchar("id").primaryKey(),
@@ -467,15 +534,19 @@ export const users = pgTable("user", {
locale: varchar("locale")
});
export const newts = pgTable("newt", {
newtId: varchar("id").primaryKey(),
secretHash: varchar("secretHash").notNull(),
dateCreated: varchar("dateCreated").notNull(),
version: varchar("version"),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
})
});
export const newts = pgTable(
"newt",
{
newtId: varchar("id").primaryKey(),
secretHash: varchar("secretHash").notNull(),
dateCreated: varchar("dateCreated").notNull(),
version: varchar("version"),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
})
},
(t) => [index("idx_newt_siteid").on(t.siteId)]
);
export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", {
codeId: serial("id").primaryKey(),
@@ -576,29 +647,49 @@ export const userOrgRoles = pgTable(
(t) => [unique().on(t.userId, t.orgId, t.roleId)]
);
export const roleActions = pgTable("roleActions", {
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" }),
actionId: varchar("actionId")
.notNull()
.references(() => actions.actionId, { onDelete: "cascade" }),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" })
});
export const roleActions = pgTable(
"roleActions",
{
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" }),
actionId: varchar("actionId")
.notNull()
.references(() => actions.actionId, { onDelete: "cascade" }),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" })
},
(t) => [
index("idx_roleActions_roleId_orgId_actionId").on(
t.roleId,
t.orgId,
t.actionId
)
]
);
export const userActions = pgTable("userActions", {
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
actionId: varchar("actionId")
.notNull()
.references(() => actions.actionId, { onDelete: "cascade" }),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" })
});
export const userActions = pgTable(
"userActions",
{
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
actionId: varchar("actionId")
.notNull()
.references(() => actions.actionId, { onDelete: "cascade" }),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" })
},
(t) => [
index("idx_userActions_userId_orgId_actionId").on(
t.userId,
t.orgId,
t.actionId
)
]
);
export const roleSites = pgTable("roleSites", {
roleId: integer("roleId")
@@ -1004,40 +1095,44 @@ export const idpOrg = pgTable("idpOrg", {
orgMapping: varchar("orgMapping")
});
export const clients = pgTable("clients", {
clientId: serial("clientId").primaryKey(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
export const clients = pgTable(
"clients",
{
clientId: serial("clientId").primaryKey(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
userId: text("userId").references(() => users.userId, {
// optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade"
})
.notNull(),
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
userId: text("userId").references(() => users.userId, {
// optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade"
}),
niceId: varchar("niceId").notNull(),
olmId: text("olmId"), // to lock it to a specific olm optionally
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet").notNull(),
megabytesIn: real("bytesIn"),
megabytesOut: real("bytesOut"),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
lastPing: integer("lastPing"),
type: varchar("type").notNull(), // "olm"
online: boolean("online").notNull().default(false),
// endpoint: varchar("endpoint"),
lastHolePunch: integer("lastHolePunch"),
maxConnections: integer("maxConnections"),
archived: boolean("archived").notNull().default(false),
blocked: boolean("blocked").notNull().default(false),
approvalState: varchar("approvalState").$type<
"pending" | "approved" | "denied"
>()
});
}),
niceId: varchar("niceId").notNull(),
olmId: text("olmId"), // to lock it to a specific olm optionally
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet").notNull(),
megabytesIn: real("bytesIn"),
megabytesOut: real("bytesOut"),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
lastPing: integer("lastPing"),
type: varchar("type").notNull(), // "olm"
online: boolean("online").notNull().default(false),
// endpoint: varchar("endpoint"),
lastHolePunch: integer("lastHolePunch"),
maxConnections: integer("maxConnections"),
archived: boolean("archived").notNull().default(false),
blocked: boolean("blocked").notNull().default(false),
approvalState: varchar("approvalState").$type<
"pending" | "approved" | "denied"
>()
},
(t) => [index("idx_clients_userid").on(t.userId)]
);
export const clientSitesAssociationsCache = pgTable(
"clientSitesAssociationsCache",
@@ -1049,7 +1144,11 @@ export const clientSitesAssociationsCache = pgTable(
isJitMode: boolean("isJitMode").notNull().default(false),
endpoint: varchar("endpoint"),
publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
}
},
(t) => [
primaryKey({ columns: [t.clientId, t.siteId] }),
index("idx_clientsitesassociationscache_siteid").on(t.siteId)
]
);
export const clientSiteResourcesAssociationsCache = pgTable(
@@ -1058,7 +1157,14 @@ export const clientSiteResourcesAssociationsCache = pgTable(
clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message
.notNull(),
siteResourceId: integer("siteResourceId").notNull()
}
},
(t) => [
primaryKey({ columns: [t.clientId, t.siteResourceId] }),
index("idx_clientSiteResourcesAssociationsCache_siteResourceId").on(
t.siteResourceId,
t.clientId
)
]
);
export const clientPostureSnapshots = pgTable("clientPostureSnapshots", {
@@ -1071,23 +1177,27 @@ export const clientPostureSnapshots = pgTable("clientPostureSnapshots", {
collectedAt: integer("collectedAt").notNull()
});
export const olms = pgTable("olms", {
olmId: varchar("id").primaryKey(),
secretHash: varchar("secretHash").notNull(),
dateCreated: varchar("dateCreated").notNull(),
version: text("version"),
agent: text("agent"),
name: varchar("name"),
clientId: integer("clientId").references(() => clients.clientId, {
// we will switch this depending on the current org it wants to connect to
onDelete: "set null"
}),
userId: text("userId").references(() => users.userId, {
// optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade"
}),
archived: boolean("archived").notNull().default(false)
});
export const olms = pgTable(
"olms",
{
olmId: varchar("id").primaryKey(),
secretHash: varchar("secretHash").notNull(),
dateCreated: varchar("dateCreated").notNull(),
version: text("version"),
agent: text("agent"),
name: varchar("name"),
clientId: integer("clientId").references(() => clients.clientId, {
// we will switch this depending on the current org it wants to connect to
onDelete: "set null"
}),
userId: text("userId").references(() => users.userId, {
// optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade"
}),
archived: boolean("archived").notNull().default(false)
},
(t) => [index("idx_olms_clientid").on(t.clientId)]
);
export const currentFingerprint = pgTable("currentFingerprint", {
fingerprintId: serial("id").primaryKey(),
+12 -50
View File
@@ -1,6 +1,5 @@
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import type BetterSqlite3 from "better-sqlite3";
import * as schema from "./schema/schema";
import path from "path";
import fs from "fs";
@@ -12,68 +11,31 @@ export const exists = checkFileExists(location);
bootstrapVolume();
/**
* Wraps better-sqlite3 Statement to call `finalize()` immediately after
* execution, freeing native sqlite3_stmt memory deterministically instead
* of waiting for GC. Fixes steady off-heap growth under load (#2120).
* WARNING: Finalizes after first execution incompatible with drizzle's
* reusable .prepare() builders. No such usage exists in this codebase.
*/
function autoFinalizeStatement(
stmt: BetterSqlite3.Statement
): BetterSqlite3.Statement {
const wrapExec = <T extends (...args: any[]) => any>(fn: T): T => {
return function (this: any, ...args: any[]) {
try {
return fn.apply(this, args);
} finally {
try {
// finalize() exists on the native Statement at runtime but
// is missing from @types/better-sqlite3.
(stmt as any).finalize();
} catch {
// Already finalized — harmless
}
}
} as unknown as T;
};
stmt.run = wrapExec(stmt.run);
stmt.get = wrapExec(stmt.get);
stmt.all = wrapExec(stmt.all);
return stmt;
}
function createDb() {
const sqlite = new Database(location);
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
// Enable WAL mode — allows concurrent readers + single writer, preventing
// contention across subsystems (verifySession, Traefik, audit, ping).
// NOTE: journal_mode persists in the DB file once set; unsetting this
// env var does NOT revert an existing WAL database.
sqlite.pragma("journal_mode = WAL");
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
sqlite.pragma("synchronous = NORMAL");
}
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log
// retry loops that accumulate memory.
sqlite.pragma("busy_timeout = 5000");
// No busy_timeout pragma: better-sqlite3 already arms
// sqlite3_busy_timeout(db, 5000) via its default `timeout` option
// (lib/database.js), so an explicit pragma is redundant.
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large
// TraefikConfigManager JOINs that block the event loop.
sqlite.pragma("cache_size = -65536");
// Intentionally NOT setting cache_size or mmap_size: a large page cache plus
// a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing
// on small (~1 GB) instances. Leave SQLite on its conservative defaults.
// 256 MB memory-mapped I/O — OS serves reads from page cache directly,
// reducing event-loop blocking.
sqlite.pragma("mmap_size = 268435456");
// Wrap prepare() so every drizzle-orm statement is auto-finalized after
// first use, preventing sqlite3_stmt accumulation between GC cycles.
const originalPrepare = sqlite.prepare.bind(sqlite);
(sqlite as any).prepare = function autoFinalizePrepare(source: string) {
return autoFinalizeStatement(originalPrepare(source));
};
// Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes
// sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a
// fresh statement per query (no statement cache), so statements cannot
// accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all.
return DrizzleSqlite(sqlite, {
schema
+2
View File
@@ -24,6 +24,7 @@ import license from "#dynamic/license/license";
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
import { fetchServerIp } from "@server/lib/serverIpService";
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
async function startServers() {
await setHostMeta();
@@ -41,6 +42,7 @@ async function startServers() {
initLogCleanupInterval();
initAcmeCertSync();
startRebuildQueueProcessor();
// Start all servers
const apiServer = createApiServer();
+5 -3
View File
@@ -12,7 +12,7 @@ import {
import { FeatureId, getFeatureMeterId } from "./features";
import logger from "@server/logger";
import { build } from "@server/build";
import cache from "#dynamic/lib/cache";
import { regionalCache as cache } from "#dynamic/lib/cache";
export function noop() {
if (build !== "saas") {
@@ -22,7 +22,6 @@ export function noop() {
}
export class UsageService {
constructor() {
if (noop()) {
return;
@@ -57,7 +56,10 @@ export class UsageService {
try {
let usage;
if (transaction) {
const orgIdToUse = await this.getBillingOrg(orgId, transaction);
const orgIdToUse = await this.getBillingOrg(
orgId,
transaction
);
usage = await this.internalAddUsage(
orgIdToUse,
featureId,
+8 -8
View File
@@ -48,18 +48,18 @@ export async function applyBlueprint({
name,
source = "API"
}: ApplyBlueprintArgs): Promise<Blueprint> {
// Validate the input data
const validationResult = ConfigSchema.safeParse(configData);
if (!validationResult.success) {
throw new Error(fromError(validationResult.error).toString());
}
const config: Config = validationResult.data;
let blueprintSucceeded: boolean = false;
let blueprintMessage: string;
let blueprintMessage = "";
let error: any | null = null;
try {
const validationResult = ConfigSchema.safeParse(configData);
if (!validationResult.success) {
throw new Error(fromError(validationResult.error).toString());
}
const config: Config = validationResult.data;
let proxyResourcesResults: PublicResourcesResults = [];
let clientResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => {
+74
View File
@@ -0,0 +1,74 @@
const MAX_RECURSION_DEPTH = 100;
const segmentRegexCache = new Map<string, RegExp>();
function getSegmentRegex(patternPart: string): RegExp {
let regex = segmentRegexCache.get(patternPart);
if (!regex) {
const regexPattern = patternPart
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
regex = new RegExp(`^${regexPattern}$`);
segmentRegexCache.set(patternPart, regex);
}
return regex;
}
export function isPathAllowed(pattern: string, path: string): boolean {
const normalize = (p: string) => p.split("/").filter(Boolean);
const patternParts = normalize(pattern);
const pathParts = normalize(path);
function matchSegments(
patternIndex: number,
pathIndex: number,
depth: number = 0
): boolean {
if (depth > MAX_RECURSION_DEPTH) {
return false;
}
const currentPatternPart = patternParts[patternIndex];
const currentPathPart = pathParts[pathIndex];
if (patternIndex >= patternParts.length) {
return pathIndex >= pathParts.length;
}
if (pathIndex >= pathParts.length) {
return patternParts.slice(patternIndex).every((p) => p === "*");
}
if (currentPatternPart === "*") {
if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) {
return true;
}
if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) {
return true;
}
return false;
}
if (currentPatternPart.includes("*")) {
const regex = getSegmentRegex(currentPatternPart);
if (regex.test(currentPathPart)) {
return matchSegments(
patternIndex + 1,
pathIndex + 1,
depth + 1
);
}
return false;
}
if (currentPatternPart !== currentPathPart) {
return false;
}
return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1);
}
return matchSegments(0, 0, 0);
}
+361 -126
View File
@@ -8,6 +8,7 @@ import {
exitNodes,
newts,
olms,
primaryDb,
roleSiteResources,
Site,
SiteResource,
@@ -20,10 +21,10 @@ import {
} from "@server/db";
import { and, count, eq, inArray, ne } from "drizzle-orm";
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers";
import {
initPeerAddHandshake,
deletePeer as olmDeletePeer
initPeerAddHandshakeBatch,
deletePeersBatch as olmDeletePeersBatch
} from "@server/routers/olm/peers";
import { sendToExitNode } from "#dynamic/lib/exitNodes";
import logger from "@server/logger";
@@ -34,12 +35,13 @@ import {
parseEndpoint
} from "@server/lib/ip";
import {
addPeerData,
addTargets as addSubnetProxyTargets,
removePeerData,
removeTargets as removeSubnetProxyTargets
addPeerDataBatch,
addTargetsBatch as addSubnetProxyTargetsBatch,
removePeerDataBatch,
removeTargetsBatch as removeSubnetProxyTargetsBatch
} from "@server/routers/client/targets";
import { lockManager } from "#dynamic/lib/lock";
import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
// TTL for rebuild-association locks. These functions can fan out into many
// peer/proxy updates, so give them a generous window.
@@ -160,18 +162,33 @@ export async function getClientSiteResourceAccess(
export async function rebuildClientAssociationsFromSiteResource(
siteResource: SiteResource,
trx: Transaction | typeof db = db
): Promise<{
mergedAllClients: {
clientId: number;
pubKey: string | null;
subnet: string | null;
}[];
}> {
return await lockManager.withLock(
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
);
) {
try {
return await lockManager.withLock(
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
() =>
rebuildClientAssociationsFromSiteResourceImpl(
siteResource,
trx
),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
);
} catch (err: any) {
if (
typeof err?.message === "string" &&
err.message.startsWith("Failed to acquire lock")
) {
logger.warn(
`rebuildClientAssociations: could not acquire lock for site resource ${siteResource.siteResourceId}, queuing for deferred processing`
);
await rebuildQueue.enqueue({
type: "site-resource",
id: siteResource.siteResourceId
});
return { mergedAllClients: [] };
}
throw err;
}
}
async function rebuildClientAssociationsFromSiteResourceImpl(
@@ -536,6 +553,28 @@ async function handleMessagesForSiteClients(
const newtJobs: Promise<any>[] = [];
const olmJobs: Promise<any>[] = [];
const exitNodeJobs: Promise<any>[] = [];
const newtPeerDeletes: {
siteId: number;
publicKey: string;
newtId: string;
}[] = [];
const olmPeerDeletes: {
clientId: number;
siteId: number;
publicKey: string;
olmId: string;
}[] = [];
const olmPeerAddHandshakes: {
clientId: number;
peer: {
siteId: number;
exitNode: {
publicKey: string;
endpoint: string;
};
};
olmId: string;
}[] = [];
// Combine all clients that need processing (those being added or removed)
const clientsToProcess = new Map<
@@ -584,6 +623,21 @@ async function handleMessagesForSiteClients(
}
}
// Batch-fetch all olm IDs for the clients we need to process
const clientIdsToProcess = Array.from(clientsToProcess.keys());
const olmRows =
clientIdsToProcess.length > 0
? await trx
.select({ olmId: olms.olmId, clientId: olms.clientId })
.from(olms)
.where(inArray(olms.clientId, clientIdsToProcess))
: [];
const olmByClientId = new Map<number, string>(
olmRows
.filter((r) => r.clientId !== null)
.map((r) => [r.clientId as number, r.olmId])
);
for (const client of clientsToProcess.values()) {
// UPDATE THE NEWT
if (!client.subnet || !client.pubKey) {
@@ -600,14 +654,8 @@ async function handleMessagesForSiteClients(
continue;
}
const [olm] = await trx
.select({
olmId: olms.olmId
})
.from(olms)
.where(eq(olms.clientId, client.clientId))
.limit(1);
if (!olm) {
const olmId = olmByClientId.get(client.clientId);
if (!olmId) {
logger.warn(
`Olm not found for client ${client.clientId} so cannot add/delete peers`
);
@@ -615,15 +663,17 @@ async function handleMessagesForSiteClients(
}
if (isDelete) {
newtJobs.push(newtDeletePeer(siteId, client.pubKey, newt.newtId));
olmJobs.push(
olmDeletePeer(
client.clientId,
siteId,
site.publicKey,
olm.olmId
)
);
newtPeerDeletes.push({
siteId,
publicKey: client.pubKey,
newtId: newt.newtId
});
olmPeerDeletes.push({
clientId: client.clientId,
siteId,
publicKey: site.publicKey,
olmId
});
}
if (isAdd) {
@@ -635,23 +685,34 @@ async function handleMessagesForSiteClients(
continue;
}
await initPeerAddHandshake(
// this will kick off the add peer process for the client
client.clientId,
{
olmPeerAddHandshakes.push({
clientId: client.clientId,
peer: {
siteId,
exitNode: {
publicKey: exitNode.publicKey,
endpoint: exitNode.endpoint
}
},
olm.olmId
);
olmId
});
}
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
}
if (newtPeerDeletes.length > 0) {
newtJobs.push(newtDeletePeersBatch(newtPeerDeletes));
}
if (olmPeerDeletes.length > 0) {
olmJobs.push(olmDeletePeersBatch(olmPeerDeletes));
}
if (olmPeerAddHandshakes.length > 0) {
olmJobs.push(initPeerAddHandshakeBatch(olmPeerAddHandshakes));
}
Promise.all(exitNodeJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`,
@@ -812,6 +873,20 @@ async function handleSubnetProxyTargetUpdates(
): Promise<void> {
const proxyJobs: Promise<any>[] = [];
const olmJobs: Promise<any>[] = [];
const targetsToAddBatch: {
newtId: string;
targets: NonNullable<
Awaited<ReturnType<typeof generateSubnetProxyTargetV2>>
>;
version: string | null;
}[] = [];
const targetsToRemoveBatch: {
newtId: string;
targets: NonNullable<
Awaited<ReturnType<typeof generateSubnetProxyTargetV2>>
>;
version: string | null;
}[] = [];
for (const siteData of sitesList) {
const siteId = siteData.siteId;
@@ -843,25 +918,25 @@ async function handleSubnetProxyTargetUpdates(
);
if (targetsToAdd) {
proxyJobs.push(
addSubnetProxyTargets(
newt.newtId,
targetsToAdd,
newt.version
)
);
targetsToAddBatch.push({
newtId: newt.newtId,
targets: targetsToAdd,
version: newt.version
});
}
for (const client of addedClients) {
olmJobs.push(
addPeerData(
client.clientId,
olmJobs.push(
addPeerDataBatch(
addedClients.map((client) => ({
clientId: client.clientId,
siteId,
generateRemoteSubnets([siteResource]),
generateAliasConfig([siteResource])
)
);
}
remoteSubnets: generateRemoteSubnets([
siteResource
]),
aliases: generateAliasConfig([siteResource])
}))
)
);
}
}
@@ -880,15 +955,20 @@ async function handleSubnetProxyTargetUpdates(
);
if (targetsToRemove) {
proxyJobs.push(
removeSubnetProxyTargets(
newt.newtId,
targetsToRemove,
newt.version
)
);
targetsToRemoveBatch.push({
newtId: newt.newtId,
targets: targetsToRemove,
version: newt.version
});
}
const peerDataRemovals: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: ReturnType<typeof generateAliasConfig>;
}[] = [];
for (const client of removedClients) {
if (!siteResource.destination) {
continue;
@@ -936,31 +1016,58 @@ async function handleSubnetProxyTargetUpdates(
? []
: generateRemoteSubnets([siteResource]);
olmJobs.push(
removePeerData(
client.clientId,
siteId,
remoteSubnetsToRemove,
generateAliasConfig([siteResource])
)
);
peerDataRemovals.push({
clientId: client.clientId,
siteId,
remoteSubnets: remoteSubnetsToRemove,
aliases: generateAliasConfig([siteResource])
});
}
if (peerDataRemovals.length > 0) {
olmJobs.push(removePeerDataBatch(peerDataRemovals));
}
}
}
}
await Promise.all(proxyJobs);
if (targetsToAddBatch.length > 0) {
proxyJobs.push(addSubnetProxyTargetsBatch(targetsToAddBatch));
}
if (targetsToRemoveBatch.length > 0) {
proxyJobs.push(removeSubnetProxyTargetsBatch(targetsToRemoveBatch));
}
await Promise.all([...proxyJobs, ...olmJobs]);
}
export async function rebuildClientAssociationsFromClient(
client: Client,
trx: Transaction | typeof db = db
): Promise<void> {
return await lockManager.withLock(
`rebuild-client-associations:client:${client.clientId}`,
() => rebuildClientAssociationsFromClientImpl(client, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
);
try {
return await lockManager.withLock(
`rebuild-client-associations:client:${client.clientId}`,
() => rebuildClientAssociationsFromClientImpl(client, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
);
} catch (err: any) {
if (
typeof err?.message === "string" &&
err.message.startsWith("Failed to acquire lock")
) {
logger.warn(
`rebuildClientAssociations: could not acquire lock for client ${client.clientId}, queuing for deferred processing`
);
await rebuildQueue.enqueue({
type: "client",
id: client.clientId
});
return;
}
throw err;
}
}
async function rebuildClientAssociationsFromClientImpl(
@@ -1237,6 +1344,28 @@ async function handleMessagesForClientSites(
const newtJobs: Promise<any>[] = [];
const olmJobs: Promise<any>[] = [];
const exitNodeJobs: Promise<any>[] = [];
const newtPeerDeletes: {
siteId: number;
publicKey: string;
newtId: string;
}[] = [];
const olmPeerDeletes: {
clientId: number;
siteId: number;
publicKey: string;
olmId: string;
}[] = [];
const olmPeerAddHandshakes: {
clientId: number;
peer: {
siteId: number;
exitNode: {
publicKey: string;
endpoint: string;
};
};
olmId: string;
}[] = [];
const totalSitesOnClient = await trx
.select({ count: count(clientSitesAssociationsCache.siteId) })
@@ -1268,19 +1397,19 @@ async function handleMessagesForClientSites(
if (isRemove) {
// Remove peer from newt
newtJobs.push(
newtDeletePeer(site.siteId, client.pubKey, newt.newtId)
);
newtPeerDeletes.push({
siteId: site.siteId,
publicKey: client.pubKey,
newtId: newt.newtId
});
try {
// Remove peer from olm
olmJobs.push(
olmDeletePeer(
client.clientId,
site.siteId,
site.publicKey,
olmId
)
);
olmPeerDeletes.push({
clientId: client.clientId,
siteId: site.siteId,
publicKey: site.publicKey,
olmId
});
} catch (error) {
// if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send
if (
@@ -1312,10 +1441,9 @@ async function handleMessagesForClientSites(
continue;
}
await initPeerAddHandshake(
// this will kick off the add peer process for the client
client.clientId,
{
olmPeerAddHandshakes.push({
clientId: client.clientId,
peer: {
siteId: site.siteId,
exitNode: {
publicKey: exitNode.publicKey,
@@ -1323,7 +1451,7 @@ async function handleMessagesForClientSites(
}
},
olmId
);
});
}
// Update exit node destinations
@@ -1339,6 +1467,18 @@ async function handleMessagesForClientSites(
);
}
if (newtPeerDeletes.length > 0) {
newtJobs.push(newtDeletePeersBatch(newtPeerDeletes));
}
if (olmPeerDeletes.length > 0) {
olmJobs.push(olmDeletePeersBatch(olmPeerDeletes));
}
if (olmPeerAddHandshakes.length > 0) {
olmJobs.push(initPeerAddHandshakeBatch(olmPeerAddHandshakes));
}
Promise.all(exitNodeJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`,
@@ -1437,6 +1577,20 @@ async function handleMessagesForClientResources(
continue;
}
const targetsToAddBatch: {
newtId: string;
targets: NonNullable<
Awaited<ReturnType<typeof generateSubnetProxyTargetV2>>
>;
version: string | null;
}[] = [];
const peerDataAdds: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: ReturnType<typeof generateAliasConfig>;
}[] = [];
for (const resource of resources) {
const targets = await generateSubnetProxyTargetV2(resource, [
{
@@ -1447,25 +1601,21 @@ async function handleMessagesForClientResources(
]);
if (targets) {
proxyJobs.push(
addSubnetProxyTargets(
newt.newtId,
targets,
newt.version
)
);
targetsToAddBatch.push({
newtId: newt.newtId,
targets,
version: newt.version
});
}
try {
// Add peer data to olm
olmJobs.push(
addPeerData(
client.clientId,
siteId,
generateRemoteSubnets([resource]),
generateAliasConfig([resource])
)
);
peerDataAdds.push({
clientId: client.clientId,
siteId,
remoteSubnets: generateRemoteSubnets([resource]),
aliases: generateAliasConfig([resource])
});
} catch (error) {
// if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send
if (
@@ -1480,6 +1630,14 @@ async function handleMessagesForClientResources(
}
}
}
if (targetsToAddBatch.length > 0) {
proxyJobs.push(addSubnetProxyTargetsBatch(targetsToAddBatch));
}
if (peerDataAdds.length > 0) {
olmJobs.push(addPeerDataBatch(peerDataAdds));
}
}
}
@@ -1546,6 +1704,20 @@ async function handleMessagesForClientResources(
continue;
}
const targetsToRemoveBatch: {
newtId: string;
targets: NonNullable<
Awaited<ReturnType<typeof generateSubnetProxyTargetV2>>
>;
version: string | null;
}[] = [];
const peerDataRemovals: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: ReturnType<typeof generateAliasConfig>;
}[] = [];
for (const resource of resources) {
const targets = await generateSubnetProxyTargetV2(resource, [
{
@@ -1556,13 +1728,11 @@ async function handleMessagesForClientResources(
]);
if (targets) {
proxyJobs.push(
removeSubnetProxyTargets(
newt.newtId,
targets,
newt.version
)
);
targetsToRemoveBatch.push({
newtId: newt.newtId,
targets,
version: newt.version
});
}
try {
@@ -1613,14 +1783,12 @@ async function handleMessagesForClientResources(
: generateRemoteSubnets([resource]);
// Remove peer data from olm
olmJobs.push(
removePeerData(
client.clientId,
siteId,
remoteSubnetsToRemove,
generateAliasConfig([resource])
)
);
peerDataRemovals.push({
clientId: client.clientId,
siteId,
remoteSubnets: remoteSubnetsToRemove,
aliases: generateAliasConfig([resource])
});
} catch (error) {
// if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send
if (
@@ -1635,6 +1803,16 @@ async function handleMessagesForClientResources(
}
}
}
if (targetsToRemoveBatch.length > 0) {
proxyJobs.push(
removeSubnetProxyTargetsBatch(targetsToRemoveBatch)
);
}
if (peerDataRemovals.length > 0) {
olmJobs.push(removePeerDataBatch(peerDataRemovals));
}
}
}
@@ -1884,11 +2062,20 @@ export async function cleanupSiteAssociations(
// 7. Fire all removal messages in parallel.
const jobs: Promise<any>[] = [];
const olmPeerDeletes: {
clientId: number;
siteId: number;
publicKey: string;
}[] = [];
for (const client of allClients) {
// Tell each olm to drop the site's WireGuard peer.
if (site.publicKey) {
jobs.push(olmDeletePeer(client.clientId, siteId, site.publicKey));
olmPeerDeletes.push({
clientId: client.clientId,
siteId,
publicKey: site.publicKey
});
}
// Recompute and push updated relay destinations (now excluding this site).
@@ -1897,6 +2084,10 @@ export async function cleanupSiteAssociations(
}
}
if (olmPeerDeletes.length > 0) {
jobs.push(olmDeletePeersBatch(olmPeerDeletes));
}
await Promise.all(jobs).catch((error) => {
logger.error(
`cleanupSiteAssociations: error sending cleanup messages for siteId=${siteId}:`,
@@ -1906,3 +2097,47 @@ export async function cleanupSiteAssociations(
logger.debug(`cleanupSiteAssociations: DONE siteId=${siteId}`);
}
/**
* Start the background rebuild queue processor. This should be called once
* during server startup. Only one server instance at a time will actively
* consume the queue (enforced via a distributed Redis lock); all other
* instances will poll and wait until the lock becomes available.
*/
export function startRebuildQueueProcessor(): void {
rebuildQueue.startProcessing({
onSiteResource: async (siteResourceId: number) => {
const [siteResource] = await primaryDb
.select()
.from(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId));
if (!siteResource) {
logger.warn(
`Rebuild queue: site resource ${siteResourceId} not found, skipping`
);
return;
}
await rebuildClientAssociationsFromSiteResource(
siteResource,
primaryDb
);
},
onClient: async (clientId: number) => {
const [client] = await primaryDb
.select()
.from(clients)
.where(eq(clients.clientId, clientId));
if (!client) {
logger.warn(
`Rebuild queue: client ${clientId} not found, skipping`
);
return;
}
await rebuildClientAssociationsFromClient(client, primaryDb);
}
});
}
+23
View File
@@ -0,0 +1,23 @@
export type RebuildJobType = "site-resource" | "client";
export interface RebuildJob {
type: RebuildJobType;
id: number;
}
export interface RebuildJobHandlers {
onSiteResource(siteResourceId: number): Promise<void>;
onClient(clientId: number): Promise<void>;
}
export interface RebuildQueueManager {
enqueue(job: RebuildJob): Promise<void>;
startProcessing(handlers: RebuildJobHandlers): void;
}
class NoopRebuildQueue implements RebuildQueueManager {
async enqueue(_job: RebuildJob): Promise<void> {}
startProcessing(_handlers: RebuildJobHandlers): void {}
}
export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue();
+8 -1
View File
@@ -511,6 +511,12 @@ export class TraefikConfigManager {
let traefikConfig;
try {
const currentExitNode = await getCurrentExitNodeId();
const maintenancePort = config.getRawConfig().server.next_port;
const maintenanceHost =
config.getRawConfig().server.internal_hostname;
const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`;
// logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`);
traefikConfig = await getTraefikConfig(
// this is called by the local exit node to get its own config
@@ -521,7 +527,8 @@ export class TraefikConfigManager {
build == "saas"
? false
: config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config
build != "oss" // generate browser gateway targets on cloud and enterprise
pangolinUIUrl, // generate maintenance pages on cloud and hybrid
pangolinUIUrl // generate browser gateway targets on cloud and hybrid
);
const domains = new Set<string>();
+2 -2
View File
@@ -44,8 +44,8 @@ export async function getTraefikConfig(
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
allowRawResources = true,
allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE
allowBrowserGatewayResources = true
maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
browserGatewayUiUrl: string | null = null // UNUSED BUT USED IN PRIVATE
): Promise<any> {
// Get resources with their targets and sites in a single optimized query
// Start from sites on this exit node, then join to targets and resources
+41 -1
View File
@@ -1,4 +1,7 @@
import { isValidUrlGlobPattern } from "./validators";
import {
getResourceRuleValueValidationError,
isValidUrlGlobPattern
} from "./validators";
import { assertEquals } from "@test/assert";
function runTests() {
@@ -236,6 +239,43 @@ function runTests() {
"Path with isolated percent sign should be invalid"
);
// ASN validation tests
assertEquals(
getResourceRuleValueValidationError("ASN", "AS15169"),
null,
"Standard ASN should be valid"
);
assertEquals(
getResourceRuleValueValidationError("ASN", " As15169 "),
null,
"Standard ASN should be valid with mixed case and whitespace"
);
assertEquals(
getResourceRuleValueValidationError("ASN", "ALL"),
null,
"ALL ASN selector should be valid"
);
assertEquals(
getResourceRuleValueValidationError("ASN", " all "),
null,
"ALL ASN selector should be valid with mixed case and whitespace"
);
assertEquals(
getResourceRuleValueValidationError("ASN", "AS0"),
null,
"AS0 alias should be valid"
);
assertEquals(
getResourceRuleValueValidationError("ASN", " as0 "),
null,
"AS0 alias should be valid with mixed case and whitespace"
);
assertEquals(
getResourceRuleValueValidationError("ASN", "not-an-asn"),
"Invalid ASN provided",
"Invalid ASN should return an error"
);
console.log("All tests passed!");
}
+4 -1
View File
@@ -100,7 +100,10 @@ export function getResourceRuleValueValidationError(
? null
: "Invalid country code provided";
case "ASN":
return /^AS\d+$/i.test(value.trim())
const normalizedValue = value.trim().toUpperCase();
return /^AS\d+$/.test(normalizedValue) ||
normalizedValue === "ALL" ||
normalizedValue === "AS0"
? null
: "Invalid ASN provided";
default:
+1 -1
View File
@@ -17,7 +17,7 @@ import { certificates, db } from "@server/db";
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import cache from "#private/lib/cache";
import { regionalCache as cache } from "#private/lib/cache";
import { build } from "@server/build";
// Define the return type for clarity and type safety
+198
View File
@@ -0,0 +1,198 @@
/*
* 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 { redis } from "#private/lib/redis";
import { lockManager } from "#dynamic/lib/lock";
import logger from "@server/logger";
export type RebuildJobType = "site-resource" | "client";
export interface RebuildJob {
type: RebuildJobType;
id: number;
}
export interface RebuildJobHandlers {
onSiteResource(siteResourceId: number): Promise<void>;
onClient(clientId: number): Promise<void>;
}
// Redis list holding pending rebuild jobs (RPUSH to enqueue, LPOP to dequeue — FIFO order).
const QUEUE_KEY = "rebuild-client-associations:queue";
const QUEUED_SET_KEY = "rebuild-client-associations:queued";
// Distributed lock that serialises queue consumption to a single server instance
// at a time. TTL is generous enough to cover a full batch of expensive rebuilds.
const PROCESSOR_LOCK_KEY = "rebuild-client-associations:processor";
// Each rebuild can take up to REBUILD_ASSOCIATIONS_LOCK_TTL_MS (120 s) per
// resource. Allow BATCH_SIZE resources per processor-lock acquisition, plus a
// small buffer.
const BATCH_SIZE = 5;
const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s
const POLL_INTERVAL_MS = 500;
class RedisRebuildQueue {
private processingStarted = false;
async enqueue(job: RebuildJob): Promise<void> {
if (!redis || redis.status !== "ready") {
logger.warn(
`Rebuild queue: Redis not available — rebuild for ${job.type}:${job.id} will not be retried`
);
return;
}
try {
const dedupeKey = `${job.type}:${job.id}`;
const added = await redis.sadd(QUEUED_SET_KEY, dedupeKey);
if (added === 0) {
logger.debug(
`Rebuild queue: skipped duplicate queued job ${job.type}:${job.id}`
);
return;
}
await redis.rpush(QUEUE_KEY, JSON.stringify(job));
logger.debug(
`Rebuild queue: enqueued ${job.type}:${job.id} (queue position: tail)`
);
} catch (err) {
await redis
.srem(QUEUED_SET_KEY, `${job.type}:${job.id}`)
.catch((cleanupErr) =>
logger.warn(
`Rebuild queue: failed to cleanup dedupe key for ${job.type}:${job.id} after enqueue failure:`,
cleanupErr
)
);
logger.error(
`Rebuild queue: failed to enqueue ${job.type}:${job.id}:`,
err
);
}
}
startProcessing(handlers: RebuildJobHandlers): void {
if (this.processingStarted) return;
this.processingStarted = true;
this.processLoop(handlers).catch((err) => {
logger.error("Rebuild queue processor loop crashed:", err);
});
logger.info("Rebuild queue processor started");
}
private async processLoop(handlers: RebuildJobHandlers): Promise<void> {
while (true) {
try {
await this.tryProcessBatch(handlers);
} catch (err) {
logger.error(
"Rebuild queue: unhandled error in process loop:",
err
);
}
await new Promise((resolve) =>
setTimeout(resolve, POLL_INTERVAL_MS)
);
}
}
private async tryProcessBatch(handlers: RebuildJobHandlers): Promise<void> {
if (!redis || redis.status !== "ready") return;
// Peek before acquiring the processor lock to avoid unnecessary Redis
// round-trips and lock contention when the queue is idle.
const queueLength = await redis.llen(QUEUE_KEY).catch(() => 0);
if (queueLength === 0) return;
try {
await lockManager.withLock(
PROCESSOR_LOCK_KEY,
async () => {
for (let i = 0; i < BATCH_SIZE; i++) {
if (!redis || redis.status !== "ready") break;
const payload = await redis.lpop(QUEUE_KEY);
if (payload === null) break; // queue drained
let job: RebuildJob;
try {
job = JSON.parse(payload) as RebuildJob;
} catch {
logger.error(
`Rebuild queue: could not parse job payload, discarding: ${payload}`
);
continue;
}
// Remove from dedupe set once dequeued so the same job
// can be re-queued while this one is in progress.
await redis
.srem(QUEUED_SET_KEY, `${job.type}:${job.id}`)
.catch((cleanupErr) =>
logger.warn(
`Rebuild queue: failed to remove dedupe key for ${job.type}:${job.id} on dequeue:`,
cleanupErr
)
);
logger.debug(
`Rebuild queue: processing ${job.type}:${job.id}`
);
try {
if (job.type === "site-resource") {
await handlers.onSiteResource(job.id);
} else if (job.type === "client") {
await handlers.onClient(job.id);
} else {
logger.warn(
`Rebuild queue: unknown job type "${(job as any).type}", discarding`
);
}
logger.debug(
`Rebuild queue: completed ${job.type}:${job.id}`
);
} catch (err) {
logger.error(
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
err
);
}
}
},
PROCESSOR_LOCK_TTL_MS
);
} catch (err: any) {
if (
typeof err?.message === "string" &&
err.message.startsWith("Failed to acquire lock")
) {
// Another server instance currently holds the processor lock and
// is consuming the queue — nothing to do this cycle.
logger.debug(
"Rebuild queue: processor lock held by another instance, skipping this cycle"
);
} else {
throw err;
}
}
}
}
export const rebuildQueue: RedisRebuildQueue = new RedisRebuildQueue();
+63 -29
View File
@@ -84,8 +84,8 @@ export async function getTraefikConfig(
filterOutNamespaceDomains = false,
generateLoginPageRouters = false,
allowRawResources = true,
allowMaintenancePage = true,
allowBrowserGatewayResources = true
maintenancePageUiUrl: string | null = null,
browserGatewayUiUrl: string | null = null
): Promise<any> {
// Get resources with their targets and sites in a single optimized query
// Start from sites on this exit node, then join to targets and resources
@@ -317,7 +317,7 @@ export async function getTraefikConfig(
BrowserGatewayResourceEntry
>();
if (allowBrowserGatewayResources) {
if (browserGatewayUiUrl) {
for (const row of resourcesWithTargetsAndSites) {
if (!["ssh", "vnc", "rdp"].includes(row.mode)) {
continue;
@@ -630,10 +630,11 @@ export async function getTraefikConfig(
}
}
if (showMaintenancePage && allowMaintenancePage) {
if (showMaintenancePage && maintenancePageUiUrl) {
const maintenanceServiceName = `${key}-maintenance-service`;
const maintenanceRouterName = `${key}-maintenance-router`;
const rewriteMiddlewareName = `${key}-maintenance-rewrite`;
const maintenanceHeadersMiddlewareName = `${key}-maintenance-headers`;
const entrypointHttp =
config.getRawConfig().traefik.http_entrypoint;
@@ -646,15 +647,11 @@ export async function getTraefikConfig(
? `*.${domainParts.slice(1).join(".")}`
: fullDomain;
const maintenancePort = config.getRawConfig().server.next_port;
const maintenanceHost =
config.getRawConfig().server.internal_hostname;
config_output.http.services[maintenanceServiceName] = {
loadBalancer: {
servers: [
{
url: `http://${maintenanceHost}:${maintenancePort}`
url: maintenancePageUiUrl
}
],
passHostHeader: true
@@ -673,12 +670,26 @@ export async function getTraefikConfig(
}
};
config_output.http.middlewares[
maintenanceHeadersMiddlewareName
] = {
headers: {
customRequestHeaders: {
Host: "app.pangolin.net", // if we are sending to the cloud the host needs to be this but we will pull the p-host to find the resource
"p-host": fullDomain
}
}
};
config_output.http.routers[maintenanceRouterName] = {
entryPoints: [
resource.ssl ? entrypointHttps : entrypointHttp
],
service: maintenanceServiceName,
middlewares: [rewriteMiddlewareName],
middlewares: [
rewriteMiddlewareName,
maintenanceHeadersMiddlewareName
],
rule: rule,
priority: 2000,
...(resource.ssl ? { tls } : {})
@@ -691,6 +702,7 @@ export async function getTraefikConfig(
resource.ssl ? entrypointHttps : entrypointHttp
],
service: maintenanceServiceName,
middlewares: [maintenanceHeadersMiddlewareName],
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`)) `,
priority: 2001,
...(resource.ssl ? { tls } : {})
@@ -1027,7 +1039,7 @@ export async function getTraefikConfig(
}
}
if (allowBrowserGatewayResources) {
if (browserGatewayUiUrl) {
// Generate Traefik config for browser gateway resources
const browserGatewayPort = 39999;
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
@@ -1119,20 +1131,17 @@ export async function getTraefikConfig(
}
}
if (showBgMaintenancePage && allowMaintenancePage) {
if (showBgMaintenancePage && maintenancePageUiUrl) {
const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`;
const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`;
const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`;
const bgMaintenanceHeadersMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-headers`;
const entrypointHttp =
config.getRawConfig().traefik.http_entrypoint;
const entrypointHttps =
config.getRawConfig().traefik.https_entrypoint;
const maintenancePort = config.getRawConfig().server.next_port;
const maintenanceHost =
config.getRawConfig().server.internal_hostname;
if (!config_output.http.services)
config_output.http.services = {};
if (!config_output.http.middlewares)
@@ -1144,7 +1153,7 @@ export async function getTraefikConfig(
loadBalancer: {
servers: [
{
url: `http://${maintenanceHost}:${maintenancePort}`
url: maintenancePageUiUrl
}
],
passHostHeader: true
@@ -1158,12 +1167,26 @@ export async function getTraefikConfig(
}
};
config_output.http.middlewares![
bgMaintenanceHeadersMiddlewareName
] = {
headers: {
customRequestHeaders: {
Host: "app.pangolin.net", // if we are sending to the cloud the host needs to be this but we will pull the p-host to find the resource
"p-host": fullDomain
}
}
};
config_output.http.routers![bgMaintenanceRouterName] = {
entryPoints: [
bgResource.ssl ? entrypointHttps : entrypointHttp
],
service: bgMaintenanceServiceName,
middlewares: [bgRewriteMiddlewareName],
middlewares: [
bgRewriteMiddlewareName,
bgMaintenanceHeadersMiddlewareName
],
rule: hostRule,
priority: 2000,
...(bgResource.ssl ? { tls } : {})
@@ -1176,6 +1199,7 @@ export async function getTraefikConfig(
bgResource.ssl ? entrypointHttps : entrypointHttp
],
service: bgMaintenanceServiceName,
middlewares: [bgMaintenanceHeadersMiddlewareName],
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 2001,
...(bgResource.ssl ? { tls } : {})
@@ -1234,9 +1258,8 @@ export async function getTraefikConfig(
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
// how the maintenance page rewrites everything to /maintenance-screen.
const primaryType = typeMap.keys().next().value as string;
const internalHost = config.getRawConfig().server.internal_hostname;
const internalPort = config.getRawConfig().server.next_port;
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
const uiHeadersMiddlewareName = `bg-r${bgResource.resourceId}-ui-headers`;
const entrypoint = bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint;
@@ -1252,22 +1275,33 @@ export async function getTraefikConfig(
}
};
config_output.http.middlewares![uiHeadersMiddlewareName] = {
headers: {
customRequestHeaders: {
Host: "app.pangolin.net", // if we are sending to the cloud the host needs to be this but we will pull the p-host to find the resource
"p-host": fullDomain
}
}
};
config_output.http.services![bgUiServiceName] = {
loadBalancer: {
servers: [
{
url: `http://${internalHost}:${internalPort}`
url: browserGatewayUiUrl
}
]
}
};
// Assets router at higher priority so /_next files load without rewrite
// Assets router at higher priority so /_next files load without rewrite.
// Do NOT apply the path-rewrite middleware here — static assets must
// keep their original path; only the host headers are needed.
config_output.http.routers![
`bg-r${bgResource.resourceId}-assets-router`
] = {
entryPoints: [entrypoint],
middlewares: routerMiddlewares,
middlewares: [...routerMiddlewares, uiHeadersMiddlewareName],
service: bgUiServiceName,
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
@@ -1279,7 +1313,11 @@ export async function getTraefikConfig(
`bg-r${bgResource.resourceId}-ui-router`
] = {
entryPoints: [entrypoint],
middlewares: [...routerMiddlewares, uiRewriteMiddlewareName],
middlewares: [
...routerMiddlewares,
uiRewriteMiddlewareName,
uiHeadersMiddlewareName
],
service: bgUiServiceName,
rule: hostRule,
priority: 100,
@@ -1312,10 +1350,6 @@ export async function getTraefikConfig(
const siteResourceRouterName = `${srKey}-router`;
const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`;
const maintenancePort = config.getRawConfig().server.next_port;
const maintenanceHost =
config.getRawConfig().server.internal_hostname;
if (!config_output.http.routers) {
config_output.http.routers = {};
}
@@ -1331,7 +1365,7 @@ export async function getTraefikConfig(
loadBalancer: {
servers: [
{
url: `http://${maintenanceHost}:${maintenancePort}`
url: maintenancePageUiUrl
}
],
passHostHeader: true
+4 -2
View File
@@ -277,6 +277,8 @@ hybridRouter.get(
);
}
const pangolinUIUrl = config.getRawConfig().app.dashboard_url; // points to the dashboard to serve from there
try {
const traefikConfig = await getTraefikConfig(
remoteExitNode.exitNodeId,
@@ -284,8 +286,8 @@ hybridRouter.get(
true, // But don't allow domain namespace resources
false, // Dont include login pages,
true, // allow raw resources
false, // dont generate maintenance page
false // dont generate browser gateway targets
pangolinUIUrl, // dont generate maintenance page
pangolinUIUrl // generate browser gateway targets
);
return response(res, {
@@ -22,7 +22,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import cache from "#private/lib/cache";
import { regionalCache as cache } from "#private/lib/cache";
import semver from "semver";
let stalePangolinNodeVersion: string | null = null;
+257 -47
View File
@@ -38,6 +38,7 @@ import { messageHandlers } from "@server/routers/ws/messageHandlers";
import { messageHandlers as privateMessageHandlers } from "#private/routers/ws/messageHandlers";
import {
AuthenticatedWebSocket,
BatchSendMessage,
ClientType,
WSMessage,
TokenPayload,
@@ -187,6 +188,8 @@ const wss: WebSocketServer = new WebSocketServer({ noServer: true });
// Generate unique node ID for this instance
const NODE_ID = uuidv4();
const REDIS_CHANNEL = "websocket_messages";
const REDIS_DIRECT_BATCH_SIZE = 250;
const REDIS_DIRECT_FLUSH_INTERVAL_MS = 10;
// Client tracking map (local to this node)
const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
@@ -197,6 +200,15 @@ const clientConfigVersions: Map<string, number> = new Map();
// Recovery tracking
let isRedisRecoveryInProgress = false;
interface RedisDirectBatchEntry {
targetClientId: string;
message: WSMessage;
resolve: () => void;
}
let pendingRedisDirectMessages: RedisDirectBatchEntry[] = [];
let redisDirectFlushTimer: NodeJS.Timeout | null = null;
// Helper to get map key
const getClientMapKey = (clientId: string) => clientId;
@@ -207,6 +219,78 @@ const getNodeConnectionsKey = (nodeId: string, clientId: string) =>
const getConfigVersionKey = (clientId: string) =>
`ws:configVersion:${clientId}`;
const clearRedisDirectFlushTimer = (): void => {
if (redisDirectFlushTimer) {
clearTimeout(redisDirectFlushTimer);
redisDirectFlushTimer = null;
}
};
const publishDirectBatch = async (
entries: RedisDirectBatchEntry[]
): Promise<void> => {
const redisMessage: RedisMessage = {
type: "direct-batch",
messages: entries.map((entry) => ({
targetClientId: entry.targetClientId,
message: entry.message
})),
fromNodeId: NODE_ID
};
await redisManager.publish(REDIS_CHANNEL, JSON.stringify(redisMessage));
};
const flushPendingRedisDirectMessages = async (): Promise<void> => {
clearRedisDirectFlushTimer();
if (pendingRedisDirectMessages.length === 0) {
return;
}
const entries = pendingRedisDirectMessages;
pendingRedisDirectMessages = [];
if (!redisManager.isRedisEnabled()) {
entries.forEach((entry) => entry.resolve());
return;
}
for (let i = 0; i < entries.length; i += REDIS_DIRECT_BATCH_SIZE) {
const batch = entries.slice(i, i + REDIS_DIRECT_BATCH_SIZE);
try {
await publishDirectBatch(batch);
} catch (error) {
logger.error(
"Failed to send batched direct messages via Redis, messages may be lost:",
error
);
} finally {
batch.forEach((entry) => entry.resolve());
}
}
};
const enqueueRedisDirectMessage = async (
targetClientId: string,
message: WSMessage
): Promise<void> => {
await new Promise<void>((resolve) => {
pendingRedisDirectMessages.push({ targetClientId, message, resolve });
if (pendingRedisDirectMessages.length >= REDIS_DIRECT_BATCH_SIZE) {
void flushPendingRedisDirectMessages();
return;
}
if (!redisDirectFlushTimer) {
redisDirectFlushTimer = setTimeout(() => {
void flushPendingRedisDirectMessages();
}, REDIS_DIRECT_FLUSH_INTERVAL_MS);
}
});
};
// Initialize Redis subscription for cross-node messaging
const initializeRedisSubscription = async (): Promise<void> => {
if (!redisManager.isRedisEnabled()) return;
@@ -227,7 +311,16 @@ const initializeRedisSubscription = async (): Promise<void> => {
// Send to specific client on this node
await sendToClientLocal(
redisMessage.targetClientId,
redisMessage.message
redisMessage.message,
{},
redisMessage.message.configVersion
);
} else if (
redisMessage.type === "direct-batch" &&
redisMessage.messages
) {
await sendRedisDirectBatchToLocalClients(
redisMessage.messages
);
} else if (redisMessage.type === "broadcast") {
// Broadcast to all clients on this node except excluded
@@ -503,7 +596,8 @@ const incrementClientConfigVersion = async (
const sendToClientLocal = async (
clientId: string,
message: WSMessage,
options: SendMessageOptions = {}
options: SendMessageOptions = {},
preResolvedConfigVersion?: number
): Promise<boolean> => {
const mapKey = getClientMapKey(clientId);
const clients = connectedClients.get(mapKey);
@@ -512,7 +606,8 @@ const sendToClientLocal = async (
}
// Handle config version
const configVersion = await getClientConfigVersion(clientId);
const configVersion =
preResolvedConfigVersion ?? (await getClientConfigVersion(clientId));
// Add config version to message
const messageWithVersion = {
@@ -545,43 +640,71 @@ const sendToClientLocal = async (
return true;
};
const sendRedisDirectBatchToLocalClients = async (
entries: { targetClientId: string; message: WSMessage }[]
): Promise<void> => {
const jobs = entries.map((entry) =>
sendToClientLocal(
entry.targetClientId,
entry.message,
{},
entry.message.configVersion
)
);
await Promise.all(jobs);
};
const broadcastToAllExceptLocal = async (
message: WSMessage,
excludeClientId?: string,
options: SendMessageOptions = {}
): Promise<void> => {
for (const [mapKey, clients] of connectedClients.entries()) {
const [type, id] = mapKey.split(":");
const clientId = mapKey; // mapKey is the clientId
if (!(excludeClientId && clientId === excludeClientId)) {
// Handle config version per client
let configVersion = await getClientConfigVersion(clientId);
if (options.incrementConfigVersion) {
configVersion = await incrementClientConfigVersion(clientId);
}
const sendPlans = await Promise.all(
Array.from(connectedClients.entries()).map(
async ([mapKey, clients]) => {
const clientId = mapKey; // mapKey is the clientId
if (excludeClientId && clientId === excludeClientId) {
return null;
}
// Add config version to message
const messageWithVersion = {
...message,
configVersion
};
let configVersion = await getClientConfigVersion(clientId);
if (options.incrementConfigVersion) {
configVersion =
await incrementClientConfigVersion(clientId);
}
if (options.compress) {
const compressed = zlib.gzipSync(
Buffer.from(JSON.stringify(messageWithVersion), "utf8")
);
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(compressed);
return {
clients,
messageWithVersion: {
...message,
configVersion
}
});
} else {
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(messageWithVersion));
}
});
};
}
)
);
for (const plan of sendPlans) {
if (!plan) {
continue;
}
if (options.compress) {
const compressed = zlib.gzipSync(
Buffer.from(JSON.stringify(plan.messageWithVersion), "utf8")
);
plan.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(compressed);
}
});
} else {
const messageString = JSON.stringify(plan.messageWithVersion);
plan.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(messageString);
}
});
}
}
};
@@ -602,28 +725,23 @@ const sendToClient = async (
);
// Try to send locally first
const localSent = await sendToClientLocal(clientId, message, options);
const localSent = await sendToClientLocal(
clientId,
message,
options,
configVersion
);
// Only send via Redis if the client is not connected locally and Redis is enabled
if (!localSent && redisManager.isRedisEnabled()) {
try {
const redisMessage: RedisMessage = {
type: "direct",
targetClientId: clientId,
message: {
...message,
configVersion
},
fromNodeId: NODE_ID
};
await redisManager.publish(
REDIS_CHANNEL,
JSON.stringify(redisMessage)
);
await enqueueRedisDirectMessage(clientId, {
...message,
configVersion
});
} catch (error) {
logger.error(
"Failed to send message via Redis, message may be lost:",
"Failed to queue batched direct message for Redis delivery, message may be lost:",
error
);
// Continue execution - local delivery already attempted
@@ -638,6 +756,95 @@ const sendToClient = async (
return localSent;
};
const sendToClientsBatch = async (
entries: BatchSendMessage[]
): Promise<void> => {
if (entries.length === 0) {
return;
}
const remoteEntries: { targetClientId: string; message: WSMessage }[] = [];
const clientsWithIncrement = new Set(
entries
.filter((entry) => !!entry.options?.incrementConfigVersion)
.map((entry) => entry.clientId)
);
const nonIncrementOnlyClientIds = Array.from(
new Set(
entries
.map((entry) => entry.clientId)
.filter((clientId) => !clientsWithIncrement.has(clientId))
)
);
const stableConfigVersionByClient = new Map<string, number | undefined>(
await Promise.all(
nonIncrementOnlyClientIds.map(
async (clientId) =>
[clientId, await getClientConfigVersion(clientId)] as const
)
)
);
for (const entry of entries) {
const options = entry.options || {};
const { clientId, message } = entry;
const configVersion = options.incrementConfigVersion
? await incrementClientConfigVersion(clientId)
: stableConfigVersionByClient.get(clientId);
logger.debug(
`sendToClientsBatch: Message type ${message.type} queued for clientId ${clientId} (new configVersion: ${configVersion})`
);
const localSent = await sendToClientLocal(
clientId,
message,
options,
configVersion
);
if (!localSent && redisManager.isRedisEnabled()) {
remoteEntries.push({
targetClientId: clientId,
message: {
...message,
configVersion
}
});
} else if (!localSent && !redisManager.isRedisEnabled()) {
logger.debug(
`Could not deliver batch message to ${clientId} - not connected locally and Redis unavailable`
);
}
}
if (!redisManager.isRedisEnabled() || remoteEntries.length === 0) {
return;
}
for (let i = 0; i < remoteEntries.length; i += REDIS_DIRECT_BATCH_SIZE) {
const messages = remoteEntries.slice(i, i + REDIS_DIRECT_BATCH_SIZE);
try {
const redisMessage: RedisMessage = {
type: "direct-batch",
messages,
fromNodeId: NODE_ID
};
await redisManager.publish(
REDIS_CHANNEL,
JSON.stringify(redisMessage)
);
} catch (error) {
logger.error(
"Failed to send explicit direct batch via Redis, messages may be lost:",
error
);
}
}
};
const broadcastToAllExcept = async (
message: WSMessage,
excludeClientId?: string,
@@ -1109,6 +1316,8 @@ const disconnectClient = async (clientId: string): Promise<boolean> => {
// Cleanup function for graceful shutdown
const cleanup = async (): Promise<void> => {
try {
await flushPendingRedisDirectMessages();
// Close all WebSocket connections
connectedClients.forEach((clients) => {
clients.forEach((client) => {
@@ -1139,6 +1348,7 @@ export {
router,
handleWSUpgrade,
sendToClient,
sendToClientsBatch,
broadcastToAllExcept,
connectedClients,
hasActiveConnections,
+117 -70
View File
@@ -1,5 +1,6 @@
import { assertEquals } from "@test/assert";
import { REGIONS } from "@server/db/regions";
import { isPathAllowed } from "@server/lib/pathMatch";
function isIpInRegion(
ipCountryCode: string | undefined,
@@ -33,76 +34,6 @@ function isIpInRegion(
return false;
}
function isPathAllowed(pattern: string, path: string): boolean {
// Normalize and split paths into segments
const normalize = (p: string) => p.split("/").filter(Boolean);
const patternParts = normalize(pattern);
const pathParts = normalize(path);
// Recursive function to try different wildcard matches
function matchSegments(patternIndex: number, pathIndex: number): boolean {
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
const currentPatternPart = patternParts[patternIndex];
const currentPathPart = pathParts[pathIndex];
// If we've consumed all pattern parts, we should have consumed all path parts
if (patternIndex >= patternParts.length) {
const result = pathIndex >= pathParts.length;
return result;
}
// If we've consumed all path parts but still have pattern parts
if (pathIndex >= pathParts.length) {
// The only way this can match is if all remaining pattern parts are wildcards
const remainingPattern = patternParts.slice(patternIndex);
const result = remainingPattern.every((p) => p === "*");
return result;
}
// For full segment wildcards, try consuming different numbers of path segments
if (currentPatternPart === "*") {
// Try consuming 0 segments (skip the wildcard)
if (matchSegments(patternIndex + 1, pathIndex)) {
return true;
}
// Try consuming current segment and recursively try rest
if (matchSegments(patternIndex, pathIndex + 1)) {
return true;
}
return false;
}
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
if (currentPatternPart.includes("*")) {
// Convert the pattern segment to a regex pattern
const regexPattern = currentPatternPart
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(currentPathPart)) {
return matchSegments(patternIndex + 1, pathIndex + 1);
}
return false;
}
// For regular segments, they must match exactly
if (currentPatternPart !== currentPathPart) {
return false;
}
// Move to next segments in both pattern and path
return matchSegments(patternIndex + 1, pathIndex + 1);
}
const result = matchSegments(0, 0);
return result;
}
function runTests() {
console.log("Running path matching tests...");
@@ -308,6 +239,121 @@ function runTests() {
console.log("All path matching tests passed!");
}
function runSpecialCharacterTests() {
console.log("\nRunning special character tests...");
let threw = false;
try {
isPathAllowed("(api*", "anything");
isPathAllowed("a(b*", "a(bc");
isPathAllowed("c[d*", "c[de");
isPathAllowed("x{2}*", "x{2}y");
isPathAllowed("a|b*", "a|bc");
isPathAllowed("back\\slash*", "back\\slashed");
} catch (e) {
threw = true;
console.error(
"Patterns accepted by isValidUrlGlobPattern crashed the matcher:",
e instanceof Error ? e.message : e
);
}
assertEquals(
threw,
false,
"Patterns with regex metacharacters must not throw"
);
assertEquals(
isPathAllowed("(api*", "(api-v1"),
true,
"Parenthesis should be treated as a literal character"
);
assertEquals(
isPathAllowed("(api*", "xapi-v1"),
false,
"Parenthesis should not match other characters"
);
assertEquals(
isPathAllowed("a(b)*", "a(b)c"),
true,
"Parentheses pair should be treated as literal characters"
);
assertEquals(
isPathAllowed("*.png", "image.png"),
true,
"Dot should match a literal dot"
);
assertEquals(
isPathAllowed("*.png", "imageXpng"),
false,
"Dot should not act as a regex wildcard"
);
assertEquals(
isPathAllowed("v1.0*", "v1.0.1"),
true,
"Version-like literal should match itself"
);
assertEquals(
isPathAllowed("v1.0*", "v1x0-beta"),
false,
"Version-like literal should not match arbitrary characters"
);
assertEquals(
isPathAllowed("a+b*", "a+bc"),
true,
"Plus should be treated as a literal character"
);
assertEquals(
isPathAllowed("a+b*", "aaabc"),
false,
"Plus should not act as a regex quantifier"
);
assertEquals(
isPathAllowed("$ref*", "$refs"),
true,
"Dollar sign should be treated as a literal character"
);
assertEquals(
isPathAllowed("price$*", "price$100"),
true,
"Dollar sign mid-pattern should be treated as a literal character"
);
assertEquals(
isPathAllowed("^start*", "^started"),
true,
"Caret should be treated as a literal character"
);
assertEquals(
isPathAllowed("a|b*", "a|bc"),
true,
"Pipe should be treated as a literal character"
);
assertEquals(
isPathAllowed("a|b*", "a"),
false,
"Pipe should not act as regex alternation"
);
assertEquals(
isPathAllowed("file?*", "fileX"),
true,
"Question mark should still act as a single-character wildcard"
);
assertEquals(
isPathAllowed("api/*", "api/" + "x/".repeat(50)),
true,
"Deeply nested paths should still match"
);
console.log("All special character tests passed!");
}
function runRegionTests() {
console.log("\nRunning isIpInRegion tests...");
@@ -367,6 +413,7 @@ function runRegionTests() {
// Run all tests
try {
runTests();
runSpecialCharacterTests();
runRegionTests();
console.log("\n✅ All tests passed!");
} catch (error) {
+2 -137
View File
@@ -25,6 +25,7 @@ import {
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
import { isPathAllowed } from "@server/lib/pathMatch";
import { response } from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
@@ -1090,143 +1091,7 @@ async function checkRules(
return;
}
export function isPathAllowed(pattern: string, path: string): boolean {
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
// Normalize and split paths into segments
const normalize = (p: string) => p.split("/").filter(Boolean);
const patternParts = normalize(pattern);
const pathParts = normalize(path);
logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`);
logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`);
// Maximum recursion depth to prevent stack overflow and memory issues
const MAX_RECURSION_DEPTH = 100;
// Recursive function to try different wildcard matches
function matchSegments(
patternIndex: number,
pathIndex: number,
depth: number = 0
): boolean {
// Check recursion depth limit
if (depth > MAX_RECURSION_DEPTH) {
logger.warn(
`Path matching exceeded maximum recursion depth (${MAX_RECURSION_DEPTH}) for pattern "${pattern}" and path "${path}"`
);
return false;
}
const indent = " ".repeat(depth); // Indent based on recursion depth
const currentPatternPart = patternParts[patternIndex];
const currentPathPart = pathParts[pathIndex];
logger.debug(
`${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"}) [depth=${depth}]`
);
// If we've consumed all pattern parts, we should have consumed all path parts
if (patternIndex >= patternParts.length) {
const result = pathIndex >= pathParts.length;
logger.debug(
`${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}`
);
return result;
}
// If we've consumed all path parts but still have pattern parts
if (pathIndex >= pathParts.length) {
// The only way this can match is if all remaining pattern parts are wildcards
const remainingPattern = patternParts.slice(patternIndex);
const result = remainingPattern.every((p) => p === "*");
logger.debug(
`${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}`
);
return result;
}
// For full segment wildcards, try consuming different numbers of path segments
if (currentPatternPart === "*") {
logger.debug(
`${indent}Found wildcard at pattern index ${patternIndex}`
);
// Try consuming 0 segments (skip the wildcard)
logger.debug(
`${indent}Trying to skip wildcard (consume 0 segments)`
);
if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) {
logger.debug(
`${indent}Successfully matched by skipping wildcard`
);
return true;
}
// Try consuming current segment and recursively try rest
logger.debug(
`${indent}Trying to consume segment "${currentPathPart}" for wildcard`
);
if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) {
logger.debug(
`${indent}Successfully matched by consuming segment for wildcard`
);
return true;
}
logger.debug(`${indent}Failed to match wildcard`);
return false;
}
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
if (currentPatternPart.includes("*")) {
logger.debug(
`${indent}Found in-segment wildcard in "${currentPatternPart}"`
);
// Convert the pattern segment to a regex pattern
const regexPattern = currentPatternPart
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(currentPathPart)) {
logger.debug(
`${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"`
);
return matchSegments(
patternIndex + 1,
pathIndex + 1,
depth + 1
);
}
logger.debug(
`${indent}Segment with wildcard mismatch: "${currentPatternPart}" doesn't match "${currentPathPart}"`
);
return false;
}
// For regular segments, they must match exactly
if (currentPatternPart !== currentPathPart) {
logger.debug(
`${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"`
);
return false;
}
logger.debug(
`${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"`
);
// Move to next segments in both pattern and path
return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1);
}
const result = matchSegments(0, 0, 0);
logger.debug(`Final result: ${result}`);
return result;
}
export { isPathAllowed };
async function isIpInGeoIP(
ipCountryCode: string | undefined,
+239 -2
View File
@@ -1,4 +1,4 @@
import { sendToClient } from "#dynamic/routers/ws";
import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws";
import { db, newts, olms } from "@server/db";
import {
Alias,
@@ -8,7 +8,7 @@ import {
} from "@server/lib/ip";
import { canCompress } from "@server/lib/clientVersionChecks";
import logger from "@server/logger";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import semver from "semver";
const NEWT_V2_TARGETS_VERSION = ">=1.10.3";
@@ -59,6 +59,42 @@ export async function addTargets(
);
}
export async function addTargetsBatch(
entries: {
newtId: string;
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[];
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolved = await Promise.all(
entries.map(async (entry) => ({
...entry,
targets: await convertTargetsIfNecessary(
entry.newtId,
entry.targets
)
}))
);
await sendToClientsBatch(
resolved.map((entry) => ({
clientId: entry.newtId,
message: {
type: `newt/wg/targets/add`,
data: entry.targets
},
options: {
incrementConfigVersion: true,
compress: canCompress(entry.version, "newt")
}
}))
);
}
export async function removeTargets(
newtId: string,
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
@@ -76,6 +112,42 @@ export async function removeTargets(
);
}
export async function removeTargetsBatch(
entries: {
newtId: string;
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[];
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolved = await Promise.all(
entries.map(async (entry) => ({
...entry,
targets: await convertTargetsIfNecessary(
entry.newtId,
entry.targets
)
}))
);
await sendToClientsBatch(
resolved.map((entry) => ({
clientId: entry.newtId,
message: {
type: `newt/wg/targets/remove`,
data: entry.targets
},
options: {
incrementConfigVersion: true,
compress: canCompress(entry.version, "newt")
}
}))
);
}
export async function updateTargets(
newtId: string,
targets: {
@@ -201,6 +273,171 @@ export async function removePeerData(
});
}
const resolveOlmTargets = async (
entries: {
clientId: number;
olmId?: string;
version?: string | null;
}[]
) => {
const unresolvedClientIds = entries
.filter((entry) => !entry.olmId)
.map((entry) => entry.clientId);
const olmMap = new Map<number, { olmId: string; version: string | null }>();
if (unresolvedClientIds.length > 0) {
const olmRows = await db
.select({
clientId: olms.clientId,
olmId: olms.olmId,
version: olms.version
})
.from(olms)
.where(inArray(olms.clientId, unresolvedClientIds));
for (const row of olmRows) {
if (row.clientId !== null) {
olmMap.set(row.clientId, {
olmId: row.olmId,
version: row.version
});
}
}
}
return entries
.map((entry) => {
if (entry.olmId) {
return {
clientId: entry.clientId,
olmId: entry.olmId,
version: entry.version
};
}
const resolved = olmMap.get(entry.clientId);
if (!resolved) {
return null;
}
return {
clientId: entry.clientId,
olmId: resolved.olmId,
version: entry.version ?? resolved.version
};
})
.filter((entry) => entry !== null);
};
export async function addPeerDataBatch(
entries: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: Alias[];
olmId?: string;
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolvedTargets = await resolveOlmTargets(entries);
if (resolvedTargets.length === 0) {
return;
}
const payloads = entries
.map((entry) => {
const resolved = resolvedTargets.find(
(target) => target.clientId === entry.clientId
);
if (!resolved) {
return null;
}
return {
clientId: resolved.olmId,
message: {
type: `olm/wg/peer/data/add`,
data: {
siteId: entry.siteId,
remoteSubnets: entry.remoteSubnets,
aliases: entry.aliases
}
},
options: {
incrementConfigVersion: true,
compress: canCompress(resolved.version, "olm")
}
};
})
.filter((entry) => entry !== null);
if (payloads.length === 0) {
return;
}
await sendToClientsBatch(payloads);
}
export async function removePeerDataBatch(
entries: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: Alias[];
olmId?: string;
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolvedTargets = await resolveOlmTargets(entries);
if (resolvedTargets.length === 0) {
return;
}
const payloads = entries
.map((entry) => {
const resolved = resolvedTargets.find(
(target) => target.clientId === entry.clientId
);
if (!resolved) {
return null;
}
return {
clientId: resolved.olmId,
message: {
type: `olm/wg/peer/data/remove`,
data: {
siteId: entry.siteId,
remoteSubnets: entry.remoteSubnets,
aliases: entry.aliases
}
},
options: {
incrementConfigVersion: true,
compress: canCompress(resolved.version, "olm")
}
};
})
.filter((entry) => entry !== null);
if (payloads.length === 0) {
return;
}
await sendToClientsBatch(payloads);
}
export async function updatePeerData(
clientId: number,
siteId: number,
+1 -1
View File
@@ -10,7 +10,7 @@ import { verifyPassword } from "@server/auth/password";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger";
import cache from "#dynamic/lib/cache";
import { regionalCache as cache } from "#dynamic/lib/cache";
import config from "@server/lib/config";
// Stale-while-revalidate in-memory fallback for the releases API.
@@ -54,7 +54,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
// TODO: somehow we should make sure a recent hole punch has happened if this occurs (hole punch could be from the last restart if done quickly)
}
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 12) {
logger.warn(
`Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the site reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
);
+1 -1
View File
@@ -2,7 +2,7 @@ import { MessageHandler } from "@server/routers/ws";
import logger from "@server/logger";
import { Newt } from "@server/db";
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
import cache from "#dynamic/lib/cache";
import cache from "#dynamic/lib/cache"; // not using regional here because we dont know where the site is
export const handleDockerStatusMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context;
+59 -18
View File
@@ -1,7 +1,7 @@
import { db, Site } from "@server/db";
import { newts, sites } from "@server/db";
import { eq } from "drizzle-orm";
import { sendToClient } from "#dynamic/routers/ws";
import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws";
import logger from "@server/logger";
export async function addPeer(
@@ -36,10 +36,14 @@ export async function addPeer(
newtId = newt.newtId;
}
await sendToClient(newtId, {
type: "newt/wg/peer/add",
data: peer
}, { incrementConfigVersion: true }).catch((error) => {
await sendToClient(
newtId,
{
type: "newt/wg/peer/add",
data: peer
},
{ incrementConfigVersion: true }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
@@ -76,12 +80,16 @@ export async function deletePeer(
newtId = newt.newtId;
}
await sendToClient(newtId, {
type: "newt/wg/peer/remove",
data: {
publicKey
}
}, { incrementConfigVersion: true }).catch((error) => {
await sendToClient(
newtId,
{
type: "newt/wg/peer/remove",
data: {
publicKey
}
},
{ incrementConfigVersion: true }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
@@ -90,6 +98,35 @@ export async function deletePeer(
return site;
}
export async function deletePeersBatch(
peers: {
siteId: number;
publicKey: string;
newtId: string;
}[]
) {
if (peers.length === 0) {
return;
}
await sendToClientsBatch(
peers.map((peer) => ({
clientId: peer.newtId,
message: {
type: "newt/wg/peer/remove",
data: {
publicKey: peer.publicKey
}
},
options: { incrementConfigVersion: true }
}))
).catch((error) => {
logger.warn(`Error sending batched newt peer removals:`, error);
});
logger.info(`Deleted ${peers.length} peer(s) from newts (batch)`);
}
export async function updatePeer(
siteId: number,
publicKey: string,
@@ -122,13 +159,17 @@ export async function updatePeer(
newtId = newt.newtId;
}
await sendToClient(newtId, {
type: "newt/wg/peer/update",
data: {
publicKey,
...peer
}
}, { incrementConfigVersion: true }).catch((error) => {
await sendToClient(
newtId,
{
type: "newt/wg/peer/update",
data: {
publicKey,
...peer
}
},
{ incrementConfigVersion: true }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
@@ -20,7 +20,7 @@ import { handleFingerprintInsertion } from "./fingerprintingUtils";
import { build } from "@server/build";
import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
import cache from "#dynamic/lib/cache";
import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;
@@ -348,7 +348,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
// this prevents us from accepting a register from an olm that has not hole punched yet.
// the olm will pump the register so we can keep checking
// TODO: I still think there is a better way to do this rather than locking it out here but ???
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
if (now - (client.lastHolePunch || 0) > 12 && sitesCount > 0) {
logger.warn(
`[handleOlmRegisterMessage] Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`,
{ orgId: client.orgId, clientId: client.clientId }
+149 -2
View File
@@ -1,9 +1,9 @@
import { sendToClient } from "#dynamic/routers/ws";
import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws";
import { clientSitesAssociationsCache, db, olms } from "@server/db";
import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
import logger from "@server/logger";
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { Alias } from "yaml";
export async function addPeer(
@@ -205,3 +205,150 @@ export async function initPeerAddHandshake(
`Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}`
);
}
export async function deletePeersBatch(
peers: {
clientId: number;
siteId: number;
publicKey: string;
olmId?: string;
version?: string | null;
}[]
) {
if (peers.length === 0) {
return;
}
const unresolvedClientIds = peers
.filter((peer) => !peer.olmId)
.map((peer) => peer.clientId);
const olmByClientId = new Map<
number,
{ olmId: string; version: string | null }
>();
if (unresolvedClientIds.length > 0) {
const olmRows = await db
.select({
clientId: olms.clientId,
olmId: olms.olmId,
version: olms.version
})
.from(olms)
.where(inArray(olms.clientId, unresolvedClientIds));
for (const row of olmRows) {
if (row.clientId !== null) {
olmByClientId.set(row.clientId, {
olmId: row.olmId,
version: row.version
});
}
}
}
const batchPayloads = peers
.map((peer) => {
const resolved = peer.olmId
? { olmId: peer.olmId, version: peer.version ?? null }
: olmByClientId.get(peer.clientId);
if (!resolved) {
return null;
}
return {
clientId: resolved.olmId,
message: {
type: "olm/wg/peer/remove",
data: {
publicKey: peer.publicKey,
siteId: peer.siteId
}
},
options: {
incrementConfigVersion: true,
compress: canCompress(
peer.version ?? resolved.version,
"olm"
)
}
};
})
.filter((payload) => payload !== null);
if (batchPayloads.length === 0) {
return;
}
await sendToClientsBatch(batchPayloads).catch((error) => {
logger.warn(`Error sending batched olm peer removals:`, error);
});
logger.info(`Deleted ${batchPayloads.length} peer(s) from olms (batch)`);
}
export async function initPeerAddHandshakeBatch(
handshakes: {
clientId: number;
peer: {
siteId: number;
exitNode: {
publicKey: string;
endpoint: string;
};
};
olmId: string;
chainId?: string;
}[]
) {
if (handshakes.length === 0) {
return;
}
await sendToClientsBatch(
handshakes.map((item) => ({
clientId: item.olmId,
message: {
type: "olm/wg/peer/holepunch/site/add",
data: {
siteId: item.peer.siteId,
exitNode: {
publicKey: item.peer.exitNode.publicKey,
relayPort:
config.getRawConfig().gerbil.clients_start_port,
endpoint: item.peer.exitNode.endpoint
},
chainId: item.chainId
}
},
options: { incrementConfigVersion: true }
}))
).catch((error) => {
logger.warn(`Error sending batched olm handshakes:`, error);
});
await Promise.all(
handshakes.map((item) =>
db
.update(clientSitesAssociationsCache)
.set({ isJitMode: false })
.where(
and(
eq(
clientSitesAssociationsCache.clientId,
item.clientId
),
eq(
clientSitesAssociationsCache.siteId,
item.peer.siteId
)
)
)
)
);
logger.info(
`Initiated ${handshakes.length} peer add handshake(s) to olms (batch)`
);
}
@@ -15,8 +15,7 @@ import logger from "@server/logger";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
import type { PaginatedResponse } from "@server/types/Pagination";
import { OpenAPITags, registry } from "@server/openApi";
import { localCache } from "#dynamic/lib/cache";
import { regionalCache as cache } from "#dynamic/lib/cache";
const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60;
@@ -153,7 +152,7 @@ export async function listUserResourceAliases(
pageSize
);
const cachedData: ListUserResourceAliasesResponse | undefined =
localCache.get(cacheKey);
await cache.get(cacheKey);
if (cachedData) {
return response<ListUserResourceAliasesResponse>(res, {
@@ -211,7 +210,11 @@ export async function listUserResourceAliases(
page
}
};
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
await cache.set(
cacheKey,
data,
USER_RESOURCE_ALIASES_CACHE_TTL_SEC
);
return response<ListUserResourceAliasesResponse>(res, {
data,
success: true,
@@ -256,7 +259,7 @@ export async function listUserResourceAliases(
page
}
};
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
await cache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
return response<ListUserResourceAliasesResponse>(res, {
data,
+1 -5
View File
@@ -3,7 +3,6 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import config from "@server/lib/config";
import { build } from "@server/build";
import { APP_VERSION } from "@server/lib/consts";
import license from "#dynamic/license/license";
@@ -22,9 +21,6 @@ export async function getServerInfo(
next: NextFunction
): Promise<any> {
try {
const supporterData = config.getSupporterData();
const supporterStatusValid = supporterData?.valid || false;
let enterpriseLicenseValid = false;
let enterpriseLicenseType: string | null = null;
@@ -41,7 +37,7 @@ export async function getServerInfo(
return sendResponse<GetServerInfoResponse>(res, {
data: {
version: APP_VERSION,
supporterStatusValid,
supporterStatusValid: true,
build,
enterpriseLicenseValid,
enterpriseLicenseType
+1 -1
View File
@@ -14,7 +14,7 @@ import {
siteLabels,
type Label
} from "@server/db";
import cache from "#dynamic/lib/cache";
import { regionalCache as cache } from "#dynamic/lib/cache";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -28,7 +28,10 @@ import {
isIpInCidr,
portRangeStringSchema
} from "@server/lib/ip";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
getClientSiteResourceAccess,
rebuildClientAssociationsFromSiteResource
} from "@server/lib/rebuildClientAssociations";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
@@ -846,9 +849,14 @@ export async function handleMessagingForUpdatedSiteResource(
updatedSiteResource
);
const { mergedAllClients } =
await rebuildClientAssociationsFromSiteResource(
existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below
await rebuildClientAssociationsFromSiteResource(
existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below
trx
);
const { sitesList, mergedAllClients, mergedAllClientIds } =
await getClientSiteResourceAccess(
existingSiteResource || updatedSiteResource,
trx
);
@@ -17,13 +17,18 @@ export async function traefikConfigProvider(
// Get the current exit node name from config
const currentExitNodeId = await getCurrentExitNodeId();
const maintenancePort = config.getRawConfig().server.next_port;
const maintenanceHost = config.getRawConfig().server.internal_hostname;
const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`;
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,
config.getRawConfig().traefik.allow_raw_resources,
build != "oss" // generate browser gateway resources on cloud and enterprise
pangolinUIUrl,
pangolinUIUrl
);
if (traefikConfig?.http?.middlewares) {
+26 -6
View File
@@ -76,12 +76,32 @@ export interface SendMessageOptions {
compress?: boolean;
}
// Redis message type for cross-node communication
export interface RedisMessage {
type: "direct" | "broadcast";
targetClientId?: string;
excludeClientId?: string;
export interface BatchSendMessage {
clientId: string;
message: WSMessage;
fromNodeId: string;
options?: SendMessageOptions;
}
// Redis message types for cross-node communication
export type RedisMessage =
| {
type: "direct";
targetClientId: string;
message: WSMessage;
fromNodeId: string;
}
| {
type: "direct-batch";
messages: {
targetClientId: string;
message: WSMessage;
}[];
fromNodeId: string;
}
| {
type: "broadcast";
excludeClientId?: string;
message: WSMessage;
fromNodeId: string;
options?: SendMessageOptions;
};
+17 -1
View File
@@ -26,7 +26,8 @@ import {
WebSocketRequest,
WSMessage,
AuthenticatedWebSocket,
SendMessageOptions
SendMessageOptions,
BatchSendMessage
} from "./types";
import { validateSessionToken } from "@server/auth/sessions/app";
@@ -212,6 +213,20 @@ const sendToClient = async (
return localSent;
};
const sendToClientsBatch = async (
entries: BatchSendMessage[]
): Promise<void> => {
if (entries.length === 0) {
return;
}
await Promise.all(
entries.map((entry) =>
sendToClient(entry.clientId, entry.message, entry.options)
)
);
};
const broadcastToAllExcept = async (
message: WSMessage,
excludeClientId?: string,
@@ -552,6 +567,7 @@ export {
router,
handleWSUpgrade,
sendToClient,
sendToClientsBatch,
broadcastToAllExcept,
connectedClients,
hasActiveConnections,
+11 -6
View File
@@ -42,7 +42,14 @@ import {
SettingsSectionFooter
} from "@app/components/Settings";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ArrowRight, Check, ExternalLink, Heart, InfoIcon, TicketCheck } from "lucide-react";
import {
ArrowRight,
Check,
ExternalLink,
Heart,
InfoIcon,
TicketCheck
} from "lucide-react";
import Link from "next/link";
import DismissableBanner from "@app/components/DismissableBanner";
import CopyTextBox from "@app/components/CopyTextBox";
@@ -50,7 +57,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { SitePriceCalculator } from "@app/components/SitePriceCalculator";
import { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
// import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useTranslations } from "next-intl";
const ENTERPRISE_DOCS_URL =
@@ -82,7 +89,7 @@ export default function LicensePage() {
const [isActivatingLicense, setIsActivatingLicense] = useState(false);
const [isDeletingLicense, setIsDeletingLicense] = useState(false);
const [isRecheckingLicense, setIsRecheckingLicense] = useState(false);
const { supporterStatus } = useSupporterStatusContext();
// const { supporterStatus } = useSupporterStatusContext();
const t = useTranslations();
@@ -347,9 +354,7 @@ export default function LicensePage() {
storageKey="license-banner-dismissed"
version={1}
title={t("licenseBannerTitle")}
titleIcon={
<TicketCheck className="w-5 h-5 text-primary" />
}
titleIcon={<TicketCheck className="w-5 h-5 text-primary" />}
description={t("licenseBannerDescription")}
>
<Link
+19 -19
View File
@@ -68,15 +68,15 @@ export default async function RootLayout({
const env = pullEnv();
const locale = await getLocale();
const supporterData = {
visible: true
} as any;
// const supporterData = {
// visible: true
// } as any;
const res = await priv.get<AxiosResponse<IsSupporterKeyVisibleResponse>>(
"supporter-key/visible"
);
supporterData.visible = res.data.data.visible;
supporterData.tier = res.data.data.tier;
// const res = await priv.get<AxiosResponse<IsSupporterKeyVisibleResponse>>(
// "supporter-key/visible"
// );
// supporterData.visible = res.data.data.visible;
// supporterData.tier = res.data.data.tier;
let licenseStatus: GetLicenseStatusResponse;
if (build === "enterprise") {
@@ -127,20 +127,20 @@ export default async function RootLayout({
<LicenseStatusProvider
licenseStatus={licenseStatus}
>
<SupportStatusProvider
{/* <SupportStatusProvider
supporterStatus={supporterData}
>
{/* Main content */}
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
<SplashImage>
<LicenseViolation />
{children}
</SplashImage>
> */}
{/* Main content */}
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
<SplashImage>
<LicenseViolation />
</div>
{children}
</SplashImage>
<LicenseViolation />
</div>
</SupportStatusProvider>
</div>
{/* </SupportStatusProvider> */}
</LicenseStatusProvider>
<Toaster />
</TanstackQueryProvider>
+1 -1
View File
@@ -28,7 +28,7 @@ export default async function MaintenanceScreen() {
try {
const headersList = await headers();
const host = headersList.get("host") || "";
const host = headersList.get("p-host") || headersList.get("host") || "";
const hostname = host.split(":")[0];
const res = await priv.get<AxiosResponse<GetMaintenanceInfoResponse>>(
+4 -4
View File
@@ -1,24 +1,24 @@
"use client";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
// import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
export default function AuthPageFooterNotices() {
const t = useTranslations();
const { supporterStatus } = useSupporterStatusContext();
// const { supporterStatus } = useSupporterStatusContext();
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
return (
<>
{supporterStatus?.visible && (
{/* {supporterStatus?.visible && (
<div className="text-center mt-2">
<span className="text-sm text-muted-foreground opacity-50">
{t("noSupportKey")}
</span>
</div>
)}
)} */}
{build === "enterprise" && !isUnlocked() ? (
<div className="text-center mt-2">
<span className="text-sm font-medium text-muted-foreground">
+28 -27
View File
@@ -9,33 +9,34 @@ export default function SupporterMessage({ tier }: { tier: string }) {
const t = useTranslations();
return (
<div className="relative flex items-center space-x-2 whitespace-nowrap group">
<span
className="cursor-pointer"
onClick={(e) => {
// Get the bounding box of the element
const rect = (
e.target as HTMLElement
).getBoundingClientRect();
<></>
// <div className="relative flex items-center space-x-2 whitespace-nowrap group">
// <span
// className="cursor-pointer"
// onClick={(e) => {
// // Get the bounding box of the element
// const rect = (
// e.target as HTMLElement
// ).getBoundingClientRect();
// Trigger confetti centered on the word "Pangolin"
confetti({
particleCount: 100,
spread: 70,
origin: {
x: (rect.left + rect.width / 2) / window.innerWidth,
y: rect.top / window.innerHeight
},
colors: ["#FFA500", "#FF4500", "#FFD700"]
});
}}
>
Pangolin
</span>
<Star className="w-3 h-3" />
<div className="absolute left-1/2 transform -translate-x-1/2 -top-10 hidden group-hover:block text-primary text-sm rounded-md border shadow-md px-4 py-2 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
{t("componentsSupporterMessage", { tier: tier })}
</div>
</div>
// // Trigger confetti centered on the word "Pangolin"
// confetti({
// particleCount: 100,
// spread: 70,
// origin: {
// x: (rect.left + rect.width / 2) / window.innerWidth,
// y: rect.top / window.innerHeight
// },
// colors: ["#FFA500", "#FF4500", "#FFD700"]
// });
// }}
// >
// Pangolin
// </span>
// <Star className="w-3 h-3" />
// <div className="absolute left-1/2 transform -translate-x-1/2 -top-10 hidden group-hover:block text-primary text-sm rounded-md border shadow-md px-4 py-2 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
// {t("componentsSupporterMessage", { tier: tier })}
// </div>
// </div>
);
}
+110 -110
View File
@@ -3,7 +3,7 @@
// THIS IS DEPRECATED AND IS NO LONGER SHOWED TO THE USER WITH THE DISCONTINUATION
// OF THE SUPPORTER PROGRAM. IT MAY BE REMOVED IN A FUTURE UPDATE.
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
// import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useState, useTransition } from "react";
import {
Tooltip,
@@ -58,134 +58,134 @@ interface SupporterStatusProps {
export default function SupporterStatus({
isCollapsed = false
}: SupporterStatusProps) {
const { supporterStatus, updateSupporterStatus } =
useSupporterStatusContext();
const [supportOpen, setSupportOpen] = useState(false);
const [keyOpen, setKeyOpen] = useState(false);
const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
// const { supporterStatus, updateSupporterStatus } =
// useSupporterStatusContext();
// const [supportOpen, setSupportOpen] = useState(false);
// const [keyOpen, setKeyOpen] = useState(false);
// const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
// const { env } = useEnvContext();
// const api = createApiClient({ env });
// const t = useTranslations();
const formSchema = z.object({
githubUsername: z.string().nonempty({
error: "GitHub username is required"
}),
key: z.string().nonempty({
error: "Supporter key is required"
})
});
// const formSchema = z.object({
// githubUsername: z.string().nonempty({
// error: "GitHub username is required"
// }),
// key: z.string().nonempty({
// error: "Supporter key is required"
// })
// });
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
githubUsername: "",
key: ""
}
});
// const form = useForm({
// resolver: zodResolver(formSchema),
// defaultValues: {
// githubUsername: "",
// key: ""
// }
// });
async function hide() {
await api.post("/supporter-key/hide");
// async function hide() {
// await api.post("/supporter-key/hide");
updateSupporterStatus({
visible: false
});
}
// updateSupporterStatus({
// visible: false
// });
// }
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
const res = await api.post<
AxiosResponse<ValidateSupporterKeyResponse>
>("/supporter-key/validate", {
githubUsername: values.githubUsername,
key: values.key
});
// async function onSubmit(values: z.infer<typeof formSchema>) {
// try {
// const res = await api.post<
// AxiosResponse<ValidateSupporterKeyResponse>
// >("/supporter-key/validate", {
// githubUsername: values.githubUsername,
// key: values.key
// });
const data = res.data.data;
// const data = res.data.data;
if (!data || !data.valid) {
toast({
variant: "destructive",
title: t("supportKeyInvalid"),
description: t("supportKeyInvalidDescription")
});
return;
}
// if (!data || !data.valid) {
// toast({
// variant: "destructive",
// title: t("supportKeyInvalid"),
// description: t("supportKeyInvalidDescription")
// });
// return;
// }
// Trigger the toast
toast({
variant: "default",
title: t("supportKeyValid"),
description: t("supportKeyValidDescription")
});
// // Trigger the toast
// toast({
// variant: "default",
// title: t("supportKeyValid"),
// description: t("supportKeyValidDescription")
// });
// Fireworks-style confetti
const duration = 5 * 1000; // 5 seconds
const animationEnd = Date.now() + duration;
const defaults = {
startVelocity: 30,
spread: 360,
ticks: 60,
zIndex: 0,
colors: ["#FFA500", "#FF4500", "#FFD700"] // Orange hues
};
// // Fireworks-style confetti
// const duration = 5 * 1000; // 5 seconds
// const animationEnd = Date.now() + duration;
// const defaults = {
// startVelocity: 30,
// spread: 360,
// ticks: 60,
// zIndex: 0,
// colors: ["#FFA500", "#FF4500", "#FFD700"] // Orange hues
// };
function randomInRange(min: number, max: number) {
return Math.random() * (max - min) + min;
}
// function randomInRange(min: number, max: number) {
// return Math.random() * (max - min) + min;
// }
const interval = setInterval(() => {
const timeLeft = animationEnd - Date.now();
// const interval = setInterval(() => {
// const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
clearInterval(interval);
return;
}
// if (timeLeft <= 0) {
// clearInterval(interval);
// return;
// }
const particleCount = 50 * (timeLeft / duration);
// const particleCount = 50 * (timeLeft / duration);
// Launch confetti from two random horizontal positions
confetti({
...defaults,
particleCount,
origin: {
x: randomInRange(0.1, 0.3),
y: Math.random() - 0.2
}
});
confetti({
...defaults,
particleCount,
origin: {
x: randomInRange(0.7, 0.9),
y: Math.random() - 0.2
}
});
}, 250);
// // Launch confetti from two random horizontal positions
// confetti({
// ...defaults,
// particleCount,
// origin: {
// x: randomInRange(0.1, 0.3),
// y: Math.random() - 0.2
// }
// });
// confetti({
// ...defaults,
// particleCount,
// origin: {
// x: randomInRange(0.7, 0.9),
// y: Math.random() - 0.2
// }
// });
// }, 250);
setPurchaseOptionsOpen(false);
setKeyOpen(false);
// setPurchaseOptionsOpen(false);
// setKeyOpen(false);
updateSupporterStatus({
visible: false
});
} catch (error) {
toast({
variant: "destructive",
title: t("error"),
description: formatAxiosError(
error,
t("supportKeyErrorValidationDescription")
)
});
return;
}
}
// updateSupporterStatus({
// visible: false
// });
// } catch (error) {
// toast({
// variant: "destructive",
// title: t("error"),
// description: formatAxiosError(
// error,
// t("supportKeyErrorValidationDescription")
// )
// });
// return;
// }
// }
return (
<>
<Credenza
{/* <Credenza
open={purchaseOptionsOpen}
onOpenChange={(val) => {
setPurchaseOptionsOpen(val);
@@ -469,7 +469,7 @@ export default function SupporterStatus({
{t("supportKeyBuy")}
</Button>
)
) : null}
) : null} */}
</>
);
}
-1
View File
@@ -139,7 +139,6 @@ Restart=always
RestartSec=2
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
[Install]
@@ -83,9 +83,19 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
{ message: t("rulesErrorInvalidCountryDescription") }
);
case "ASN":
return required.refine((value) => /^AS\d+$/i.test(value.trim()), {
message: t("rulesErrorInvalidAsnDescription")
});
return required.refine(
(value) => {
const normalizedValue = value.trim().toUpperCase();
return (
/^AS\d+$/.test(normalizedValue) ||
normalizedValue === "ALL" ||
normalizedValue === "AS0"
);
},
{
message: t("rulesErrorInvalidAsnDescription")
}
);
default:
return required;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { cache } from "react";
export const getBrowserTargetForRequest = cache(async () => {
const headersList = await headers();
const host = headersList.get("host") || "";
const host = headersList.get("p-host") || headersList.get("host") || "";
const hostname = host.split(":")[0];
try {