Compare commits

...

208 Commits

Author SHA1 Message Date
Owen
52a90fbd2b Add regional redis cache 2026-05-05 14:23:59 -07:00
miloschwartz
2ecf076c0f don't await second calculate func 2026-05-05 12:37:52 -07:00
miloschwartz
e06dda27cb dont wait rebuild 2026-05-05 12:10:55 -07:00
miloschwartz
18f6e0f75d add subscribed check back 2026-05-05 11:52:31 -07:00
miloschwartz
3b232bcc58 set orgId to undefined 2026-05-05 11:31:58 -07:00
Owen
c575bb76e7 Fix only using acme.json in dir
Ref #2978
2026-05-05 11:11:43 -07:00
Owen
c8e7e0ee1e WAL off default ENABLE_SQLITE_WAL_MODE to enable 2026-05-04 17:54:28 -07:00
Owen Schwartz
0e7aafd364 Merge pull request #2998 from Josh-Voyles/mem-fix-2
fix: deterministically finalize SQLite prepared statements to prevent native memory leak (#2120)
2026-05-04 17:29:45 -07:00
miloschwartz
91f1bae3e9 fix alignement in info sections 2026-05-04 14:51:17 -07:00
miloschwartz
53c138ce3e use consistent button spacing 2026-05-04 14:34:32 -07:00
miloschwartz
969db14a3c remove delay in oidc validate 2026-05-04 13:14:35 -07:00
Owen Schwartz
c1c387bdd8 Merge pull request #2996 from fosrl/crowdin_dev
New Crowdin updates
2026-05-04 11:48:58 -07:00
Owen Schwartz
6e83d77a87 New translations en-us.json (Spanish)
[ci skip]
2026-05-04 11:48:00 -07:00
Owen Schwartz
ba9a1efa4c New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-04 11:47:58 -07:00
Owen Schwartz
9e046b9608 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-04 11:47:56 -07:00
Owen Schwartz
37794eb299 New translations en-us.json (Turkish)
[ci skip]
2026-05-04 11:47:55 -07:00
Owen Schwartz
4e66b0e74b New translations en-us.json (Russian)
[ci skip]
2026-05-04 11:47:53 -07:00
Owen Schwartz
44fa873977 New translations en-us.json (Portuguese)
[ci skip]
2026-05-04 11:47:51 -07:00
Owen Schwartz
505461a533 New translations en-us.json (Polish)
[ci skip]
2026-05-04 11:47:49 -07:00
Owen Schwartz
a88c5b1428 New translations en-us.json (Dutch)
[ci skip]
2026-05-04 11:47:47 -07:00
Owen Schwartz
97ef1d605c New translations en-us.json (Korean)
[ci skip]
2026-05-04 11:47:45 -07:00
Owen Schwartz
3fc1c9d948 New translations en-us.json (Italian)
[ci skip]
2026-05-04 11:47:44 -07:00
Owen Schwartz
68bd37ab6c New translations en-us.json (German)
[ci skip]
2026-05-04 11:47:42 -07:00
Owen Schwartz
5c317c535b New translations en-us.json (Czech)
[ci skip]
2026-05-04 11:47:40 -07:00
Owen Schwartz
37c6b11899 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-04 11:47:38 -07:00
Owen Schwartz
45c567ffa0 New translations en-us.json (French)
[ci skip]
2026-05-04 11:47:36 -07:00
Owen Schwartz
775ea64b55 Merge pull request #2977 from fosrl/newt-install-commands
fix(newt): update Helm install credentials and client flag handling
2026-05-04 11:40:19 -07:00
Owen
64ad7641af Add migration
Fixes #2968
Fixes #2990
2026-05-04 11:35:07 -07:00
Owen
d724f5bb5d Add missing redirects and threshold to api
Fixes #2987
2026-05-04 10:46:11 -07:00
Owen
d4f7c4a9c4 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-05-03 14:46:59 -07:00
miloschwartz
1cc0e9b689 consolidate org idps in login form 2026-05-03 14:46:48 -07:00
Owen
584be4dbd2 Add badge 2026-05-03 14:45:42 -07:00
Owen
c33e295ce7 Add a banner showing that you are on a trial 2026-05-03 14:42:43 -07:00
Owen
1a926a7127 Handle trial limit lifecycle 2026-05-03 14:31:05 -07:00
miloschwartz
eb515a8f7f consolidate orgidps in import list 2026-05-03 14:16:36 -07:00
Owen
81b8a8a9e3 Fix ns cert generation 2026-05-03 12:29:48 -07:00
Owen
bcd164219f Try to speed up 2026-05-03 12:29:48 -07:00
Owen Schwartz
c90e405105 Merge pull request #2843 from Blacks-Army/dev
Exclude local/private/CGNAT IPs from geo-block rules (fixes  issue #2239)
2026-05-03 11:19:36 -07:00
Mustafa
b2c8311b26 Merge branch 'fosrl:dev' into dev 2026-05-03 18:53:48 +02:00
Josh Voyles
2154811ffb removed possible introduced HA Redis bug; improved comment 2026-05-03 09:39:27 -04:00
Marc Schäfer
1772ac220f fix(newt): update Helm install credentials and client flag handling
Use a Kubernetes Secret for Newt Helm chart credentials and configure the chart
with auth.existingSecretName instead of passing credential values through
auth.keys.*.

Add Helm-specific acceptClients handling so the generated Kubernetes command sets
newtInstances[0].acceptClients=true when client connections are enabled.
2026-05-03 15:07:42 +02:00
Josh Voyles
9bd33072f4 cleaned comments - more concise 2026-05-03 00:00:11 -04:00
Owen Schwartz
70f619b726 Merge pull request #2970 from fosrl/crowdin_dev
New Crowdin updates
2026-05-02 20:59:16 -07:00
Owen Schwartz
7743e3890b New translations en-us.json (Spanish)
[ci skip]
2026-05-02 20:57:57 -07:00
Owen Schwartz
d8df250555 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-02 20:57:55 -07:00
Owen Schwartz
45c9f217c6 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-02 20:57:54 -07:00
Owen Schwartz
8371692cc5 New translations en-us.json (Turkish)
[ci skip]
2026-05-02 20:57:52 -07:00
Owen Schwartz
5377dc7a1c New translations en-us.json (Russian)
[ci skip]
2026-05-02 20:57:51 -07:00
Owen Schwartz
02649468e0 New translations en-us.json (Portuguese)
[ci skip]
2026-05-02 20:57:49 -07:00
Owen Schwartz
c5ef00fb0e New translations en-us.json (Polish)
[ci skip]
2026-05-02 20:57:48 -07:00
Owen Schwartz
6f4325e9a0 New translations en-us.json (Dutch)
[ci skip]
2026-05-02 20:57:45 -07:00
Owen Schwartz
a2a031dfe7 New translations en-us.json (Korean)
[ci skip]
2026-05-02 20:57:44 -07:00
Owen Schwartz
e34a4c82eb New translations en-us.json (Italian)
[ci skip]
2026-05-02 20:57:42 -07:00
Owen Schwartz
52fd7df727 New translations en-us.json (German)
[ci skip]
2026-05-02 20:57:41 -07:00
Owen Schwartz
d5f08437d7 New translations en-us.json (Czech)
[ci skip]
2026-05-02 20:57:39 -07:00
Owen Schwartz
9ee07ba343 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-02 20:57:38 -07:00
Owen Schwartz
4baaa5fc14 New translations en-us.json (French)
[ci skip]
2026-05-02 20:57:36 -07:00
Owen
61de100630 Fix imports 2026-05-02 20:46:52 -07:00
miloschwartz
3694f43ae8 dont early return on multi org 2026-05-02 20:38:14 -07:00
Owen
279211142d Bump version 2026-05-02 13:48:25 -07:00
Owen
b8822b4d25 Fix CE not processing alert status
Fixes #2968
2026-05-02 13:38:05 -07:00
Josh Voyles
0655ba9423 fix: revert investigative changes, keep root cause fixes only
Reverts diagnostic instrumentation and defensive hardening added during
memory leak investigation. Only root cause fixes survive.

Root causes fixed:
- SQLite driver: auto-finalize wrapper + PRAGMAs
- WS routers: delete clientConfigVersions on disconnect (unbounded Map leak)
- WS private router: same + Redis key cleanup

Reverted:
- Memory monitor, rate limiting, request timeouts (diagnostic/hardening)
- shutdownAuditLogger wiring, audit re-queue change, debug logs (cleanup/secondary)
- package-lock.json drift
2026-05-02 16:33:13 -04:00
Owen
e1afbc226c Allow configuring the webhook body 2026-05-02 13:26:54 -07:00
miloschwartz
96c450fd08 update private resources screenshot 2026-05-02 13:19:00 -07:00
Owen Schwartz
587e4d104b New translations en-us.json (Spanish)
[ci skip]
2026-05-02 13:16:28 -07:00
Owen Schwartz
368c5c374f New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-02 13:16:26 -07:00
Owen Schwartz
7675b6409c New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-02 13:16:24 -07:00
Owen Schwartz
d31da1a41e New translations en-us.json (Turkish)
[ci skip]
2026-05-02 13:16:23 -07:00
Owen Schwartz
49e259e259 New translations en-us.json (Russian)
[ci skip]
2026-05-02 13:16:21 -07:00
Owen Schwartz
f4684c1858 New translations en-us.json (Portuguese)
[ci skip]
2026-05-02 13:16:19 -07:00
Owen Schwartz
6e223bb363 New translations en-us.json (Polish)
[ci skip]
2026-05-02 13:16:18 -07:00
Owen Schwartz
22e7038b2c New translations en-us.json (Dutch)
[ci skip]
2026-05-02 13:16:16 -07:00
Owen Schwartz
76ba4c1fdf New translations en-us.json (Korean)
[ci skip]
2026-05-02 13:16:14 -07:00
Owen Schwartz
7f25d94a83 New translations en-us.json (Italian)
[ci skip]
2026-05-02 13:16:13 -07:00
Owen Schwartz
769ba27e3a New translations en-us.json (German)
[ci skip]
2026-05-02 13:16:11 -07:00
Owen Schwartz
a188552ba0 New translations en-us.json (Czech)
[ci skip]
2026-05-02 13:16:09 -07:00
Owen Schwartz
208132082e New translations en-us.json (Bulgarian)
[ci skip]
2026-05-02 13:16:08 -07:00
Owen Schwartz
fcd5789221 New translations en-us.json (French)
[ci skip]
2026-05-02 13:16:06 -07:00
Josh Voyles
2c85bcd06b fix(db): deterministically finalize prepared statements after execution
Wrap Statement .all()/.get()/.run() via autoFinalizeStatement() with
try/finally calling stmt.finalize() post-execution, releasing native
sqlite3_stmt memory immediately instead of waiting for GC.

Safe because:
- Drizzle one-time queries invoke each statement once only
- Drizzle does not access statement after .all()/.get()/.run() returns
- Migration scripts use isolated new Database() instances (unpatched)
- No app code holds persistent .prepare() refs on main db
2026-05-02 15:50:54 -04:00
miloschwartz
c6a8b09cff log in page improvements 2026-05-02 12:46:39 -07:00
miloschwartz
380ff381fc fix credenza scroll extra spacing above footer 2026-05-02 12:19:00 -07:00
Owen Schwartz
5eb3951f00 New translations en-us.json (Spanish)
[ci skip]
2026-05-02 12:13:08 -07:00
Owen Schwartz
c30e94da98 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-02 12:13:06 -07:00
Owen Schwartz
6ca24d51a1 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-02 12:13:05 -07:00
Owen Schwartz
13f512aed6 New translations en-us.json (Turkish)
[ci skip]
2026-05-02 12:13:03 -07:00
Owen Schwartz
2bdbc9d688 New translations en-us.json (Russian)
[ci skip]
2026-05-02 12:13:02 -07:00
Owen Schwartz
8e2f30d8de New translations en-us.json (Portuguese)
[ci skip]
2026-05-02 12:13:00 -07:00
Owen Schwartz
a84e1cc9e0 New translations en-us.json (Polish)
[ci skip]
2026-05-02 12:12:58 -07:00
Owen Schwartz
6b28f0c81e New translations en-us.json (Dutch)
[ci skip]
2026-05-02 12:12:56 -07:00
Owen Schwartz
d28d3ba6ea New translations en-us.json (Korean)
[ci skip]
2026-05-02 12:12:55 -07:00
Owen Schwartz
6efaf9f40d New translations en-us.json (Italian)
[ci skip]
2026-05-02 12:12:53 -07:00
Owen Schwartz
5379b32959 New translations en-us.json (German)
[ci skip]
2026-05-02 12:12:51 -07:00
Owen Schwartz
9bb936a40d New translations en-us.json (Czech)
[ci skip]
2026-05-02 12:12:50 -07:00
Owen Schwartz
960fe760f1 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-02 12:12:48 -07:00
Owen Schwartz
2f2105a085 New translations en-us.json (French)
[ci skip]
2026-05-02 12:12:46 -07:00
miloschwartz
de92a28435 update mac models 2026-05-02 12:09:55 -07:00
Owen
d8c3484ed5 Have to import from private 2026-05-02 12:00:51 -07:00
Owen
726e000154 Show remote nodes update in table 2026-05-02 11:55:01 -07:00
Josh Voyles
d6abe83fdc fix: memory improvements
- SQLite: enable WAL mode and PRAGMA performance settings

- ws.ts (public + private): fix clientConfigVersions memory leak

- internal server: add rate limiting and request timeouts

- audit log: fix flush re-queue feedback loop

- memory: add monitoring instrumentation

- security: remove debug log of full request body
2026-05-02 07:37:18 -04:00
Owen Schwartz
9df46f7014 Merge pull request #2966 from fosrl/dev
Try to pull domains from host regex
2026-05-01 20:54:09 -07:00
Owen
908f0d54e2 Try to pull domains from host regex 2026-05-01 20:53:39 -07:00
Milo Schwartz
f0010ea12a Merge pull request #2965 from fosrl/dev
add new screenshots
2026-05-01 17:35:29 -07:00
miloschwartz
cab8be1a9a add new screenshots 2026-05-01 17:34:05 -07:00
Owen Schwartz
0a9dab7cca Merge pull request #2964 from fosrl/dev
Update translations
2026-05-01 17:02:41 -07:00
Owen Schwartz
889ab1f8a8 Merge pull request #2963 from fosrl/crowdin_dev
New Crowdin updates
2026-05-01 17:02:10 -07:00
Owen Schwartz
a9019cfb23 New translations en-us.json (Spanish)
[ci skip]
2026-05-01 17:00:49 -07:00
Owen Schwartz
441d4bce6e New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-01 17:00:47 -07:00
Owen Schwartz
dd1e681a9c New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-01 17:00:45 -07:00
Owen Schwartz
a882619eaf New translations en-us.json (Turkish)
[ci skip]
2026-05-01 17:00:43 -07:00
Owen Schwartz
f43baaaf1f New translations en-us.json (Russian)
[ci skip]
2026-05-01 17:00:41 -07:00
Owen Schwartz
c3dc0bd015 New translations en-us.json (Portuguese)
[ci skip]
2026-05-01 17:00:39 -07:00
Owen Schwartz
1fd2a0fae2 New translations en-us.json (Polish)
[ci skip]
2026-05-01 17:00:37 -07:00
Owen Schwartz
8ba5b43569 New translations en-us.json (Dutch)
[ci skip]
2026-05-01 17:00:35 -07:00
Owen Schwartz
6deefcd003 New translations en-us.json (Korean)
[ci skip]
2026-05-01 17:00:33 -07:00
Owen Schwartz
4d6cea5fcd New translations en-us.json (Italian)
[ci skip]
2026-05-01 17:00:31 -07:00
Owen Schwartz
f175ac774f New translations en-us.json (German)
[ci skip]
2026-05-01 17:00:29 -07:00
Owen Schwartz
0fe2b24f6b New translations en-us.json (Czech)
[ci skip]
2026-05-01 17:00:27 -07:00
Owen Schwartz
6ad06e6faf New translations en-us.json (Bulgarian)
[ci skip]
2026-05-01 17:00:25 -07:00
Owen Schwartz
d47faeced1 New translations en-us.json (French)
[ci skip]
2026-05-01 17:00:23 -07:00
Owen Schwartz
498f586eeb New translations en-us.json (Spanish)
[ci skip]
2026-05-01 16:57:38 -07:00
Owen Schwartz
e94fc6bc65 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-01 16:57:37 -07:00
Owen Schwartz
0a1fe1b725 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-01 16:57:35 -07:00
Owen Schwartz
eb40b04b43 New translations en-us.json (Turkish)
[ci skip]
2026-05-01 16:57:33 -07:00
Owen Schwartz
6685afdcf9 New translations en-us.json (Russian)
[ci skip]
2026-05-01 16:57:32 -07:00
Owen Schwartz
49232e32bf New translations en-us.json (Portuguese)
[ci skip]
2026-05-01 16:57:30 -07:00
Owen Schwartz
aec0aed211 New translations en-us.json (Polish)
[ci skip]
2026-05-01 16:57:28 -07:00
Owen Schwartz
d43b3176f5 New translations en-us.json (Dutch)
[ci skip]
2026-05-01 16:57:26 -07:00
Owen Schwartz
190074ea0c New translations en-us.json (Korean)
[ci skip]
2026-05-01 16:57:24 -07:00
Owen Schwartz
c5a7719239 New translations en-us.json (Italian)
[ci skip]
2026-05-01 16:57:22 -07:00
Owen Schwartz
5eac131d2e New translations en-us.json (German)
[ci skip]
2026-05-01 16:57:21 -07:00
Owen Schwartz
0bc3276ee2 New translations en-us.json (Czech)
[ci skip]
2026-05-01 16:57:18 -07:00
Owen Schwartz
5073507b90 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-01 16:57:16 -07:00
Owen Schwartz
805e6f856a New translations en-us.json (French)
[ci skip]
2026-05-01 16:57:14 -07:00
Owen Schwartz
412a9b5294 Merge pull request #2962 from fosrl/dev
1.18.1-s.6
2026-05-01 16:54:37 -07:00
Owen
fbf95c5363 Start creating ns one level down 2026-05-01 16:51:42 -07:00
Owen
b907850344 Add missing heading 2026-05-01 16:51:42 -07:00
miloschwartz
22116373e3 increase target site selector width 2026-05-01 16:33:40 -07:00
miloschwartz
9757c3d8b6 show newt version on site 2026-05-01 16:26:45 -07:00
miloschwartz
f8b85d4b4e fix sidebar product updates spacing 2026-05-01 16:14:06 -07:00
Owen
4651f19c53 Support acme_json_path as a directory of acme file
Fixes #2961
2026-05-01 16:06:37 -07:00
Owen
4524bdc094 Add http cert syncing for use with the controller 2026-05-01 15:42:38 -07:00
Owen Schwartz
741850880e Merge pull request #2959 from fosrl/dev
1.18.1-s.4
2026-05-01 15:05:59 -07:00
Owen
53e096f7cb Allow deleting account with trial 2026-05-01 15:01:48 -07:00
Owen
3dfd7e8a43 Update limits 2026-05-01 11:47:14 -07:00
Owen
db6e60d0a3 Adjust language 2026-05-01 10:48:09 -07:00
Owen
54d2d689c1 Run messaging for delete in the background as well 2026-04-30 14:38:03 -07:00
Owen Schwartz
bb5853827b Merge pull request #2948 from fosrl/dev
1.18.1-s.3
2026-04-30 14:11:16 -07:00
Owen
68f5512732 Handle messaging in the background; dont time out 2026-04-30 14:00:32 -07:00
Owen
416e124c02 Rotate the secret on the new things using it 2026-04-30 11:53:55 -07:00
Owen
d3e4d8cda8 Fix pr blueprints not picking up site 2026-04-30 11:39:37 -07:00
Owen
81972dbb73 Add name to migration
Fixes #2943
2026-04-30 10:56:12 -07:00
Owen Schwartz
b715786a1e Merge pull request #2939 from fosrl/dev
1.18.1-s.2
2026-04-29 21:33:03 -07:00
Owen
ae24eb2d2c Disable the alerts and hc when downgrading 2026-04-29 21:31:02 -07:00
Owen
20fc59dcda Delete trial when upgrading 2026-04-29 21:25:58 -07:00
Owen
93b09de425 Adjust cloud api endpoints 2026-04-29 21:04:11 -07:00
Owen
bacc130453 Clean up sign and verify 2026-04-29 17:14:22 -07:00
Owen Schwartz
79541ec7b8 Merge pull request #2936 from fosrl/dev
1.18.1 patch over
2026-04-29 16:43:06 -07:00
Owen
81197f8a86 Update the database if the wildcard changes 2026-04-29 16:42:10 -07:00
miloschwartz
dcfc7822f4 hide cert in public resources col on oss 2026-04-29 16:03:59 -07:00
Owen Schwartz
269bd9aa0f Merge pull request #2934 from fosrl/dev
1.18.1-s.1
2026-04-29 15:18:28 -07:00
Owen
0a0817b860 Restrict alerting 2026-04-29 15:15:53 -07:00
Owen Schwartz
b7a903ab32 Merge pull request #2933 from fosrl/dev
1.18.1
2026-04-29 15:00:29 -07:00
Owen Schwartz
ab60438aa7 Merge pull request #2917 from fosrl/crowdin_dev
New Crowdin updates
2026-04-29 14:55:53 -07:00
Owen Schwartz
b9f3f90de6 New translations en-us.json (Spanish)
[ci skip]
2026-04-29 14:54:32 -07:00
Owen Schwartz
b53cc397be New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-04-29 14:54:30 -07:00
Owen Schwartz
994fb456c2 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-04-29 14:54:29 -07:00
Owen Schwartz
b36927c7a0 New translations en-us.json (Turkish)
[ci skip]
2026-04-29 14:54:27 -07:00
Owen Schwartz
1c57473b6d New translations en-us.json (Russian)
[ci skip]
2026-04-29 14:54:25 -07:00
Owen Schwartz
c02c3eaa4a New translations en-us.json (Portuguese)
[ci skip]
2026-04-29 14:54:23 -07:00
Owen Schwartz
3c265ee577 New translations en-us.json (Polish)
[ci skip]
2026-04-29 14:54:22 -07:00
Owen Schwartz
98dfd05f06 New translations en-us.json (Dutch)
[ci skip]
2026-04-29 14:54:20 -07:00
Owen Schwartz
faa2e97530 New translations en-us.json (Korean)
[ci skip]
2026-04-29 14:54:18 -07:00
Owen Schwartz
175f10a51d New translations en-us.json (Italian)
[ci skip]
2026-04-29 14:54:16 -07:00
Owen Schwartz
6284930fce New translations en-us.json (German)
[ci skip]
2026-04-29 14:54:15 -07:00
Owen Schwartz
6c93aca444 New translations en-us.json (Czech)
[ci skip]
2026-04-29 14:54:13 -07:00
Owen Schwartz
d83318cbfc New translations en-us.json (Bulgarian)
[ci skip]
2026-04-29 14:54:11 -07:00
Owen Schwartz
143f362a48 New translations en-us.json (French)
[ci skip]
2026-04-29 14:54:09 -07:00
miloschwartz
698cd868a8 show cert status in public reosurces table 2026-04-29 14:47:34 -07:00
Owen
a55842ffff Scrape certs from ALL resolvers 2026-04-29 14:29:15 -07:00
Owen
2ffe254879 Dont include site resources on the cloud 2026-04-29 14:08:42 -07:00
miloschwartz
e173f59d89 visual improvements 2026-04-29 13:44:35 -07:00
miloschwartz
d3870f4920 cert status in priv resources table first pass 2026-04-29 13:05:26 -07:00
miloschwartz
227501d8f8 fix rounded buttons in target input 2026-04-29 12:39:08 -07:00
miloschwartz
a16f805709 fix style for unknown status 2026-04-29 12:36:47 -07:00
miloschwartz
a029b107ae dont show site online status for local sites 2026-04-29 12:35:08 -07:00
miloschwartz
f03389a9a0 fix cert styling 2026-04-29 12:18:52 -07:00
Owen
78fff6bfde Filter to only allow newt sites 2026-04-29 12:18:28 -07:00
Owen
bc585c24fc Calculate actual resource status
Fixes #2930
2026-04-29 12:07:32 -07:00
miloschwartz
0f6c66dc67 use localfont and updated mona sans closes #2924 2026-04-29 11:58:06 -07:00
Owen
6be150bafe Handle possible not null for tcp, udp, and icmp
Fixes #2929
2026-04-29 11:42:18 -07:00
Owen
1eac7741a5 Show the certs elsewhere when required 2026-04-29 11:34:10 -07:00
Owen
b8ca0499af Dont show the cert box oss and dont check license 2026-04-29 11:28:30 -07:00
Owen
b39a2bcfb1 Quiet logs 2026-04-29 11:25:43 -07:00
Owen
d45b727dca Dont show cert status because not saved yet 2026-04-29 11:06:14 -07:00
Owen
5c31d35e28 Handle sans in the acme.json 2026-04-29 10:59:49 -07:00
Owen
8c645315f3 Handle when siteIds is not provided 2026-04-29 10:59:36 -07:00
Milo Schwartz
ab6377e086 Merge pull request #2923 from fosrl/miloschwartz-patch-2
Update README.md
2026-04-28 23:03:31 -07:00
Milo Schwartz
8685cf4208 Update README.md 2026-04-29 02:03:18 -04:00
Owen Schwartz
26fe1259da Merge pull request #2922 from fosrl/dev
1.18.0-s.2
2026-04-28 22:28:35 -07:00
Owen
3bcbeb24f3 Query the right column 2026-04-28 22:27:35 -07:00
Owen
1d0a92c83e Its in the transaction so we wait 2026-04-28 22:22:06 -07:00
Owen
a44100c2bd Handle deleting client and orphaning resources 2026-04-28 22:19:22 -07:00
miloschwartz
2203ebf723 show user idp in devices 2026-04-28 21:27:11 -07:00
Owen Schwartz
70958185bd Merge pull request #2921 from fosrl/dev
1.18.0-s.1
2026-04-28 21:03:36 -07:00
Owen
7e374baee9 Update if the ssl toggle changes 2026-04-28 20:45:20 -07:00
Owen
4cf6ca1d55 Force tcp and udp ports when http mode 2026-04-28 20:27:27 -07:00
Owen Schwartz
8ed9adbfae New translations en-us.json (German)
[ci skip]
2026-04-28 16:21:16 -07:00
Mustafa
8e1905a695 Exclude local/private/CGNAT IPs from COUNTRY=ALL and ASN=ALL/AS0 geo-blocking rules 2026-04-12 20:19:32 +02:00
155 changed files with 5108 additions and 1953 deletions

View File

@@ -414,28 +414,18 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign
# cosign is used to sign and verify container images (key and keyless)
# cosign is used to sign container images using keyless (OIDC) signing
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Dual-sign and verify (GHCR & Docker Hub)
# Sign each image by digest using keyless (OIDC) and key-based signing,
# then verify both the public key signature and the keyless OIDC signature.
- name: Sign (GHCR, keyless)
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.
# Signatures are stored in the registry alongside the image.
env:
TAG: ${{ env.TAG }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
COSIGN_YES: "true"
run: |
set -euo pipefail
issuer="https://token.actions.githubusercontent.com"
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
# Track failures
FAILED_TAGS=()
SUCCESSFUL_TAGS=()
# Determine if this is an RC release
IS_RC="false"
if [[ "$TAG" == *"-rc."* ]]; then
@@ -463,95 +453,47 @@ jobs:
)
fi
# Sign each image variant for both registries
for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}"
TAG_FAILED=false
FAILED_TAGS=()
SUCCESSFUL_TAGS=()
# Wrap the entire tag processing in error handling
(
set -e
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${BASE_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
echo "Processing ${GHCR_IMAGE}:${IMAGE_TAG}"
TAG_FAILED=false
echo "==> cosign sign (keyless) --recursive ${REF}"
cosign sign --recursive "${REF}"
(
set -e
DIGEST="$(skopeo inspect --retry-times 3 docker://${GHCR_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${GHCR_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
echo "==> cosign sign (key) --recursive ${REF}"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
echo "==> cosign sign (keyless) --recursive ${REF}"
cosign sign --recursive "${REF}"
) || TAG_FAILED=true
# Retry wrapper for verification to handle registry propagation delays
retry_verify() {
local cmd="$1"
local attempts=6
local delay=5
local i=1
until eval "$cmd"; do
if [ $i -ge $attempts ]; then
echo "Verification failed after $attempts attempts"
return 1
fi
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
sleep $delay
i=$((i+1))
delay=$((delay*2))
# Cap the delay to avoid very long waits
if [ $delay -gt 60 ]; then delay=60; fi
done
return 0
}
echo "==> cosign verify (public key) ${REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
VERIFIED_INDEX=true
else
VERIFIED_INDEX=false
fi
echo "==> cosign verify (keyless policy) ${REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
VERIFIED_INDEX_KEYLESS=true
else
VERIFIED_INDEX_KEYLESS=false
fi
# Check if verification succeeded
if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}"
echo "This may be due to registry propagation delays. Continuing anyway."
fi
) || TAG_FAILED=true
if [ "$TAG_FAILED" = "true" ]; then
echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}"
FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
else
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
fi
done
if [ "$TAG_FAILED" = "true" ]; then
echo "⚠️ WARNING: Failed to sign ${GHCR_IMAGE}:${IMAGE_TAG}"
FAILED_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
else
echo "✓ Successfully signed ${GHCR_IMAGE}:${IMAGE_TAG}"
SUCCESSFUL_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
fi
done
# Report summary
echo ""
echo "=========================================="
echo "Sign and Verify Summary"
echo "Sign Summary"
echo "=========================================="
echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
echo "Failed: ${#FAILED_TAGS[@]}"
echo ""
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
echo "Failed tags:"
for tag in "${FAILED_TAGS[@]}"; do
echo " - $tag"
done
echo ""
echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway"
echo "⚠️ WARNING: Some tags failed to sign, but continuing anyway"
else
echo "✓ All images signed and verified successfully!"
echo "✓ All images signed successfully!"
fi
shell: bash

View File

@@ -41,7 +41,7 @@
</strong>
</p>
Pangolin is an open-source, identity-based remote access platform built on WireGuard that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
## Installation

View File

@@ -1,5 +1,5 @@
import { CommandModule } from "yargs";
import { db, idpOidcConfig, licenseKey } from "@server/db";
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db";
import { encrypt, decrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq } from "drizzle-orm";
@@ -129,9 +129,15 @@ export const rotateServerSecret: CommandModule<
console.log("\nReading encrypted data from database...");
const idpConfigs = await db.select().from(idpOidcConfig);
const licenseKeys = await db.select().from(licenseKey);
const certs = await db.select().from(certificates);
const streamingDestinations = await db.select().from(eventStreamingDestinations);
const webhookActions = await db.select().from(alertWebhookActions);
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
console.log(`Found ${licenseKeys.length} license key(s)`);
console.log(`Found ${certs.length} certificate(s)`);
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
// Prepare all decrypted and re-encrypted values
console.log("\nDecrypting and re-encrypting values...");
@@ -149,8 +155,27 @@ export const rotateServerSecret: CommandModule<
encryptedInstanceId: string;
};
type CertUpdate = {
certId: number;
encryptedCertFile: string | null;
encryptedKeyFile: string | null;
};
type StreamingDestinationUpdate = {
destinationId: number;
encryptedConfig: string;
};
type WebhookActionUpdate = {
webhookActionId: number;
encryptedConfig: string;
};
const idpUpdates: IdpUpdate[] = [];
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
const certUpdates: CertUpdate[] = [];
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
const webhookActionUpdates: WebhookActionUpdate[] = [];
// Process idpOidcConfig entries
for (const idpConfig of idpConfigs) {
@@ -217,6 +242,70 @@ export const rotateServerSecret: CommandModule<
}
}
// Process certificate entries
for (const cert of certs) {
try {
const encryptedCertFile = cert.certFile
? encrypt(decrypt(cert.certFile, oldSecret), newSecret)
: null;
const encryptedKeyFile = cert.keyFile
? encrypt(decrypt(cert.keyFile, oldSecret), newSecret)
: null;
certUpdates.push({
certId: cert.certId,
encryptedCertFile,
encryptedKeyFile
});
} catch (error) {
console.error(
`Error processing certificate ${cert.certId} (${cert.domain}):`,
error
);
throw error;
}
}
// Process eventStreamingDestinations entries
for (const dest of streamingDestinations) {
try {
const decryptedConfig = decrypt(dest.config, oldSecret);
const encryptedConfig = encrypt(decryptedConfig, newSecret);
streamingDestinationUpdates.push({
destinationId: dest.destinationId,
encryptedConfig
});
} catch (error) {
console.error(
`Error processing event streaming destination ${dest.destinationId}:`,
error
);
throw error;
}
}
// Process alertWebhookActions entries
for (const webhook of webhookActions) {
try {
if (webhook.config == null) continue;
const decryptedConfig = decrypt(webhook.config, oldSecret);
const encryptedConfig = encrypt(decryptedConfig, newSecret);
webhookActionUpdates.push({
webhookActionId: webhook.webhookActionId,
encryptedConfig
});
} catch (error) {
console.error(
`Error processing alert webhook action ${webhook.webhookActionId}:`,
error
);
throw error;
}
}
// Perform all database updates in a single transaction
console.log("\nUpdating database in transaction...");
await db.transaction(async (trx) => {
@@ -250,10 +339,50 @@ export const rotateServerSecret: CommandModule<
instanceId: update.encryptedInstanceId
});
}
// Update certificate entries
for (const update of certUpdates) {
await trx
.update(certificates)
.set({
certFile: update.encryptedCertFile,
keyFile: update.encryptedKeyFile
})
.where(eq(certificates.certId, update.certId));
}
// Update event streaming destination entries
for (const update of streamingDestinationUpdates) {
await trx
.update(eventStreamingDestinations)
.set({ config: update.encryptedConfig })
.where(
eq(
eventStreamingDestinations.destinationId,
update.destinationId
)
);
}
// Update alert webhook action entries
for (const update of webhookActionUpdates) {
await trx
.update(alertWebhookActions)
.set({ config: update.encryptedConfig })
.where(
eq(
alertWebhookActions.webhookActionId,
update.webhookActionId
)
);
}
});
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`);
console.log(`Rotated ${certUpdates.length} certificate(s)`);
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
// Update config file with new secret
console.log("\nUpdating config file...");
@@ -270,6 +399,9 @@ export const rotateServerSecret: CommandModule<
console.log(`\nSummary:`);
console.log(` - OIDC IdP configurations: ${idpUpdates.length}`);
console.log(` - License keys: ${licenseKeyUpdates.length}`);
console.log(` - Certificates: ${certUpdates.length}`);
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
console.log(
`\n IMPORTANT: Restart the server for the new secret to take effect.`
);

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Превишихте ограничението на текущия си план. Коригирайте проблема, като премахнете сайтове, потребители или други ресурси, за да оставате в рамките на плана си.",
"trialBannerMessage": "Пробният Ви период изтича след {countdown}. Актуализирайте за запазване на достъпа.",
"trialBannerExpired": "Пробният Ви период е изтекъл. Актуализирайте сега, за да възстановите достъпа.",
"billingTrialBannerTitle": "Пробният период е активен",
"billingTrialBannerDescription": "В момента сте в пробен период на бизнес ниво. След края на пробния период, вашият акаунт автоматично ще бъде върнат към функциите и ограниченията на основното ниво. Надградете по всяко време, за да запазите достъпа до текущите функции на плана.",
"billingTrialBannerUpgrade": "Надградете сега",
"billingTrialBadge": "Пробен период",
"trialActive": "Активен пробен период",
"trialExpired": "Пробният период е изтекъл",
"trialHasEnded": "Пробният Ви период е приключил.",
@@ -763,6 +767,7 @@
"newtEndpoint": "Крайна точка",
"newtId": "Идентификационен номер",
"newtSecretKey": "Секретен ключ",
"newtVersion": "Версия",
"architecture": "Архитектура",
"sites": "Сайтове",
"siteWgAnyClients": "Използвайте клиент на WireGuard, за да се свържете. Ще трябва да използвате вътрешните ресурси чрез IP адреса на връстника.",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "Създаване на админ акаунт",
"setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.",
"certificateStatus": "Сертификат",
"certificateStatusAutoRefreshHint": "Състоянието се опреснява автоматично.",
"loading": "Зареждане",
"loadingAnalytics": "Зареждане на анализи",
"restart": "Рестарт",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "Преглед на бележките за изданието",
"newtUpdateAvailable": "Ново обновление",
"newtUpdateAvailableInfo": "Нова версия на Newt е налична. Моля, обновете до последната версия за най-добро изживяване.",
"pangolinNodeUpdateAvailableInfo": "Налична е нова версия на Pangolin Node. Моля, актуализирайте до последната версия за най-добро изживяване.",
"domainPickerEnterDomain": "Домейн",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Въведете пълния домейн на ресурса, за да видите наличните опции.",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "Изберете своя доставчик на идентичност, за да продължите",
"orgAuthNoIdpConfigured": "Тази организация няма конфигурирани доставчици на идентичност. Можете да влезете с вашата Pangolin идентичност.",
"orgAuthSignInWithPangolin": "Впишете се с Pangolin",
"orgAuthSignInToOrg": "Влезте в организация",
"orgAuthSignInToOrg": "Идентификационен доставчик на организация (SSO)",
"orgAuthSelectOrgTitle": "Вход в организация.",
"orgAuthSelectOrgDescription": "Въведете идентификатора на вашата организация, за да продължите.",
"orgAuthOrgIdPlaceholder": "вашата-организация",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "Уайлдкард подсайтове не са позволени.",
"domainPickerWildcardCertWarning": "Ресурсите с уайлдкард може да изискват допълнителна конфигурация за правилна работа.",
"domainPickerWildcardCertWarningLink": "Научете повече",
"health": "Здраве"
"health": "Здраве",
"domainPendingErrorTitle": "Проблем при проверка"
}

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Jste za hranicemi vašeho aktuálního plánu. Opravte problém odstraněním webů, uživatelů nebo jiných zdrojů, abyste zůstali ve vašem tarifu.",
"trialBannerMessage": "Vaše zkušební verze vyprší za {countdown}. Pro udržení přístupu upgraduje.",
"trialBannerExpired": "Vaše zkušební verze vypršela. Upgradujte nyní pro obnovu přístupu.",
"billingTrialBannerTitle": "Aktivní zkušební verze",
"billingTrialBannerDescription": "Právě používáte zkušební verzi na úrovni business. Po skončení zkušební verze se váš účet automaticky vrátí k funkcím a limitům úrovně Basic. Upgradujte kdykoli pro zachování přístupu k funkcím vašeho aktuálního plánu.",
"billingTrialBannerUpgrade": "Upgradovat nyní",
"billingTrialBadge": "Zkušební verze",
"trialActive": "Zkušební verze je aktivní",
"trialExpired": "Zkušební verze vypršela",
"trialHasEnded": "Vaše zkušební verze skončila.",
@@ -763,6 +767,7 @@
"newtEndpoint": "Endpoint",
"newtId": "ID",
"newtSecretKey": "Tajný klíč",
"newtVersion": "Verze",
"architecture": "Architektura",
"sites": "Stránky",
"siteWgAnyClients": "K připojení použijte jakéhokoli klienta WireGuard. Budete muset řešit interní zdroje pomocí klientské IP adresy.",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "Vytvořit účet správce",
"setupErrorCreateAdmin": "Došlo k chybě při vytváření účtu správce serveru.",
"certificateStatus": "Certifikát",
"certificateStatusAutoRefreshHint": "Stav se automaticky obnovuje.",
"loading": "Načítání",
"loadingAnalytics": "Načítání analytiky",
"restart": "Restartovat",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "Zobrazit poznámky k vydání",
"newtUpdateAvailable": "Dostupná aktualizace",
"newtUpdateAvailableInfo": "Je k dispozici nová verze Newt. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.",
"pangolinNodeUpdateAvailableInfo": "Je k dispozici nová verze uzlu Pangolin. Pro nejlepší zážitek aktualizujte na nejnovější verzi.",
"domainPickerEnterDomain": "Doména",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Zadejte úplnou doménu zdroje pro zobrazení dostupných možností.",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "Chcete-li pokračovat, vyberte svého poskytovatele identity",
"orgAuthNoIdpConfigured": "Tato organizace nemá nakonfigurovány žádné poskytovatele identity. Místo toho se můžete přihlásit s vaší Pangolinovou identitou.",
"orgAuthSignInWithPangolin": "Přihlásit se pomocí Pangolinu",
"orgAuthSignInToOrg": "Přihlásit se do organizace",
"orgAuthSignInToOrg": "Poskytovatel identity organizace (SSO)",
"orgAuthSelectOrgTitle": "Přihlášení do organizace",
"orgAuthSelectOrgDescription": "Zadejte ID vaší organizace pro pokračování",
"orgAuthOrgIdPlaceholder": "vaše-organizace",
@@ -3167,7 +3174,7 @@
"publicIpEndpoint": "Koncový bod",
"lastTriggeredAt": "Poslední spouštěč",
"reject": "Odmítnout",
"uptimeDaysAgo": "{count} days ago",
"uptimeDaysAgo": "Před {count} dny",
"uptimeToday": "Dnes",
"uptimeNoDataAvailable": "Dostupná žádná data",
"uptimeSuffix": "doba dostupnosti",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "Zástupné poddomény nejsou povoleny.",
"domainPickerWildcardCertWarning": "Zástupné zdroje mohou vyžadovat dodatečnou konfiguraci pro správnou funkci.",
"domainPickerWildcardCertWarningLink": "Zjistit více",
"health": "Zdraví"
"health": "Zdraví",
"domainPendingErrorTitle": "Problém s ověřením"
}

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Sie überschreiten Ihre Grenzen für Ihr aktuelles Paket. Korrigieren Sie das Problem, indem Sie Webseiten, Benutzer oder andere Ressourcen entfernen, um in Ihrem Paket zu bleiben.",
"trialBannerMessage": "Ihre Testversion läuft in {countdown} ab. Upgraden, um den Zugriff zu behalten.",
"trialBannerExpired": "Ihre Testversion ist abgelaufen. Jetzt upgraden, um den Zugriff wiederherzustellen.",
"billingTrialBannerTitle": "Kostenlose Testversion aktiv",
"billingTrialBannerDescription": "Sie nutzen derzeit eine kostenlose Testversion auf der Geschäftsstufe. Wenn die Testversion endet, wird Ihr Konto automatisch auf die Funktionen und Beschränkungen der Basisstufe zurückgesetzt. Upgraden Sie jederzeit, um weiterhin Zugriff auf die Funktionen Ihres aktuellen Plans zu behalten.",
"billingTrialBannerUpgrade": "Jetzt upgraden",
"billingTrialBadge": "Kostenlose Testversion",
"trialActive": "Kostenlose Testversion aktiv",
"trialExpired": "Testversion abgelaufen",
"trialHasEnded": "Ihre Testversion ist beendet.",
@@ -763,6 +767,7 @@
"newtEndpoint": "Endpunkt",
"newtId": "ID",
"newtSecretKey": "Geheimnis",
"newtVersion": "Version",
"architecture": "Architektur",
"sites": "Standorte",
"siteWgAnyClients": "Verwenden Sie jeden WireGuard-Client um sich zu verbinden. Sie müssen interne Ressourcen über die Peer-IP ansprechen.",
@@ -1432,7 +1437,7 @@
"alertingTriggerHcToggle": "Gesundheits-Check-Status ändern",
"alertingTriggerResourceHealthy": "Ressource gesund",
"alertingTriggerResourceUnhealthy": "Ressource ungesund",
"alertingTriggerResourceDegraded": "Resource degraded",
"alertingTriggerResourceDegraded": "Ressource verschlechtert",
"alertingSearchHealthChecks": "Gesundheits-Checks suchen…",
"alertingHealthChecksEmpty": "Keine Gesundheits-Checks verfügbar.",
"alertingTriggerResourceToggle": "Ressourcenstatus ändern",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "Admin-Konto erstellen",
"setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.",
"certificateStatus": "Zertifikat",
"certificateStatusAutoRefreshHint": "Der Status wird automatisch aktualisiert.",
"loading": "Laden",
"loadingAnalytics": "Analytik wird geladen",
"restart": "Neustart",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "Versionshinweise anzeigen",
"newtUpdateAvailable": "Update verfügbar",
"newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
"pangolinNodeUpdateAvailableInfo": "Eine neue Version von Pangolin Node ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
"domainPickerEnterDomain": "Domäne",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Geben Sie die vollständige Domain der Ressource ein, um verfügbare Optionen zu sehen.",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "Wähle deinen Identitätsanbieter um fortzufahren",
"orgAuthNoIdpConfigured": "Diese Organisation hat keine Identitätsanbieter konfiguriert. Sie können sich stattdessen mit Ihrer Pangolin-Identität anmelden.",
"orgAuthSignInWithPangolin": "Mit Pangolin anmelden",
"orgAuthSignInToOrg": "Bei einer Organisation anmelden",
"orgAuthSignInToOrg": "Organisations-Identitätsanbieter (SSO)",
"orgAuthSelectOrgTitle": "Organisations-Anmeldung",
"orgAuthSelectOrgDescription": "Geben Sie Ihre Organisations-ID ein, um fortzufahren",
"orgAuthOrgIdPlaceholder": "Ihre Organisation",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "Wildcard-Subdomains sind nicht erlaubt.",
"domainPickerWildcardCertWarning": "Wildcard-Ressourcen erfordern möglicherweise zusätzliche Konfigurationen, um ordnungsgemäß zu funktionieren.",
"domainPickerWildcardCertWarningLink": "Mehr erfahren",
"health": "Gesundheit"
"health": "Gesundheit",
"domainPendingErrorTitle": "Verifizierungsproblem"
}

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.",
"trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.",
"trialBannerExpired": "Your trial has expired. Upgrade now to restore access.",
"billingTrialBannerTitle": "Free Trial Active",
"billingTrialBannerDescription": "You're currently on a free trial on the business tier. When the trial ends, your account will automatically revert to the Basic tier features and limits. Upgrade anytime to keep access to your current plan's features.",
"billingTrialBannerUpgrade": "Upgrade Now",
"billingTrialBadge": "Free Trial",
"trialActive": "Free Trial Active",
"trialExpired": "Trial Expired",
"trialHasEnded": "Your trial has ended.",
@@ -763,6 +767,7 @@
"newtEndpoint": "Endpoint",
"newtId": "ID",
"newtSecretKey": "Secret",
"newtVersion": "Version",
"architecture": "Architecture",
"sites": "Sites",
"siteWgAnyClients": "Use any WireGuard client to connect. You will have to address internal resources using the peer IP.",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "Create Admin Account",
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
"certificateStatus": "Certificate",
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
"loading": "Loading",
"loadingAnalytics": "Loading Analytics",
"restart": "Restart",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "View Release Notes",
"newtUpdateAvailable": "Update Available",
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
"pangolinNodeUpdateAvailableInfo": "A new version of Pangolin Node is available. Please update to the latest version for the best experience.",
"domainPickerEnterDomain": "Domain",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "Choose your identity provider to continue",
"orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.",
"orgAuthSignInWithPangolin": "Sign in with Pangolin",
"orgAuthSignInToOrg": "Sign in to an organization",
"orgAuthSignInToOrg": "Organization Identity Provider (SSO)",
"orgAuthSelectOrgTitle": "Organization Sign In",
"orgAuthSelectOrgDescription": "Enter your organization ID to continue",
"orgAuthOrgIdPlaceholder": "your-organization",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed.",
"domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.",
"domainPickerWildcardCertWarningLink": "Learn more",
"health": "Health"
"health": "Health",
"domainPendingErrorTitle": "Verification Issue"
}

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Estás más allá de tus límites para tu plan actual. Corrija el problema eliminando sitios, usuarios u otros recursos para permanecer dentro de tu plan.",
"trialBannerMessage": "Su prueba expira en {countdown}. Actualice para mantener el acceso.",
"trialBannerExpired": "Su prueba ha expirado. Actualice ahora para restaurar el acceso.",
"billingTrialBannerTitle": "Prueba gratuita activada",
"billingTrialBannerDescription": "Actualmente estás en una prueba gratuita en el nivel empresarial. Cuando finalice la prueba, tu cuenta volverá automáticamente a las características y límites del nivel Básico. Mejora en cualquier momento para mantener el acceso a las características de tu plan actual.",
"billingTrialBannerUpgrade": "Actualizar ahora",
"billingTrialBadge": "Prueba Gratuita",
"trialActive": "Prueba gratuita activa",
"trialExpired": "Prueba expirada",
"trialHasEnded": "Su prueba ha terminado.",
@@ -763,6 +767,7 @@
"newtEndpoint": "Endpoint",
"newtId": "ID",
"newtSecretKey": "Secreto",
"newtVersion": "Versión",
"architecture": "Arquitectura",
"sites": "Sitios",
"siteWgAnyClients": "Usa cualquier cliente de Wirex para conectarte. Tendrás que dirigirte a los recursos internos usando la IP de compañeros.",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "Crear cuenta de administrador",
"setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.",
"certificateStatus": "Certificado",
"certificateStatusAutoRefreshHint": "El estado se actualiza automáticamente.",
"loading": "Cargando",
"loadingAnalytics": "Cargando analíticas",
"restart": "Reiniciar",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "Ver notas de lanzamiento",
"newtUpdateAvailable": "Nueva actualización disponible",
"newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.",
"pangolinNodeUpdateAvailableInfo": "Hay una nueva versión de Pangolin Node disponible. Actualice a la última versión para la mejor experiencia.",
"domainPickerEnterDomain": "Dominio",
"domainPickerPlaceholder": "miapp.ejemplo.com",
"domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "Elige tu proveedor de identidad para continuar",
"orgAuthNoIdpConfigured": "Esta organización no tiene ningún proveedor de identidad configurado. En su lugar puedes iniciar sesión con tu identidad de Pangolin.",
"orgAuthSignInWithPangolin": "Iniciar sesión con Pangolin",
"orgAuthSignInToOrg": "Iniciar sesión en una organización",
"orgAuthSignInToOrg": "Proveedor de identidad de la organización (SSO)",
"orgAuthSelectOrgTitle": "Inicio de sesión de organización",
"orgAuthSelectOrgDescription": "Ingrese el ID de su organización para continuar",
"orgAuthOrgIdPlaceholder": "tu-organización",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "No se permiten subdominios comodín.",
"domainPickerWildcardCertWarning": "Los recursos comodín pueden requerir configuración adicional para funcionar correctamente.",
"domainPickerWildcardCertWarningLink": "Más información",
"health": "Salud"
"health": "Salud",
"domainPendingErrorTitle": "Problema de verificación"
}

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Vous dépassez vos limites pour votre forfait actuel. Corrigez le problème en supprimant des sites, des utilisateurs ou d'autres ressources pour rester dans votre forfait.",
"trialBannerMessage": "Votre essai expire dans {countdown}. Passez à l'abonnement pour garder l'accès.",
"trialBannerExpired": "Votre essai a expiré. Passez à l'abonnement maintenant pour restaurer l'accès.",
"billingTrialBannerTitle": "Essai gratuit actif",
"billingTrialBannerDescription": "Vous êtes actuellement en essai gratuit sur le niveau business. À la fin de l'essai, votre compte basculera automatiquement aux fonctionnalités et limites du niveau Basique. Mettez à jour à tout moment pour conserver l'accès aux fonctionnalités de votre plan actuel.",
"billingTrialBannerUpgrade": "Passer à la version supérieure maintenant",
"billingTrialBadge": "Essai gratuit",
"trialActive": "Essai gratuit actif",
"trialExpired": "Essai expiré",
"trialHasEnded": "Votre essai est terminé.",
@@ -763,6 +767,7 @@
"newtEndpoint": "Endpoint",
"newtId": "ID",
"newtSecretKey": "Secrète",
"newtVersion": "Version",
"architecture": "Architecture",
"sites": "Nœuds",
"siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser des ressources internes en utilisant l'adresse IP du pair.",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "Créer un compte administrateur",
"setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.",
"certificateStatus": "Certificat",
"certificateStatusAutoRefreshHint": "L'état se rafraîchit automatiquement.",
"loading": "Chargement",
"loadingAnalytics": "Chargement de l'analyse",
"restart": "Redémarrer",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "Voir les notes de publication",
"newtUpdateAvailable": "Mise à jour disponible",
"newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
"pangolinNodeUpdateAvailableInfo": "Une nouvelle version de Pangolin Node est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
"domainPickerEnterDomain": "Domaine",
"domainPickerPlaceholder": "monapp.exemple.com",
"domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "Choisissez votre fournisseur d'identité pour continuer",
"orgAuthNoIdpConfigured": "Cette organisation n'a aucun fournisseur d'identité configuré. Vous pouvez vous connecter avec votre identité Pangolin à la place.",
"orgAuthSignInWithPangolin": "Se connecter avec Pangolin",
"orgAuthSignInToOrg": "Se connecter à une organisation",
"orgAuthSignInToOrg": "Fournisseur d'identité d'organisation (SSO)",
"orgAuthSelectOrgTitle": "Connexion à l'organisation",
"orgAuthSelectOrgDescription": "Entrez votre identifiant d'organisation pour continuer",
"orgAuthOrgIdPlaceholder": "votre-organisation",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "Les sous-domaines Joker ne sont pas autorisés.",
"domainPickerWildcardCertWarning": "Les ressources Joker peuvent nécessiter une configuration supplémentaire pour fonctionner correctement.",
"domainPickerWildcardCertWarningLink": "En savoir plus",
"health": "Santé"
"health": "Santé",
"domainPendingErrorTitle": "Problème de vérification"
}

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Hai superato i tuoi limiti per il tuo piano attuale. Correggi il problema rimuovendo siti, utenti o altre risorse per rimanere all'interno del tuo piano.",
"trialBannerMessage": "Il tuo periodo di prova scade tra {countdown}. Aggiorna per mantenere l'accesso.",
"trialBannerExpired": "Il tuo periodo di prova è scaduto. Aggiorna ora per ripristinare l'accesso.",
"billingTrialBannerTitle": "Prova Gratuita Attiva",
"billingTrialBannerDescription": "Attualmente sei in una prova gratuita sul livello business. Quando la prova terminerà, il tuo account tornerà automaticamente alle funzionalità e ai limiti del piano Basic. Effettua l'upgrade in qualsiasi momento per mantenere l'accesso alle funzionalità del tuo piano attuale.",
"billingTrialBannerUpgrade": "Effettua l'Upgrade Ora",
"billingTrialBadge": "Prova Gratuita",
"trialActive": "Prova Gratuita Attiva",
"trialExpired": "Prova scaduta",
"trialHasEnded": "La tua prova è terminata.",
@@ -763,6 +767,7 @@
"newtEndpoint": "Endpoint",
"newtId": "ID",
"newtSecretKey": "Segreto",
"newtVersion": "Versione",
"architecture": "Architettura",
"sites": "Siti",
"siteWgAnyClients": "Usa qualsiasi client WireGuard per connetterti. Dovrai indirizzare le risorse interne utilizzando l'IP del peer.",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "Crea Account Admin",
"setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.",
"certificateStatus": "Certificato",
"certificateStatusAutoRefreshHint": "Lo stato si aggiorna automaticamente.",
"loading": "Caricamento",
"loadingAnalytics": "Caricamento Delle Analisi",
"restart": "Riavvia",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "Visualizza Note Di Rilascio",
"newtUpdateAvailable": "Aggiornamento Disponibile",
"newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
"pangolinNodeUpdateAvailableInfo": "È disponibile una nuova versione di Pangolin Node. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
"domainPickerEnterDomain": "Dominio",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "Scegli il tuo provider di identità per continuare",
"orgAuthNoIdpConfigured": "Questa organizzazione non ha nessun provider di identità configurato. Puoi accedere con la tua identità Pangolin.",
"orgAuthSignInWithPangolin": "Accedi con Pangolino",
"orgAuthSignInToOrg": "Accedi a un'organizzazione",
"orgAuthSignInToOrg": "Provider di identità dell'organizzazione (SSO)",
"orgAuthSelectOrgTitle": "Accesso Organizzazione",
"orgAuthSelectOrgDescription": "Inserisci l'ID dell'organizzazione per continuare",
"orgAuthOrgIdPlaceholder": "la-tua-organizzazione",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "I sottodomini wildcard non sono permessi.",
"domainPickerWildcardCertWarning": "Le risorse wildcard potrebbero richiedere configurazioni aggiuntive per funzionare correttamente.",
"domainPickerWildcardCertWarningLink": "Scopri di più",
"health": "Salute"
"health": "Salute",
"domainPendingErrorTitle": "Problema di Verifica"
}

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "현재 계획의 한계를 초과했습니다. 사이트, 사용자 또는 기타 리소스를 제거하여 계획 내에 머물도록 해결하세요.",
"trialBannerMessage": "시험 사용 기간이 {countdown} 안에 만료됩니다. 업그레이드하여 액세스를 유지하세요.",
"trialBannerExpired": "시험 사용 기간이 만료되었습니다. 지금 업그레이드하여 액세스를 복구하세요.",
"billingTrialBannerTitle": "무료 평가판 활성화",
"billingTrialBannerDescription": "현재 비즈니스 티어의 무료 평가판을 사용 중입니다. 평가판이 종료되면 계정은 자동으로 기본 티어 기능 및 제한으로 돌아갑니다. 현재 계획의 기능을 유지하려면 언제든지 업그레이드 하세요.",
"billingTrialBannerUpgrade": "지금 업그레이드",
"billingTrialBadge": "무료 평가판",
"trialActive": "무료 체험 활성화됨",
"trialExpired": "체험 만료됨",
"trialHasEnded": "시험 사용 기간이 종료되었습니다.",
@@ -763,6 +767,7 @@
"newtEndpoint": "엔드포인트",
"newtId": "ID",
"newtSecretKey": "비밀",
"newtVersion": "버전",
"architecture": "아키텍처",
"sites": "사이트",
"siteWgAnyClients": "WireGuard 클라이언트를 사용하여 연결하십시오. 피어 IP를 사용하여 내부 리소스에 접근해야 합니다.",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "관리자 계정 생성",
"setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.",
"certificateStatus": "인증서",
"certificateStatusAutoRefreshHint": "상태가 자동으로 새로 고쳐집니다.",
"loading": "로딩 중",
"loadingAnalytics": "분석 로딩 중",
"restart": "재시작",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "릴리스 노트 보기",
"newtUpdateAvailable": "업데이트 가능",
"newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
"pangolinNodeUpdateAvailableInfo": "Pangolin Node의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
"domainPickerEnterDomain": "도메인",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "계속하려면 신원 공급자를 선택하세요.",
"orgAuthNoIdpConfigured": "이 조직은 구성된 신원 공급자가 없습니다. 대신 Pangolin 아이덴티티로 로그인할 수 있습니다.",
"orgAuthSignInWithPangolin": "Pangolin으로 로그인",
"orgAuthSignInToOrg": "조직에 로그인",
"orgAuthSignInToOrg": "조직 아이덴티티 제공자 (SSO)",
"orgAuthSelectOrgTitle": "조직 로그인",
"orgAuthSelectOrgDescription": "계속하려면 조직 ID를 입력하십시오.",
"orgAuthOrgIdPlaceholder": "your-organization",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "와일드카드 서브도메인은 허용되지 않습니다.",
"domainPickerWildcardCertWarning": "와일드카드 리소스는 올바르게 작동하려면 추가 구성이 필요할 수 있습니다.",
"domainPickerWildcardCertWarningLink": "자세히 알아보기",
"health": "건강"
"health": "건강",
"domainPendingErrorTitle": "확인 문제"
}

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Du er utenfor grensen for gjeldende plan. Rett problemet ved å fjerne nettsteder, brukere eller andre ressurser for å bli innenfor planen din.",
"trialBannerMessage": "Din prøveperiode utløper om {countdown}. Oppgrader for å beholde tilgangen.",
"trialBannerExpired": "Prøveperioden din har utløpt. Oppgrader nå for å gjenopprette tilgangen.",
"billingTrialBannerTitle": "Prøveversjon Aktiv",
"billingTrialBannerDescription": "Du har for øyeblikket en gratis prøveversjon på forretningsnivået. Når prøven avsluttes, vil kontoen din automatisk gå tilbake til funksjoner og begrensninger på Basis-nivået. Oppgrader når som helst for å beholde tilgang til de nåværende planens funksjoner.",
"billingTrialBannerUpgrade": "Oppgrader nå",
"billingTrialBadge": "Prøveversjon",
"trialActive": "Gratis prøveversjon aktiv",
"trialExpired": "Prøveperioden er utløpt",
"trialHasEnded": "Din prøveperiode har avsluttet.",
@@ -763,6 +767,7 @@
"newtEndpoint": "Endpoint",
"newtId": "ID",
"newtSecretKey": "Sikkerhetsnøkkel",
"newtVersion": "Versjon",
"architecture": "Arkitektur",
"sites": "Områder",
"siteWgAnyClients": "Bruk hvilken som helst WireGuard klient til å koble til. Du må adressere interne ressurser ved hjelp av peer IP.",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "Opprett administratorkonto",
"setupErrorCreateAdmin": "En feil oppstod under opprettelsen av serveradministratorkontoen.",
"certificateStatus": "Sertifikat",
"certificateStatusAutoRefreshHint": "Status oppdateres automatisk.",
"loading": "Laster inn",
"loadingAnalytics": "Laster inn analyser",
"restart": "Start på nytt",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "Se utgivelsesnotater",
"newtUpdateAvailable": "Oppdatering tilgjengelig",
"newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.",
"pangolinNodeUpdateAvailableInfo": "En ny versjon av Pangolin Node er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.",
"domainPickerEnterDomain": "Domene",
"domainPickerPlaceholder": "minapp.eksempel.no",
"domainPickerDescription": "Skriv inn hele domenet til ressursen for å se tilgjengelige alternativer.",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "Velg din identitet leverandør for å fortsette",
"orgAuthNoIdpConfigured": "Denne organisasjonen har ikke noen identitetstjeneste konfigurert. Du kan i stedet logge inn med Pangolin identiteten din.",
"orgAuthSignInWithPangolin": "Logg inn med Pangolin",
"orgAuthSignInToOrg": "Logg inn på en organisasjon",
"orgAuthSignInToOrg": "Organisasjonens identitetsleverandør (SSO)",
"orgAuthSelectOrgTitle": "Organisasjonsinnlogging",
"orgAuthSelectOrgDescription": "Skriv inn organisasjons-ID-en din for å fortsette",
"orgAuthOrgIdPlaceholder": "din-organisasjon",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "Jokertegnsubdomener er ikke tillatt.",
"domainPickerWildcardCertWarning": "Jokertegnressurser kan kreve ekstra konfigurasjon for å fungere skikkelig.",
"domainPickerWildcardCertWarningLink": "Lær mer",
"health": "Helse"
"health": "Helse",
"domainPendingErrorTitle": "Verifiseringsproblem"
}

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "U overschrijdt uw huidige abonnement. Corrigeer het probleem door sites, gebruikers of andere bronnen te verwijderen om binnen uw plan te blijven.",
"trialBannerMessage": "Uw proefversie verloopt over {countdown}. Upgrade om toegang te behouden.",
"trialBannerExpired": "Uw proefperiode is verlopen. Upgrade nu om toegang te herstellen.",
"billingTrialBannerTitle": "Proefperiode Actief",
"billingTrialBannerDescription": "Je bent momenteel bezig met een gratis proefperiode op het zakelijke niveau. Wanneer de proefperiode eindigt, wordt je account automatisch teruggezet naar de functies en limieten van het Basic-niveau. Upgrade op elk moment om toegang te houden tot de functies van je huidige plan.",
"billingTrialBannerUpgrade": "Nu Upgraden",
"billingTrialBadge": "Gratis Proefversie",
"trialActive": "Gratis proefversie actief",
"trialExpired": "Proefversie verlopen",
"trialHasEnded": "Uw proefperiode is geëindigd.",
@@ -763,6 +767,7 @@
"newtEndpoint": "Endpoint",
"newtId": "ID",
"newtSecretKey": "Geheim",
"newtVersion": "Versie",
"architecture": "Architectuur",
"sites": "Sites",
"siteWgAnyClients": "Gebruik een willekeurige WireGuard client om verbinding te maken. Je zult interne bronnen moeten aanspreken met behulp van de peer IP.",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "Maak een beheeraccount aan",
"setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.",
"certificateStatus": "Certificaat",
"certificateStatusAutoRefreshHint": "Status ververst automatisch.",
"loading": "Bezig met laden",
"loadingAnalytics": "Laden van Analytics",
"restart": "Herstarten",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "Uitgaveopmerkingen bekijken",
"newtUpdateAvailable": "Update beschikbaar",
"newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
"pangolinNodeUpdateAvailableInfo": "Er is een nieuwe versie van Pangolin Node beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
"domainPickerEnterDomain": "Domein",
"domainPickerPlaceholder": "mijnapp.voorbeeld.nl",
"domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "Kies uw identiteitsprovider om door te gaan",
"orgAuthNoIdpConfigured": "Deze organisatie heeft geen identiteitsproviders geconfigureerd. Je kunt in plaats daarvan inloggen met je Pangolin-identiteit.",
"orgAuthSignInWithPangolin": "Log in met Pangolin",
"orgAuthSignInToOrg": "Log in bij een organisatie",
"orgAuthSignInToOrg": "Organisatie Identiteitsprovider (SSO)",
"orgAuthSelectOrgTitle": "Organisatie Inloggen",
"orgAuthSelectOrgDescription": "Voer je organisatie-ID in om verder te gaan",
"orgAuthOrgIdPlaceholder": "jouw-organisatie",
@@ -3167,7 +3174,7 @@
"publicIpEndpoint": "Eindpunt",
"lastTriggeredAt": "Laatste Trigger",
"reject": "Afwijzen",
"uptimeDaysAgo": "{count} days ago",
"uptimeDaysAgo": "{count} dagen geleden",
"uptimeToday": "Vandaag",
"uptimeNoDataAvailable": "Geen gegevens beschikbaar",
"uptimeSuffix": "werktijd",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "Wildcard-subdomeinen zijn niet toegestaan.",
"domainPickerWildcardCertWarning": "Wildcard-bronnen hebben mogelijk extra configuratie nodig om correct te werken.",
"domainPickerWildcardCertWarningLink": "Meer informatie",
"health": "Gezondheid"
"health": "Gezondheid",
"domainPendingErrorTitle": "Verificatieprobleem"
}

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Nie masz ograniczeń dla aktualnego planu. Popraw problem poprzez usunięcie stron, użytkowników lub innych zasobów, aby pozostać w swoim planie.",
"trialBannerMessage": "Twój okres próbny wygasa za {countdown}. Uaktualnij, aby zachować dostęp.",
"trialBannerExpired": "Twój okres próbny wygasł. Uaktualnij teraz, aby przywrócić dostęp.",
"billingTrialBannerTitle": "Bezpłatna wersja próbna aktywna",
"billingTrialBannerDescription": "Obecnie korzystasz z bezpłatnej wersji próbnej na poziomie biznesowym. Po zakończeniu wersji próbnej, Twoje konto automatycznie powróci do funkcji i limitów poziomu Podstawowego. Możesz dokonać uaktualnienia w każdej chwili, aby zachować dostęp do funkcji obecnego planu.",
"billingTrialBannerUpgrade": "Uaktualnij teraz",
"billingTrialBadge": "Bezpłatna wersja próbna",
"trialActive": "Okres próbny aktywny",
"trialExpired": "Okres próbny wygasł",
"trialHasEnded": "Twój okres próbny dobiegł końca.",
@@ -763,6 +767,7 @@
"newtEndpoint": "Endpoint",
"newtId": "ID",
"newtSecretKey": "Sekret",
"newtVersion": "Wersja",
"architecture": "Architektura",
"sites": "Witryny",
"siteWgAnyClients": "Użyj dowolnego klienta WireGuard, aby się połączyć. Będziesz musiał przekierować wewnętrzne zasoby za pomocą adresu IP.",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "Utwórz konto administratora",
"setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.",
"certificateStatus": "Certyfikat",
"certificateStatusAutoRefreshHint": "Status odświeża się automatycznie.",
"loading": "Ładowanie",
"loadingAnalytics": "Ładowanie Analityki",
"restart": "Uruchom ponownie",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "Zobacz informacje o wydaniu",
"newtUpdateAvailable": "Dostępna aktualizacja",
"newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.",
"pangolinNodeUpdateAvailableInfo": "Nowa wersja Pangolin Node jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.",
"domainPickerEnterDomain": "Domena",
"domainPickerPlaceholder": "mojapp.example.com",
"domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "Wybierz swojego dostawcę tożsamości, aby kontynuować",
"orgAuthNoIdpConfigured": "Ta organizacja nie ma skonfigurowanych żadnych dostawców tożsamości. Zamiast tego możesz zalogować się za pomocą swojej tożsamości Pangolin.",
"orgAuthSignInWithPangolin": "Zaloguj się używając Pangolin",
"orgAuthSignInToOrg": "Zaloguj się do organizacji",
"orgAuthSignInToOrg": "Dostawca tożsamości organizacji (SSO)",
"orgAuthSelectOrgTitle": "Logowanie do organizacji",
"orgAuthSelectOrgDescription": "Wprowadź identyfikator organizacji, aby kontynuować",
"orgAuthOrgIdPlaceholder": "twoja-organizacja",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "Uniwersalne subdomeny nie są dozwolone.",
"domainPickerWildcardCertWarning": "Uniwersalne zasoby mogą wymagać dodatkowej konfiguracji, aby działać poprawnie.",
"domainPickerWildcardCertWarningLink": "Dowiedz się więcej",
"health": "Zdrowie"
"health": "Zdrowie",
"domainPendingErrorTitle": "Problem z weryfikacją"
}

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Você está além dos seus limites para o seu plano atual. Corrija o problema removendo sites, usuários, ou outros recursos para ficar em seu plano.",
"trialBannerMessage": "Sua avaliação termina em {countdown}. Faça o upgrade para manter o acesso.",
"trialBannerExpired": "Sua avaliação expirou. Faça o upgrade agora para restaurar o acesso.",
"billingTrialBannerTitle": "Teste Gratuito Ativo",
"billingTrialBannerDescription": "Atualmente, você está em um teste gratuito no nível empresarial. Quando o teste terminar, sua conta reverterá automaticamente para os recursos e limites do nível Básico. Atualize a qualquer momento para manter o acesso aos recursos do seu plano atual.",
"billingTrialBannerUpgrade": "Atualize Agora",
"billingTrialBadge": "Teste Gratuito",
"trialActive": "Avaliação Gratuita Ativa",
"trialExpired": "Avaliação Expirada",
"trialHasEnded": "Sua avaliação terminou.",
@@ -763,6 +767,7 @@
"newtEndpoint": "Endpoint",
"newtId": "ID",
"newtSecretKey": "Chave Secreta",
"newtVersion": "Versão",
"architecture": "Arquitetura",
"sites": "sites",
"siteWgAnyClients": "Use qualquer cliente do WireGuard para se conectar. Você terá que endereçar recursos internos usando o IP de pares.",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "Criar Conta de Administrador",
"setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.",
"certificateStatus": "Certificado",
"certificateStatusAutoRefreshHint": "Status atualiza automaticamente.",
"loading": "Carregando",
"loadingAnalytics": "Carregando Analytics",
"restart": "Reiniciar",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "Ver notas de versão",
"newtUpdateAvailable": "Nova Atualização Disponível",
"newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.",
"pangolinNodeUpdateAvailableInfo": "Uma nova versão do Pangolin Node está disponível. Atualize para a versão mais recente para uma melhor experiência.",
"domainPickerEnterDomain": "Domínio",
"domainPickerPlaceholder": "myapp.exemplo.com",
"domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "Escolha o seu provedor de identidade para continuar",
"orgAuthNoIdpConfigured": "Esta organização não tem nenhum provedor de identidade configurado. Você pode entrar com a identidade do seu Pangolin.",
"orgAuthSignInWithPangolin": "Entrar com o Pangolin",
"orgAuthSignInToOrg": "Fazer login em uma organização",
"orgAuthSignInToOrg": "Provedor de Identidade da Organização (SSO)",
"orgAuthSelectOrgTitle": "Entrada da Organização",
"orgAuthSelectOrgDescription": "Digite seu ID da organização para continuar",
"orgAuthOrgIdPlaceholder": "sua-organização",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "Subdomínios curinga não são permitidos.",
"domainPickerWildcardCertWarning": "Recursos curinga podem exigir configurações adicionais para funcionarem corretamente.",
"domainPickerWildcardCertWarningLink": "Saiba mais",
"health": "Saúde"
"health": "Saúde",
"domainPendingErrorTitle": "Problema de Verificação"
}

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Вы превысили лимиты для вашего текущего плана. Исправьте проблему, удалив сайты, пользователей или другие ресурсы, чтобы остаться в пределах вашего плана.",
"trialBannerMessage": "Ваш пробный период истекает через {countdown}. Обновите, чтобы сохранить доступ.",
"trialBannerExpired": "Ваш пробный период истек. Обновите сейчас, чтобы восстановить доступ.",
"billingTrialBannerTitle": "Бесплатная версия активна",
"billingTrialBannerDescription": "Вы в настоящее время находитесь на бесплатном пробном периоде бизнес-уровня. Когда пробный период закончится, ваш аккаунт автоматически вернётся к функциям и лимитам базового уровня. Обновите в любое время, чтобы сохранить доступ к функциям текущего плана.",
"billingTrialBannerUpgrade": "Обновить сейчас",
"billingTrialBadge": "Бесплатная версия",
"trialActive": "Бесплатный пробный период активен",
"trialExpired": "Пробный период истек",
"trialHasEnded": "Ваш пробный период окончен.",
@@ -763,6 +767,7 @@
"newtEndpoint": "Endpoint",
"newtId": "ID",
"newtSecretKey": "Секретный ключ",
"newtVersion": "Версия",
"architecture": "Архитектура",
"sites": "Сайты",
"siteWgAnyClients": "Для подключения используйте любой клиент WireGuard. Вы должны будете адресовать внутренние ресурсы, используя IP адрес пира.",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "Создать учётную запись администратора",
"setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.",
"certificateStatus": "Сертификат",
"certificateStatusAutoRefreshHint": "Статус обновляется автоматически.",
"loading": "Загрузка",
"loadingAnalytics": "Загрузка аналитики",
"restart": "Перезагрузка",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "Просмотреть примечания к выпуску",
"newtUpdateAvailable": "Доступно обновление",
"newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
"pangolinNodeUpdateAvailableInfo": "Доступна новая версия Pangolin Node. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
"domainPickerEnterDomain": "Домен",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения",
"orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.",
"orgAuthSignInWithPangolin": "Войти через Pangolin",
"orgAuthSignInToOrg": "Войти в организацию",
"orgAuthSignInToOrg": "Поставщик удостоверений организации (SSO)",
"orgAuthSelectOrgTitle": "Вход в организацию",
"orgAuthSelectOrgDescription": "Введите ID вашей организации, чтобы продолжить",
"orgAuthOrgIdPlaceholder": "ваша-организация",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "Wildcard поддомены не допускаются.",
"domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.",
"domainPickerWildcardCertWarningLink": "Узнать больше",
"health": "Состояние"
"health": "Состояние",
"domainPendingErrorTitle": "Проблема с подтверждением"
}

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Geçerli planınız için limitlerinizi aştınız. Planınız dahilinde kalmak için siteleri, kullanıcıları veya diğer kaynakları kaldırarak sorunu düzeltin.",
"trialBannerMessage": "Deneme süreniz {countdown} içinde sona eriyor. Erişimi sürdürmek için yükseltin.",
"trialBannerExpired": "Deneme süreniz sona erdi. Erişimi geri yüklemek için şimdi yükseltin.",
"billingTrialBannerTitle": "Ücretsiz Deneme Aktif",
"billingTrialBannerDescription": "Şu anda iş seviyesi için ücretsiz deneme sürümündesiniz. Deneme süresi sona erdiğinde, hesabınız otomatik olarak Temel seviye özelliklerine ve limitlerine geri dönecektir. Mevcut planınızın özelliklerine erişimi sürdürmek için istediğiniz zaman yükseltin.",
"billingTrialBannerUpgrade": "Şimdi Yükselt",
"billingTrialBadge": "Ücretsiz Deneme",
"trialActive": "Ücretsiz Deneme Aktif",
"trialExpired": "Deneme Süresi Doldu",
"trialHasEnded": "Deneme süreniz sona erdi.",
@@ -763,6 +767,7 @@
"newtEndpoint": "Uç Nokta",
"newtId": "Kimlik",
"newtSecretKey": "Gizli",
"newtVersion": "Sürüm",
"architecture": "Mimari",
"sites": "Siteler",
"siteWgAnyClients": "Herhangi bir WireGuard istemcisi kullanarak bağlanın. Dahili kaynaklara eş IP adresini kullanarak erişmeniz gerekecek.",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "Yönetici Hesabı Oluştur",
"setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.",
"certificateStatus": "Sertifika",
"certificateStatusAutoRefreshHint": "Durum otomatik olarak yenilenir.",
"loading": "Yükleniyor",
"loadingAnalytics": "Analiz Yükleniyor",
"restart": "Yeniden Başlat",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "Yayın Notlarını Görüntüle",
"newtUpdateAvailable": "Güncelleme Mevcut",
"newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
"pangolinNodeUpdateAvailableInfo": "Pangolin Node'un yeni bir sürümü mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
"domainPickerEnterDomain": "Alan Adı",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "Devam etmek için kimlik sağlayıcınızı seçin",
"orgAuthNoIdpConfigured": "Bu kuruluşta yapılandırılmış kimlik sağlayıcı yok. Bunun yerine Pangolin kimliğinizle giriş yapabilirsiniz.",
"orgAuthSignInWithPangolin": "Pangolin ile Giriş Yap",
"orgAuthSignInToOrg": "Bir kuruluşa giriş yapın",
"orgAuthSignInToOrg": "Kuruluş Kimlik Sağlayıcısı (SSO)",
"orgAuthSelectOrgTitle": "Kuruluş Giriş",
"orgAuthSelectOrgDescription": "Devam etmek için kuruluş kimliğinizi girin",
"orgAuthOrgIdPlaceholder": "kuruluşunuz",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "Genel alt alanlara izin verilmiyor.",
"domainPickerWildcardCertWarning": "Genel kaynaklar düzgün çalışmak için ek yapılandırma gerektirebilir.",
"domainPickerWildcardCertWarningLink": "Daha fazla bilgi",
"health": "Sağlık"
"health": "Sağlık",
"domainPendingErrorTitle": "Doğrulama Sorunu"
}

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "您的当前计划超出了您的限制。通过移除站点、用户或其他资源以保持在您的计划范围内来纠正问题。",
"trialBannerMessage": "您的试用将在 {countdown} 到期。升级以保持访问。",
"trialBannerExpired": "您的试用已到期。立即升级以恢复访问。",
"billingTrialBannerTitle": "免费试用激活中",
"billingTrialBannerDescription": "您目前正在商用层进行免费试用。试用结束后,您的账户将自动回到基础层功能和限制。可随时升级以保持当前计划的功能访问。",
"billingTrialBannerUpgrade": "立即升级",
"billingTrialBadge": "免费试用",
"trialActive": "免费试用中",
"trialExpired": "试用到期",
"trialHasEnded": "您的试用已结束。",
@@ -763,6 +767,7 @@
"newtEndpoint": "Endpoint",
"newtId": "ID",
"newtSecretKey": "密钥",
"newtVersion": "版本",
"architecture": "架构",
"sites": "站点",
"siteWgAnyClients": "使用任何 WireGuard 客户端连接。您必须使用对等IP解决内部资源问题。",
@@ -1597,6 +1602,7 @@
"createAdminAccount": "创建管理员帐户",
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
"certificateStatus": "证书",
"certificateStatusAutoRefreshHint": "状态自动刷新。",
"loading": "加载中",
"loadingAnalytics": "加载分析",
"restart": "重启",
@@ -1665,6 +1671,7 @@
"pangolinUpdateAvailableReleaseNotes": "查看发布说明",
"newtUpdateAvailable": "更新可用",
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
"pangolinNodeUpdateAvailableInfo": "新版本的 Pangolin Node 已可用。请更新到最新版本以获得最佳体验。",
"domainPickerEnterDomain": "域名",
"domainPickerPlaceholder": "example.com",
"domainPickerDescription": "输入资源的完整域名以查看可用选项。",
@@ -2352,7 +2359,7 @@
"orgAuthChooseIdpDescription": "选择您的身份提供商以继续",
"orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。",
"orgAuthSignInWithPangolin": "使用 Pangolin 登录",
"orgAuthSignInToOrg": "登录到组织",
"orgAuthSignInToOrg": "组织身份提供商 (SSO)",
"orgAuthSelectOrgTitle": "组织登录",
"orgAuthSelectOrgDescription": "输入您的组织ID以继续",
"orgAuthOrgIdPlaceholder": "您的组织",
@@ -3200,5 +3207,6 @@
"domainPickerWildcardSubdomainNotAllowed": "不允许使用通配符子域。",
"domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。",
"domainPickerWildcardCertWarningLink": "了解更多",
"health": "健康"
"health": "健康",
"domainPendingErrorTitle": "验证问题"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 KiB

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 569 KiB

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 KiB

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 KiB

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 516 KiB

View File

@@ -122,8 +122,6 @@ export enum ActionsEnum {
createOrgDomain = "createOrgDomain",
deleteOrgDomain = "deleteOrgDomain",
restartOrgDomain = "restartOrgDomain",
sendUsageNotification = "sendUsageNotification",
sendTrialNotification = "sendTrialNotification",
createRemoteExitNode = "createRemoteExitNode",
updateRemoteExitNode = "updateRemoteExitNode",
getRemoteExitNode = "getRemoteExitNode",

View File

@@ -1,94 +1,53 @@
{
"PowerMac4,4": "eMac",
"PowerMac6,4": "eMac",
"PowerBook2,1": "iBook",
"PowerBook2,2": "iBook",
"PowerBook4,1": "iBook",
"PowerBook4,2": "iBook",
"PowerBook4,3": "iBook",
"PowerBook6,3": "iBook",
"PowerBook6,5": "iBook",
"PowerBook6,7": "iBook",
"iMac,1": "iMac",
"PowerMac2,1": "iMac",
"PowerMac2,2": "iMac",
"PowerMac4,1": "iMac",
"PowerMac4,2": "iMac",
"PowerMac4,5": "iMac",
"PowerMac6,1": "iMac",
"PowerMac6,3*": "iMac",
"PowerMac6,3": "iMac",
"PowerMac8,1": "iMac",
"PowerMac8,2": "iMac",
"PowerMac12,1": "iMac",
"iMac4,1": "iMac",
"iMac4,2": "iMac",
"iMac5,2": "iMac",
"iMac5,1": "iMac",
"iMac6,1": "iMac",
"iMac7,1": "iMac",
"iMac8,1": "iMac",
"iMac9,1": "iMac",
"iMac10,1": "iMac",
"iMac11,1": "iMac",
"iMac11,2": "iMac",
"iMac11,3": "iMac",
"iMac12,1": "iMac",
"iMac12,2": "iMac",
"iMac13,1": "iMac",
"iMac13,2": "iMac",
"iMac14,1": "iMac",
"iMac14,3": "iMac",
"iMac14,2": "iMac",
"iMac14,4": "iMac",
"iMac15,1": "iMac",
"iMac16,1": "iMac",
"iMac16,2": "iMac",
"iMac17,1": "iMac",
"iMac18,1": "iMac",
"iMac18,2": "iMac",
"iMac18,3": "iMac",
"iMac19,2": "iMac",
"iMac19,1": "iMac",
"iMac20,1": "iMac",
"iMac20,2": "iMac",
"iMac21,2": "iMac",
"iMac21,1": "iMac",
"iMacPro1,1": "iMac Pro",
"PowerMac10,1": "Mac mini",
"PowerMac10,2": "Mac mini",
"Macmini1,1": "Mac mini",
"Macmini2,1": "Mac mini",
"Macmini3,1": "Mac mini",
"Macmini4,1": "Mac mini",
"Macmini5,1": "Mac mini",
"Macmini5,2": "Mac mini",
"Macmini5,3": "Mac mini",
"Macmini6,1": "Mac mini",
"Macmini6,2": "Mac mini",
"Macmini7,1": "Mac mini",
"Macmini8,1": "Mac mini",
"ADP3,2": "Mac mini",
"Macmini9,1": "Mac mini",
"Mac14,3": "Mac mini",
"Mac14,12": "Mac mini",
"MacPro1,1*": "Mac Pro",
"MacPro2,1": "Mac Pro",
"MacPro3,1": "Mac Pro",
"MacPro4,1": "Mac Pro",
"MacPro5,1": "Mac Pro",
"MacPro6,1": "Mac Pro",
"MacPro7,1": "Mac Pro",
"N/A*": "Power Macintosh",
"PowerMac1,1": "Power Macintosh",
"PowerMac3,1": "Power Macintosh",
"PowerMac3,3": "Power Macintosh",
"PowerMac3,4": "Power Macintosh",
"PowerMac3,5": "Power Macintosh",
"PowerMac3,6": "Power Macintosh",
"Mac13,1": "Mac Studio",
"Mac13,2": "Mac Studio",
"Mac14,10": "MacBook Pro",
"Mac14,12": "Mac mini",
"Mac14,13": "Mac Studio",
"Mac14,14": "Mac Studio",
"Mac14,15": "MacBook Air",
"Mac14,2": "MacBook Air",
"Mac14,3": "Mac mini",
"Mac14,5": "MacBook Pro",
"Mac14,6": "MacBook Pro",
"Mac14,7": "MacBook Pro",
"Mac14,8": "Mac Pro",
"Mac14,9": "MacBook Pro",
"Mac15,10": "MacBook Pro",
"Mac15,11": "MacBook Pro",
"Mac15,12": "MacBook Air",
"Mac15,13": "MacBook Air",
"Mac15,14": "Mac Studio",
"Mac15,3": "MacBook Pro",
"Mac15,4": "iMac",
"Mac15,5": "iMac",
"Mac15,6": "MacBook Pro",
"Mac15,7": "MacBook Pro",
"Mac15,8": "MacBook Pro",
"Mac15,9": "MacBook Pro",
"Mac16,1": "MacBook Pro",
"Mac16,10": "Mac mini",
"Mac16,11": "Mac mini",
"Mac16,12": "MacBook Air",
"Mac16,13": "MacBook Air",
"Mac16,2": "iMac",
"Mac16,3": "iMac",
"Mac16,5": "MacBook Pro",
"Mac16,6": "MacBook Pro",
"Mac16,7": "MacBook Pro",
"Mac16,8": "MacBook Pro",
"Mac16,9": "Mac Studio",
"Mac17,2": "MacBook Pro",
"Mac17,3": "MacBook Air",
"Mac17,4": "MacBook Air",
"Mac17,5": "MacBook Neo",
"Mac17,6": "MacBook Pro",
"Mac17,7": "MacBook Pro",
"Mac17,8": "MacBook Pro",
"Mac17,9": "MacBook Pro",
"MacBook1,1": "MacBook",
"MacBook10,1": "MacBook",
"MacBook2,1": "MacBook",
"MacBook3,1": "MacBook",
"MacBook4,1": "MacBook",
@@ -98,8 +57,8 @@
"MacBook7,1": "MacBook",
"MacBook8,1": "MacBook",
"MacBook9,1": "MacBook",
"MacBook10,1": "MacBook",
"MacBookAir1,1": "MacBook Air",
"MacBookAir10,1": "MacBook Air",
"MacBookAir2,1": "MacBook Air",
"MacBookAir3,1": "MacBook Air",
"MacBookAir3,2": "MacBook Air",
@@ -114,88 +73,163 @@
"MacBookAir8,1": "MacBook Air",
"MacBookAir8,2": "MacBook Air",
"MacBookAir9,1": "MacBook Air",
"MacBookAir10,1": "MacBook Air",
"Mac14,2": "MacBook Air",
"MacBookPro1,1": "MacBook Pro",
"MacBookPro1,2": "MacBook Pro",
"MacBookPro2,2": "MacBook Pro",
"MacBookPro2,1": "MacBook Pro",
"MacBookPro3,1": "MacBook Pro",
"MacBookPro4,1": "MacBook Pro",
"MacBookPro5,1": "MacBook Pro",
"MacBookPro5,2": "MacBook Pro",
"MacBookPro5,5": "MacBook Pro",
"MacBookPro5,4": "MacBook Pro",
"MacBookPro5,3": "MacBook Pro",
"MacBookPro7,1": "MacBook Pro",
"MacBookPro6,2": "MacBook Pro",
"MacBookPro6,1": "MacBook Pro",
"MacBookPro8,1": "MacBook Pro",
"MacBookPro8,2": "MacBook Pro",
"MacBookPro8,3": "MacBook Pro",
"MacBookPro9,2": "MacBook Pro",
"MacBookPro9,1": "MacBook Pro",
"MacBookPro10,1": "MacBook Pro",
"MacBookPro10,2": "MacBook Pro",
"MacBookPro11,1": "MacBook Pro",
"MacBookPro11,2": "MacBook Pro",
"MacBookPro11,3": "MacBook Pro",
"MacBookPro12,1": "MacBook Pro",
"MacBookPro11,4": "MacBook Pro",
"MacBookPro11,5": "MacBook Pro",
"MacBookPro12,1": "MacBook Pro",
"MacBookPro13,1": "MacBook Pro",
"MacBookPro13,2": "MacBook Pro",
"MacBookPro13,3": "MacBook Pro",
"MacBookPro14,1": "MacBook Pro",
"MacBookPro14,2": "MacBook Pro",
"MacBookPro14,3": "MacBook Pro",
"MacBookPro15,2": "MacBook Pro",
"MacBookPro15,1": "MacBook Pro",
"MacBookPro15,2": "MacBook Pro",
"MacBookPro15,3": "MacBook Pro",
"MacBookPro15,4": "MacBook Pro",
"MacBookPro16,1": "MacBook Pro",
"MacBookPro16,3": "MacBook Pro",
"MacBookPro16,2": "MacBook Pro",
"MacBookPro16,3": "MacBook Pro",
"MacBookPro16,4": "MacBook Pro",
"MacBookPro17,1": "MacBook Pro",
"MacBookPro18,3": "MacBook Pro",
"MacBookPro18,4": "MacBook Pro",
"MacBookPro18,1": "MacBook Pro",
"MacBookPro18,2": "MacBook Pro",
"Mac14,7": "MacBook Pro",
"Mac14,9": "MacBook Pro",
"Mac14,5": "MacBook Pro",
"Mac14,10": "MacBook Pro",
"Mac14,6": "MacBook Pro",
"PowerMac1,2": "Power Macintosh",
"PowerMac5,1": "Power Macintosh",
"PowerMac7,2": "Power Macintosh",
"PowerMac7,3": "Power Macintosh",
"PowerMac9,1": "Power Macintosh",
"PowerMac11,2": "Power Macintosh",
"MacBookPro18,3": "MacBook Pro",
"MacBookPro18,4": "MacBook Pro",
"MacBookPro2,1": "MacBook Pro",
"MacBookPro2,2": "MacBook Pro",
"MacBookPro3,1": "MacBook Pro",
"MacBookPro4,1": "MacBook Pro",
"MacBookPro5,1": "MacBook Pro",
"MacBookPro5,2": "MacBook Pro",
"MacBookPro5,3": "MacBook Pro",
"MacBookPro5,4": "MacBook Pro",
"MacBookPro5,5": "MacBook Pro",
"MacBookPro6,1": "MacBook Pro",
"MacBookPro6,2": "MacBook Pro",
"MacBookPro7,1": "MacBook Pro",
"MacBookPro8,1": "MacBook Pro",
"MacBookPro8,2": "MacBook Pro",
"MacBookPro8,3": "MacBook Pro",
"MacBookPro9,1": "MacBook Pro",
"MacBookPro9,2": "MacBook Pro",
"MacPro1,1": "Mac Pro",
"MacPro2,1": "Mac Pro",
"MacPro3,1": "Mac Pro",
"MacPro4,1": "Mac Pro",
"MacPro5,1": "Mac Pro",
"MacPro6,1": "Mac Pro",
"MacPro7,1": "Mac Pro",
"Macmini1,1": "Mac mini",
"Macmini2,1": "Mac mini",
"Macmini3,1": "Mac mini",
"Macmini4,1": "Mac mini",
"Macmini5,1": "Mac mini",
"Macmini5,2": "Mac mini",
"Macmini5,3": "Mac mini",
"Macmini6,1": "Mac mini",
"Macmini6,2": "Mac mini",
"Macmini7,1": "Mac mini",
"Macmini8,1": "Mac mini",
"Macmini9,1": "Mac mini",
"PowerBook1,1": "PowerBook",
"PowerBook2,1": "iBook",
"PowerBook2,2": "iBook",
"PowerBook3,1": "PowerBook",
"PowerBook3,2": "PowerBook",
"PowerBook3,3": "PowerBook",
"PowerBook3,4": "PowerBook",
"PowerBook3,5": "PowerBook",
"PowerBook6,1": "PowerBook",
"PowerBook4,1": "iBook",
"PowerBook4,2": "iBook",
"PowerBook4,3": "iBook",
"PowerBook5,1": "PowerBook",
"PowerBook6,2": "PowerBook",
"PowerBook5,2": "PowerBook",
"PowerBook5,3": "PowerBook",
"PowerBook6,4": "PowerBook",
"PowerBook5,4": "PowerBook",
"PowerBook5,5": "PowerBook",
"PowerBook6,8": "PowerBook",
"PowerBook5,6": "PowerBook",
"PowerBook5,7": "PowerBook",
"PowerBook5,8": "PowerBook",
"PowerBook5,9": "PowerBook",
"PowerBook6,1": "PowerBook",
"PowerBook6,2": "PowerBook",
"PowerBook6,3": "iBook",
"PowerBook6,4": "PowerBook",
"PowerBook6,5": "iBook",
"PowerBook6,7": "iBook",
"PowerBook6,8": "PowerBook",
"PowerMac1,1": "Power Macintosh",
"PowerMac1,2": "Power Macintosh",
"PowerMac10,1": "Mac mini",
"PowerMac10,2": "Mac mini",
"PowerMac11,2": "Power Macintosh",
"PowerMac12,1": "iMac",
"PowerMac2,1": "iMac",
"PowerMac2,2": "iMac",
"PowerMac3,1": "Mac Server",
"PowerMac3,3": "Power Macintosh",
"PowerMac3,4": "Power Macintosh",
"PowerMac3,5": "Power Macintosh",
"PowerMac3,6": "Power Macintosh",
"PowerMac4,1": "iMac",
"PowerMac4,2": "iMac",
"PowerMac4,4": "eMac",
"PowerMac4,5": "iMac",
"PowerMac5,1": "Power Macintosh",
"PowerMac6,1": "iMac",
"PowerMac6,3": "iMac",
"PowerMac6,4": "eMac",
"PowerMac7,2": "Power Macintosh",
"PowerMac7,3": "Power Macintosh",
"PowerMac8,1": "iMac",
"PowerMac8,2": "iMac",
"PowerMac9,1": "Power Macintosh",
"RackMac1,1": "Xserve",
"RackMac1,2": "Xserve",
"RackMac3,1": "Xserve",
"Xserve1,1": "Xserve",
"Xserve2,1": "Xserve",
"Xserve3,1": "Xserve"
}
"Xserve3,1": "Xserve",
"iMac,1": "iMac",
"iMac10,1": "iMac",
"iMac11,1": "iMac",
"iMac11,2": "iMac",
"iMac11,3": "iMac",
"iMac12,1": "iMac",
"iMac12,2": "iMac",
"iMac13,1": "iMac",
"iMac13,2": "iMac",
"iMac14,1": "iMac",
"iMac14,2": "iMac",
"iMac14,3": "iMac",
"iMac14,4": "iMac",
"iMac15,1": "iMac",
"iMac16,1": "iMac",
"iMac16,2": "iMac",
"iMac17,1": "iMac",
"iMac18,1": "iMac",
"iMac18,2": "iMac",
"iMac18,3": "iMac",
"iMac19,1": "iMac",
"iMac19,2": "iMac",
"iMac20,1": "iMac",
"iMac20,2": "iMac",
"iMac21,1": "iMac",
"iMac21,2": "iMac",
"iMac4,1": "iMac",
"iMac4,2": "iMac",
"iMac5,1": "iMac",
"iMac5,2": "iMac",
"iMac6,1": "iMac",
"iMac7,1": "iMac",
"iMac8,1": "iMac",
"iMac9,1": "iMac",
"iMacPro1,1": "iMac Pro"
}

View File

@@ -566,6 +566,17 @@ export const alertWebhookActions = pgTable("alertWebhookActions", {
lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable
});
export const trialNotifications = pgTable("trialNotifications", {
notificationId: serial("notificationId").primaryKey(),
subscriptionId: varchar("subscriptionId", { length: 255 })
.notNull()
.references(() => subscriptions.subscriptionId, {
onDelete: "cascade"
}),
notificationType: varchar("notificationType", { length: 50 }).notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
sentAt: bigint("sentAt", { mode: "number" }).notNull()
});
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -604,3 +615,12 @@ export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors
>;
export type AlertResources = InferSelectModel<typeof alertResources>;
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
export type AlertSites = InferSelectModel<typeof alertSites>;
export type AlertRules = InferSelectModel<typeof alertRules>;
export type AlertEmailActions = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipients = InferSelectModel<
typeof alertEmailRecipients
>;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;

View File

@@ -1,5 +1,6 @@
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";
@@ -11,8 +12,69 @@ 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).
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");
// 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");
// 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));
};
return DrizzleSqlite(sqlite, {
schema
});
@@ -23,7 +85,7 @@ export default db;
export const primaryDb = db;
export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0]
>[0];
>[0];
export const DB_TYPE: "pg" | "sqlite" = "sqlite";
function checkFileExists(filePath: string): boolean {

View File

@@ -21,6 +21,9 @@ import {
targetHealthCheck,
users
} from "./schema";
import { serial, varchar } from "drizzle-orm/mysql-core";
import { pgTable } from "drizzle-orm/pg-core";
import { bigint } from "zod";
export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }),
@@ -569,6 +572,19 @@ export const alertWebhookActions = sqliteTable("alertWebhookActions", {
lastSentAt: integer("lastSentAt")
});
export const trialNotifications = sqliteTable("trialNotifications", {
notificationId: integer("notificationId").primaryKey({
autoIncrement: true
}),
subscriptionId: text("subscriptionId")
.notNull()
.references(() => subscriptions.subscriptionId, {
onDelete: "cascade"
}),
notificationType: text("notificationType").notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
sentAt: integer("sentAt").notNull()
});
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -601,3 +617,10 @@ export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors
>;
export type AlertResources = InferSelectModel<typeof alertResources>;
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
export type AlertSites = InferSelectModel<typeof alertSites>;
export type AlertRule = InferSelectModel<typeof alertRules>;
export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;

View File

@@ -64,7 +64,7 @@ export const NotifyTrialExpiring = ({
<EmailText>
Some features and resources may now be
restricted or disconnected. To restore full
restricted. To restore full
access and continue using all the features
you had during your trial, please upgrade to
a paid plan.
@@ -85,7 +85,7 @@ export const NotifyTrialExpiring = ({
<strong>{orgName}</strong> will end on{" "}
<strong>{trialEndsAt}</strong>
{isLastDay
? " that's tomorrow!"
? " - that's tomorrow!"
: `, in ${daysRemaining} days`}
.
</EmailText>
@@ -93,8 +93,7 @@ export const NotifyTrialExpiring = ({
<EmailText>
After your trial ends, your account will be
moved to the free plan and some
functionality may be restricted or your
sites may disconnect.
functionality may be restricted.
</EmailText>
<EmailText>

View File

@@ -1,27 +1,153 @@
// stub
import logger from "@server/logger";
import { processAlerts } from "#dynamic/lib/alerts";
import {
db,
statusHistory,
targetHealthCheck,
targets,
resources,
Transaction,
logsDb
} from "@server/db";
import { eq } from "drizzle-orm";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
import {
fireResourceDegradedAlert,
fireResourceHealthyAlert,
fireResourceUnhealthyAlert,
fireResourceUnknownAlert
} from "./resourceEvents";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Fire a `health_check_healthy` alert for the given health check.
*
* Call this after a previously-failing health check has recovered so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the health check.
* @param healthCheckId - Numeric primary key of the health check.
* @param healthCheckName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireHealthCheckHealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string,
healthCheckName?: string | null,
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx?: unknown
trx: Transaction | typeof db = db
): Promise<void> {
return;
try {
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "healthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
}
await processAlerts({
eventType: "health_check_healthy",
orgId,
healthCheckId,
data: {
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
await processAlerts({
eventType: "health_check_toggle",
orgId,
healthCheckId,
data: {
healthCheckId,
status: "healthy",
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
err
);
}
}
/**
* Fire a `health_check_unhealthy` alert for the given health check.
*
* Call this after a health check has been detected as failing so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the health check.
* @param healthCheckId - Numeric primary key of the health check.
* @param healthCheckName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireHealthCheckUnhealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string,
healthCheckName?: string | null,
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx?: unknown
trx: Transaction | typeof db = db
): Promise<void> {
return;
try {
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
}
await processAlerts({
eventType: "health_check_unhealthy",
orgId,
healthCheckId,
data: {
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
await processAlerts({
eventType: "health_check_toggle",
orgId,
healthCheckId,
data: {
healthCheckId,
status: "unhealthy",
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireHealthCheckUnhealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
err
);
}
}
export async function fireHealthCheckUnknownAlert(
@@ -31,7 +157,137 @@ export async function fireHealthCheckUnknownAlert(
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx?: unknown
trx: Transaction | typeof db = db
): Promise<void> {
return;
try {
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "unknown",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
}
} catch (err) {
logger.error(
`fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`,
err
);
}
}
async function handleResource(
orgId: string,
healthCheckTargetId?: number | null,
send: boolean = true,
trx: Transaction | typeof db = db
) {
if (!healthCheckTargetId) {
return;
}
// we have targets lets get them
const [target] = await trx
.select()
.from(targets)
.where(eq(targets.targetId, healthCheckTargetId))
.limit(1);
if (!target) {
return;
}
const [resource] = await trx
.select()
.from(resources)
.where(eq(resources.resourceId, target.resourceId))
.limit(1);
if (!resource) {
return;
}
const otherTargets = await trx
.select({ hcHealth: targetHealthCheck.hcHealth })
.from(targets)
.innerJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
)
.where(eq(targets.resourceId, resource.resourceId));
let health = "healthy";
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
if (allUnknown) {
logger.debug(
`Marking resource ${resource.resourceId} as unknown because all health checks are disabled`
);
health = "unknown";
} else if (allHealthy) {
health = "healthy";
} else if (allUnhealthy) {
logger.debug(
`Marking resource ${resource.resourceId} as unhealthy because all targets are unhealthy`
);
health = "unhealthy";
} else {
logger.debug(
`Marking resource ${resource.resourceId} as degraded because some targets are unhealthy`
);
health = "degraded";
}
if (health != resource.health) {
// it changed
await trx
.update(resources)
.set({ health })
.where(eq(resources.resourceId, resource.resourceId));
if (health === "unknown") {
await fireResourceUnknownAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "unhealthy") {
await fireResourceUnhealthyAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "healthy") {
await fireResourceHealthyAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "degraded") {
await fireResourceDegradedAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
}
}
}

View File

@@ -1,26 +1,243 @@
import logger from "@server/logger";
import { processAlerts } from "#dynamic/lib/alerts";
import { db, logsDb, statusHistory, Transaction } from "@server/db";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Fire a `resource_healthy` alert for the given resource.
*
* Call this after a previously-unhealthy resource has recovered so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceHealthyAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx?: unknown
): Promise<void> {}
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "healthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_healthy",
orgId,
resourceId,
data: {
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "healthy",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* Fire a `resource_unhealthy` alert for the given resource.
*
* Call this after a resource has been detected as unhealthy so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceUnhealthyAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx?: unknown
): Promise<void> {}
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
export async function fireResourceToggleAlert(
if (!send) {
return;
}
await processAlerts({
eventType: "resource_unhealthy",
orgId,
resourceId,
data: {
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "unhealthy",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* Fire a `resource_degraded` alert for the given resource.
*
* Call this after a resource has been detected as degraded so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceDegradedAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx?: unknown
): Promise<void> {}
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "degraded",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_degraded",
orgId,
resourceId,
data: {
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "degraded",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* Fire a `resource_unknown` alert for the given resource.
*
* Call this when all health checks on a resource are disabled so that the
* resource status transitions to unknown.
*
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceUnknownAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "unknown",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "unknown",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}

View File

@@ -1,21 +1,156 @@
// stub
import logger from "@server/logger";
import { processAlerts } from "#dynamic/lib/alerts";
import {
db,
logsDb,
statusHistory,
targetHealthCheck,
Transaction
} from "@server/db";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
import { and, eq, inArray } from "drizzle-orm";
import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Fire a `site_online` alert for the given site.
*
* Call this after the site has been confirmed reachable / connected so that
* any matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the site.
* @param siteId - Numeric primary key of the site.
* @param siteName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireSiteOnlineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record<string, unknown>,
trx?: unknown
trx: Transaction | typeof db = db
): Promise<void> {
return;
try {
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId: orgId,
status: "online",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("site", siteId);
await processAlerts({
eventType: "site_online",
orgId,
siteId,
data: {
...(siteName != null ? { siteName } : {}),
...extra
}
});
await processAlerts({
eventType: "site_toggle",
orgId,
siteId,
data: {
siteId,
status: "online",
...(siteName != null ? { siteName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireSiteOnlineAlert: unexpected error for siteId ${siteId}`,
err
);
}
}
/**
* Fire a `site_offline` alert for the given site.
*
* Call this after the site has been detected as unreachable / disconnected so
* that any matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the site.
* @param siteId - Numeric primary key of the site.
* @param siteName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireSiteOfflineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record<string, unknown>,
trx?: unknown
trx: Transaction | typeof db = db
): Promise<void> {
return;
}
try {
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId: orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("site", siteId);
const unhealthyHealthChecks = await trx
.update(targetHealthCheck)
.set({ hcHealth: "unhealthy" })
.where(
and(
eq(targetHealthCheck.orgId, orgId),
eq(targetHealthCheck.siteId, siteId),
eq(targetHealthCheck.hcEnabled, true) // only effect the ones that are enabled
)
)
.returning();
for (const healthCheck of unhealthyHealthChecks) {
logger.info(
`Marking health check ${healthCheck.targetHealthCheckId} unhealthy due to site ${siteId} being marked offline`
);
await fireHealthCheckUnhealthyAlert(
healthCheck.orgId,
healthCheck.targetHealthCheckId,
healthCheck.name,
healthCheck.targetId, // for the resource if we have one
undefined,
true,
trx
);
}
await processAlerts({
eventType: "site_offline",
orgId,
siteId,
data: {
...(siteName != null ? { siteName } : {}),
...extra
}
});
await processAlerts({
eventType: "site_toggle",
orgId,
siteId,
data: {
siteId,
status: "offline",
...(siteName != null ? { siteName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireSiteOfflineAlert: unexpected error for siteId ${siteId}`,
err
);
}
}

View File

@@ -1,3 +1,4 @@
export * from "./events/siteEvents";
export * from "./events/healthCheckEvents";
export * from "./events/resourceEvents";
export * from "./processAlerts";

View File

@@ -0,0 +1,5 @@
import { AlertContext } from "@server/routers/alertRule/types";
export async function processAlerts(context: AlertContext): Promise<void> {
return;
}

View File

@@ -1,8 +1,9 @@
export async function getOrgTierData(
orgId: string
): Promise<{ tier: string | null; active: boolean }> {
): Promise<{ tier: string | null; active: boolean; isTrial: boolean }> {
const tier = null;
const active = false;
const isTrial = false;
return { tier, active };
return { tier, active, isTrial };
}

View File

@@ -25,7 +25,7 @@ export const tier1LimitSet: LimitSet = {
export const tier2LimitSet: LimitSet = {
[FeatureId.USERS]: {
value: 100,
value: 50,
description: "Team limit"
},
[FeatureId.SITES]: {
@@ -48,7 +48,7 @@ export const tier2LimitSet: LimitSet = {
export const tier3LimitSet: LimitSet = {
[FeatureId.USERS]: {
value: 500,
value: 250,
description: "Business limit"
},
[FeatureId.SITES]: {

View File

@@ -64,7 +64,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.SIEM]: ["enterprise"],
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
};

View File

@@ -125,47 +125,28 @@ export async function updateClientResources(
const existingSiteIds = existingResource?.networkId
? await trx
.select({ siteId: sites.siteId })
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(eq(siteNetworks.networkId, existingResource.networkId))
: [];
let allSites: { siteId: number }[] = [];
const allSites: { siteId: number }[] = [];
if (resourceData.site) {
let siteSingle;
const resourceSiteId = resourceData.site;
if (resourceSiteId) {
// Look up site by niceId
[siteSingle] = await trx
.select({ siteId: sites.siteId })
.from(sites)
.where(
and(
eq(sites.niceId, resourceSiteId),
eq(sites.orgId, orgId)
)
// Look up site by niceId
const [siteSingle] = await trx
.select({ siteId: sites.siteId })
.from(sites)
.where(
and(
eq(sites.niceId, resourceData.site),
eq(sites.orgId, orgId)
)
.limit(1);
} else if (siteId) {
// Use the provided siteId directly, but verify it belongs to the org
[siteSingle] = await trx
.select({ siteId: sites.siteId })
.from(sites)
.where(
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
)
.limit(1);
} else {
throw new Error(`Target site is required`);
)
.limit(1);
if (siteSingle) {
allSites.push(siteSingle);
}
if (!siteSingle) {
throw new Error(
`Site not found: ${resourceSiteId} in org ${orgId}`
);
}
allSites.push(siteSingle);
}
if (resourceData.sites) {
@@ -180,15 +161,31 @@ export async function updateClientResources(
)
)
.limit(1);
if (!site) {
throw new Error(
`Site not found: ${siteId} in org ${orgId}`
);
if (site) {
allSites.push(site);
}
allSites.push(site);
}
}
if (siteId && allSites.length === 0) {
// only add if there are not provided sites
// Use the provided siteId directly, but verify it belongs to the org
const [siteSingle] = await trx
.select({ siteId: sites.siteId })
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
if (siteSingle) {
allSites.push(siteSingle);
}
}
if (allSites.length === 0) {
throw new Error(
`No valid sites found for private private resource ${resourceNiceId} in org ${orgId}`
);
}
if (existingResource) {
let domainInfo:
| { subdomain: string | null; domainId: string }
@@ -215,9 +212,17 @@ export async function updateClientResources(
enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null,
disableIcmp: resourceData["disable-icmp"],
tcpPortRangeString: resourceData["tcp-ports"],
udpPortRangeString: resourceData["udp-ports"],
disableIcmp:
resourceData["disable-icmp"] ||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
tcpPortRangeString:
resourceData.mode == "http"
? "443,80"
: resourceData["tcp-ports"],
udpPortRangeString:
resourceData.mode == "http"
? ""
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null
@@ -397,9 +402,17 @@ export async function updateClientResources(
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null,
aliasAddress: aliasAddress,
disableIcmp: resourceData["disable-icmp"],
tcpPortRangeString: resourceData["tcp-ports"],
udpPortRangeString: resourceData["udp-ports"],
disableIcmp:
resourceData["disable-icmp"] ||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
tcpPortRangeString:
resourceData.mode == "http"
? "443,80"
: resourceData["tcp-ports"],
udpPortRangeString:
resourceData.mode == "http"
? ""
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null

View File

@@ -34,7 +34,7 @@ import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isValidRegionId } from "@server/db/regions";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts";
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
import { tierMatrix } from "../billing/tierMatrix";
export type ProxyResourcesResults = {
@@ -165,7 +165,8 @@ export async function updateProxyResources(
hcStatus: healthcheckData?.status,
hcHealth: "unknown",
hcHealthyThreshold: healthcheckData?.["healthy-threshold"],
hcUnhealthyThreshold: healthcheckData?.["unhealthy-threshold"]
hcUnhealthyThreshold:
healthcheckData?.["unhealthy-threshold"]
})
.returning();
@@ -544,8 +545,10 @@ export async function updateProxyResources(
healthcheckData?.["follow-redirects"],
hcMethod: healthcheckData?.method,
hcStatus: healthcheckData?.status,
hcHealthyThreshold: healthcheckData?.["healthy-threshold"],
hcUnhealthyThreshold: healthcheckData?.["unhealthy-threshold"]
hcHealthyThreshold:
healthcheckData?.["healthy-threshold"],
hcUnhealthyThreshold:
healthcheckData?.["unhealthy-threshold"]
})
.where(
eq(
@@ -1120,8 +1123,10 @@ function checkIfHealthcheckChanged(
JSON.stringify(incoming.hcHeaders)
)
return true;
if (existing.hcHealthyThreshold !== incoming.hcHealthyThreshold) return true;
if (existing.hcUnhealthyThreshold !== incoming.hcUnhealthyThreshold) return true;
if (existing.hcHealthyThreshold !== incoming.hcHealthyThreshold)
return true;
if (existing.hcUnhealthyThreshold !== incoming.hcUnhealthyThreshold)
return true;
return false;
}
@@ -1184,7 +1189,11 @@ async function getDomainId(
orgId: string,
fullDomain: string,
trx: Transaction
): Promise<{ subdomain: string | null; domainId: string; wildcard: boolean } | null> {
): Promise<{
subdomain: string | null;
domainId: string;
wildcard: boolean;
} | null> {
const isWildcardFullDomain = fullDomain.startsWith("*.");
const possibleDomains = await trx

View File

@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.18.0";
export const APP_VERSION = "1.18.2";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import { db, logsDb, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm";
import cache from "@server/lib/cache";
import { regionalCache as cache } from "@server/private/lib/cache";
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
@@ -63,7 +63,7 @@ export async function invalidateStatusHistoryCache(
entityId: number
): Promise<void> {
const prefix = `statusHistory:${entityType}:${entityId}:`;
const keys = cache.keys().filter((k) => k.startsWith(prefix));
const keys = await cache.keysWithPrefix(prefix);
if (keys.length > 0) {
await cache.del(keys);
}

View File

@@ -535,6 +535,24 @@ export class TraefikConfigManager {
if (match && match[1]) {
domains.add(match[1]);
}
// Match HostRegexp(`^[^.]+\.parent.domain$`) generated for wildcard resources
const hostRegexpMatch = router.rule.match(
/HostRegexp\(`([^`]+)`\)/
);
if (hostRegexpMatch && hostRegexpMatch[1]) {
const innerRegex = hostRegexpMatch[1];
// Pattern is always ^[^.]+\.PARENT_DOMAIN$ where dots are escaped as \.
const domainMatch = innerRegex.match(
/^\^\[\^\.\]\+\\\.(.+)\$$/
);
if (domainMatch && domainMatch[1]) {
const parentDomain = domainMatch[1].replace(
/\\\./g,
"."
);
domains.add(`*.${parentDomain}`);
}
}
}
}
}

View File

@@ -12,6 +12,7 @@
*/
import fs from "fs";
import path from "path";
import crypto from "crypto";
import {
certificates,
@@ -250,71 +251,129 @@ function extractFirstCert(pemBundle: string): string | null {
return match ? match[0] : null;
}
async function syncAcmeCerts(
acmeJsonPath: string,
resolver: string
): Promise<void> {
let raw: string;
try {
raw = fs.readFileSync(acmeJsonPath, "utf8");
} catch (err) {
logger.debug(`acmeCertSync: could not read ${acmeJsonPath}: ${err}`);
return;
/**
* Determine whether an ACME cert entry represents a wildcard cert by checking
* both the primary domain (`main`) and the SANs. Some ACME clients (notably
* Traefik) store the bare apex in `main` and only put the wildcard form in
* `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]).
*/
function detectWildcard(
main: string,
sans: string[] | undefined
): { wildcard: boolean; wildcardSan: string | null } {
if (main.startsWith("*.")) {
return { wildcard: true, wildcardSan: null };
}
let acmeJson: AcmeJson;
try {
acmeJson = JSON.parse(raw);
} catch (err) {
logger.debug(`acmeCertSync: could not parse acme.json: ${err}`);
return;
if (Array.isArray(sans)) {
for (const san of sans) {
if (typeof san !== "string") continue;
if (san === `*.${main}` || san.startsWith("*.")) {
return { wildcard: true, wildcardSan: san };
}
}
}
return { wildcard: false, wildcardSan: null };
}
const resolverData = acmeJson[resolver];
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
interface HttpCert {
wildcard: boolean;
altName: string;
certName: string;
commonName: string;
certFile: string;
keyFile: string;
}
async function syncAcmeCertsFromHttp(endpoint: string): Promise<void> {
let response: Response;
try {
response = await fetch(endpoint);
} catch (err) {
logger.debug(
`acmeCertSync: no certificates found for resolver "${resolver}"`
`acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}`
);
return;
}
for (const cert of resolverData.Certificates) {
const domain = cert.domain?.main;
const wildcard = domain.startsWith("*.");
if (!domain) {
logger.debug(`acmeCertSync: skipping cert with missing domain`);
continue;
}
if (!cert.certificate || !cert.key) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
);
continue;
}
const certPem = Buffer.from(cert.certificate, "base64").toString(
"utf8"
if (!response.ok) {
logger.debug(
`acmeCertSync: HTTP endpoint returned status ${response.status}`
);
const keyPem = Buffer.from(cert.key, "base64").toString("utf8");
return;
}
if (!certPem.trim() || !keyPem.trim()) {
let httpCerts: HttpCert[];
try {
httpCerts = await response.json();
} catch (err) {
logger.debug(
`acmeCertSync: could not parse JSON from HTTP endpoint: ${err}`
);
return;
}
if (!Array.isArray(httpCerts) || httpCerts.length === 0) {
logger.debug(
`acmeCertSync: no certificates returned from HTTP endpoint`
);
return;
}
for (const cert of httpCerts) {
const domain = cert?.certName;
if (!domain || typeof domain !== "string") {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode`
`acmeCertSync: skipping HTTP cert with missing certName`
);
continue;
}
// Check if cert already exists in DB
const certPem = cert.certFile;
const keyPem = cert.keyFile;
if (!certPem?.trim() || !keyPem?.trim()) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - empty certFile or keyFile`
);
continue;
}
const firstCertPemForValidation = extractFirstCert(certPem);
if (!firstCertPemForValidation) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - no PEM certificate block found`
);
continue;
}
let validatedX509: crypto.X509Certificate;
try {
validatedX509 = new crypto.X509Certificate(
firstCertPemForValidation
);
} catch (err) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - invalid X.509 certificate: ${err}`
);
continue;
}
try {
crypto.createPrivateKey(keyPem);
} catch (err) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - invalid private key: ${err}`
);
continue;
}
const wildcard = cert.wildcard ?? false;
const existing = await db
.select()
.from(certificates)
.where(
and(
eq(certificates.domain, domain)
)
)
.where(eq(certificates.domain, domain))
.limit(1);
let oldCertPem: string | null = null;
@@ -326,12 +385,287 @@ async function syncAcmeCerts(
existing[0].certFile,
config.getRawConfig().server.secret!
);
if (storedCertPem === certPem) {
logger.debug(
`acmeCertSync: cert for ${domain} is unchanged, skipping`
const wildcardUnchanged = existing[0].wildcard === wildcard;
if (storedCertPem === certPem && wildcardUnchanged) {
continue;
}
oldCertPem = storedCertPem;
if (existing[0].keyFile) {
try {
oldKeyPem = decrypt(
existing[0].keyFile,
config.getRawConfig().server.secret!
);
} catch (keyErr) {
logger.debug(
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
);
}
}
} catch (err) {
logger.debug(
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
);
}
}
let expiresAt: number | null = null;
try {
expiresAt = Math.floor(
new Date(validatedX509.validTo).getTime() / 1000
);
} catch (err) {
logger.debug(
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
);
}
const encryptedCert = encrypt(
certPem,
config.getRawConfig().server.secret!
);
const encryptedKey = encrypt(
keyPem,
config.getRawConfig().server.secret!
);
const now = Math.floor(Date.now() / 1000);
const domainId = await findDomainId(domain);
if (domainId) {
logger.debug(
`acmeCertSync: resolved domainId "${domainId}" for HTTP cert domain "${domain}"`
);
} else {
logger.debug(
`acmeCertSync: no matching domain record found for HTTP cert domain "${domain}"`
);
}
if (existing.length > 0) {
logger.debug(
`acmeCertSync: updating existing certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db
.update(certificates)
.set({
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
updatedAt: now,
wildcard,
...(domainId !== null && { domainId })
})
.where(eq(certificates.domain, domain));
await pushCertUpdateToAffectedNewts(
domain,
domainId,
oldCertPem,
oldKeyPem
);
} else {
logger.debug(
`acmeCertSync: inserting new certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db.insert(certificates).values({
domain,
domainId,
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
createdAt: now,
updatedAt: now,
wildcard
});
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
}
}
}
function findAcmeJsonFiles(dirPath: string): string[] {
const results: string[] = [];
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dirPath, { withFileTypes: true });
} catch (err) {
logger.warn(
`acmeCertSync: could not read directory "${dirPath}": ${err}`
);
return results;
}
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
results.push(...findAcmeJsonFiles(fullPath));
} else if (entry.isFile()) {
// check if it is a json file
if (entry.name.endsWith(".json")) {
let raw: string;
try {
raw = fs.readFileSync(fullPath, "utf8");
} catch (err) {
logger.warn(
`acmeCertSync: could not read file "${fullPath}": ${err}`
);
continue;
}
let parsed: any;
try {
parsed = JSON.parse(raw);
} catch (err) {
logger.warn(
`acmeCertSync: could not parse "${fullPath}" as JSON: ${err}`
);
continue;
}
}
results.push(fullPath);
}
}
return results;
}
async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
let raw: string;
try {
raw = fs.readFileSync(acmeJsonPath, "utf8");
} catch (err) {
logger.warn(`acmeCertSync: could not read "${acmeJsonPath}": ${err}`);
return;
}
let acmeJson: AcmeJson;
try {
acmeJson = JSON.parse(raw);
} catch (err) {
logger.warn(
`acmeCertSync: could not parse "${acmeJsonPath}" as JSON: ${err}`
);
return;
}
const resolvers = Object.keys(acmeJson || {});
if (resolvers.length === 0) {
logger.debug(`acmeCertSync: no resolvers found in acme.json`);
return;
}
// Collect certificates from every resolver. If the same domain appears in
// multiple resolvers, the last one wins (resolvers iterated in object order).
const allCerts: AcmeCert[] = [];
for (const resolver of resolvers) {
const resolverData = acmeJson[resolver];
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
logger.debug(
`acmeCertSync: no certificates found for resolver "${resolver}"`
);
continue;
}
logger.debug(
`acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"`
);
for (const cert of resolverData.Certificates) {
allCerts.push(cert);
}
}
for (const cert of allCerts) {
const domain = cert?.domain?.main;
if (!domain || typeof domain !== "string") {
logger.debug(`acmeCertSync: skipping cert with missing domain`);
continue;
}
const { wildcard } = detectWildcard(domain, cert.domain?.sans);
if (!cert.certificate || !cert.key) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
);
continue;
}
let certPem: string;
let keyPem: string;
try {
certPem = Buffer.from(cert.certificate, "base64").toString("utf8");
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - failed to base64-decode cert/key: ${err}`
);
continue;
}
if (!certPem.trim() || !keyPem.trim()) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode`
);
continue;
}
// Validate that the decoded data actually parses as a real X.509 cert
// before we touch the database. This prevents importing partially-written
// or corrupted entries from acme.json.
const firstCertPemForValidation = extractFirstCert(certPem);
if (!firstCertPemForValidation) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - no PEM certificate block found`
);
continue;
}
let validatedX509: crypto.X509Certificate;
try {
validatedX509 = new crypto.X509Certificate(
firstCertPemForValidation
);
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - invalid X.509 certificate: ${err}`
);
continue;
}
// Sanity-check the private key parses too
try {
crypto.createPrivateKey(keyPem);
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - invalid private key: ${err}`
);
continue;
}
// Check if cert already exists in DB
const existing = await db
.select()
.from(certificates)
.where(and(eq(certificates.domain, domain)))
.limit(1);
let oldCertPem: string | null = null;
let oldKeyPem: string | null = null;
if (existing.length > 0 && existing[0].certFile) {
try {
const storedCertPem = decrypt(
existing[0].certFile,
config.getRawConfig().server.secret!
);
const wildcardUnchanged = existing[0].wildcard === wildcard;
if (storedCertPem === certPem && wildcardUnchanged) {
// logger.debug(
// `acmeCertSync: cert for ${domain} is unchanged, skipping`
// );
continue;
}
// Cert has changed; capture old values so we can send a correct
// update message to the newt after the DB write.
oldCertPem = storedCertPem;
@@ -355,18 +689,16 @@ async function syncAcmeCerts(
}
}
// Parse cert expiry from the first cert in the PEM bundle
// Parse cert expiry from the validated X.509 certificate
let expiresAt: number | null = null;
const firstCertPem = extractFirstCert(certPem);
if (firstCertPem) {
try {
const x509 = new crypto.X509Certificate(firstCertPem);
expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000);
} catch (err) {
logger.debug(
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
);
}
try {
expiresAt = Math.floor(
new Date(validatedX509.validTo).getTime() / 1000
);
} catch (err) {
logger.debug(
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
);
}
const encryptedCert = encrypt(
@@ -468,21 +800,63 @@ export function initAcmeCertSync(): void {
const acmeJsonPath =
privateConfigData.acme?.acme_json_path ??
"config/letsencrypt/acme.json";
const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
const httpEndpoint = privateConfigData.acme?.acme_http_endpoint;
logger.debug(
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms`
);
if (httpEndpoint) {
logger.debug(
`acmeCertSync: also syncing from HTTP endpoint "${httpEndpoint}" every ${intervalMs}ms`
);
}
const runSync = () => {
if (httpEndpoint) {
syncAcmeCertsFromHttp(httpEndpoint).catch((err) => {
logger.error(`acmeCertSync: error during HTTP sync: ${err}`);
});
} else {
// only run the file-based sync if the HTTP endpoint is not configured, to avoid doubling up
let stat: fs.Stats | null = null;
try {
stat = fs.statSync(acmeJsonPath);
} catch (err) {
logger.warn(
`acmeCertSync: cannot stat path "${acmeJsonPath}": ${err}`
);
return;
}
if (stat.isDirectory()) {
const files = findAcmeJsonFiles(acmeJsonPath);
if (files.length === 0) {
logger.debug(
`acmeCertSync: no acme.json files found in directory "${acmeJsonPath}"`
);
return;
}
logger.debug(
`acmeCertSync: found ${files.length} acme.json file(s) in directory "${acmeJsonPath}"`
);
for (const file of files) {
syncAcmeCerts(file).catch((err) => {
logger.error(
`acmeCertSync: error during sync of "${file}": ${err}`
);
});
}
} else {
syncAcmeCerts(acmeJsonPath).catch((err) => {
logger.error(`acmeCertSync: error during sync: ${err}`);
});
}
}
};
// Run immediately on init, then on the configured interval
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
logger.error(`acmeCertSync: error during initial sync: ${err}`);
});
runSync();
setInterval(() => {
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
logger.error(`acmeCertSync: error during sync: ${err}`);
});
}, intervalMs);
setInterval(runSync, intervalMs);
}

View File

@@ -1,306 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
import {
db,
statusHistory,
targetHealthCheck,
targets,
resources,
Transaction,
logsDb
} from "@server/db";
import { eq } from "drizzle-orm";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
import {
fireResourceDegradedAlert,
fireResourceHealthyAlert,
fireResourceUnhealthyAlert,
fireResourceUnknownAlert
} from "./resourceEvents";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Fire a `health_check_healthy` alert for the given health check.
*
* Call this after a previously-failing health check has recovered so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the health check.
* @param healthCheckId - Numeric primary key of the health check.
* @param healthCheckName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireHealthCheckHealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "healthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
}
await processAlerts({
eventType: "health_check_healthy",
orgId,
healthCheckId,
data: {
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
await processAlerts({
eventType: "health_check_toggle",
orgId,
healthCheckId,
data: {
healthCheckId,
status: "healthy",
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
err
);
}
}
/**
* Fire a `health_check_unhealthy` alert for the given health check.
*
* Call this after a health check has been detected as failing so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the health check.
* @param healthCheckId - Numeric primary key of the health check.
* @param healthCheckName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireHealthCheckUnhealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
}
await processAlerts({
eventType: "health_check_unhealthy",
orgId,
healthCheckId,
data: {
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
await processAlerts({
eventType: "health_check_toggle",
orgId,
healthCheckId,
data: {
healthCheckId,
status: "unhealthy",
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireHealthCheckUnhealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
err
);
}
}
export async function fireHealthCheckUnknownAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "unknown",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
}
} catch (err) {
logger.error(
`fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`,
err
);
}
}
async function handleResource(
orgId: string,
healthCheckTargetId?: number | null,
send: boolean = true,
trx: Transaction | typeof db = db
) {
if (!healthCheckTargetId) {
return;
}
// we have targets lets get them
const [target] = await trx
.select()
.from(targets)
.where(eq(targets.targetId, healthCheckTargetId))
.limit(1);
if (!target) {
return;
}
const [resource] = await trx
.select()
.from(resources)
.where(eq(resources.resourceId, target.resourceId))
.limit(1);
if (!resource) {
return;
}
const otherTargets = await trx
.select({ hcHealth: targetHealthCheck.hcHealth })
.from(targets)
.innerJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
)
.where(eq(targets.resourceId, resource.resourceId));
let health = "healthy";
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
if (allUnknown) {
logger.debug(
`Marking resource ${resource.resourceId} as unknown because all health checks are disabled`
);
health = "unknown";
} else if (allHealthy) {
health = "healthy";
} else if (allUnhealthy) {
logger.debug(
`Marking resource ${resource.resourceId} as unhealthy because all targets are unhealthy`
);
health = "unhealthy";
} else {
logger.debug(
`Marking resource ${resource.resourceId} as degraded because some targets are unhealthy`
);
health = "degraded";
}
if (health != resource.health) {
// it changed
await trx
.update(resources)
.set({ health })
.where(eq(resources.resourceId, resource.resourceId));
if (health === "unknown") {
await fireResourceUnknownAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "unhealthy") {
await fireResourceUnhealthyAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "healthy") {
await fireResourceHealthyAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "degraded") {
await fireResourceDegradedAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
}
}
}

View File

@@ -1,256 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
import { db, logsDb, statusHistory, Transaction } from "@server/db";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Fire a `resource_healthy` alert for the given resource.
*
* Call this after a previously-unhealthy resource has recovered so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceHealthyAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "healthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_healthy",
orgId,
resourceId,
data: {
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "healthy",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* Fire a `resource_unhealthy` alert for the given resource.
*
* Call this after a resource has been detected as unhealthy so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceUnhealthyAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_unhealthy",
orgId,
resourceId,
data: {
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "unhealthy",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* Fire a `resource_degraded` alert for the given resource.
*
* Call this after a resource has been detected as degraded so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceDegradedAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "degraded",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_degraded",
orgId,
resourceId,
data: {
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "degraded",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* Fire a `resource_unknown` alert for the given resource.
*
* Call this when all health checks on a resource are disabled so that the
* resource status transitions to unknown.
*
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceUnknownAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "unknown",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "unknown",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}

View File

@@ -1,169 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
import {
db,
logsDb,
statusHistory,
targetHealthCheck,
Transaction
} from "@server/db";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
import { and, eq, inArray } from "drizzle-orm";
import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Fire a `site_online` alert for the given site.
*
* Call this after the site has been confirmed reachable / connected so that
* any matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the site.
* @param siteId - Numeric primary key of the site.
* @param siteName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireSiteOnlineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId: orgId,
status: "online",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("site", siteId);
await processAlerts({
eventType: "site_online",
orgId,
siteId,
data: {
...(siteName != null ? { siteName } : {}),
...extra
}
});
await processAlerts({
eventType: "site_toggle",
orgId,
siteId,
data: {
siteId,
status: "online",
...(siteName != null ? { siteName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireSiteOnlineAlert: unexpected error for siteId ${siteId}`,
err
);
}
}
/**
* Fire a `site_offline` alert for the given site.
*
* Call this after the site has been detected as unreachable / disconnected so
* that any matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the site.
* @param siteId - Numeric primary key of the site.
* @param siteName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireSiteOfflineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId: orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("site", siteId);
const unhealthyHealthChecks = await trx
.update(targetHealthCheck)
.set({ hcHealth: "unhealthy" })
.where(
and(
eq(targetHealthCheck.orgId, orgId),
eq(targetHealthCheck.siteId, siteId),
eq(targetHealthCheck.hcEnabled, true) // only effect the ones that are enabled
)
)
.returning();
for (const healthCheck of unhealthyHealthChecks) {
logger.info(
`Marking health check ${healthCheck.targetHealthCheckId} unhealthy due to site ${siteId} being marked offline`
);
await fireHealthCheckUnhealthyAlert(
healthCheck.orgId,
healthCheck.targetHealthCheckId,
healthCheck.name,
healthCheck.targetId, // for the resource if we have one
undefined,
true,
trx
);
}
await processAlerts({
eventType: "site_offline",
orgId,
siteId,
data: {
...(siteName != null ? { siteName } : {}),
...extra
}
});
await processAlerts({
eventType: "site_toggle",
orgId,
siteId,
data: {
siteId,
status: "offline",
...(siteName != null ? { siteName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireSiteOfflineAlert: unexpected error for siteId ${siteId}`,
err
);
}
}

View File

@@ -14,6 +14,3 @@
export * from "./processAlerts";
export * from "./sendAlertWebhook";
export * from "./sendAlertEmail";
export * from "./events/siteEvents";
export * from "./events/healthCheckEvents";
export * from "./events/resourceEvents";

View File

@@ -42,17 +42,23 @@ export async function sendAlertWebhook(
webhookConfig: WebhookAlertConfig,
context: AlertContext
): Promise<void> {
const payload = {
event: context.eventType,
timestamp: new Date().toISOString(),
status: deriveStatus(context.eventType, context.data),
data: {
orgId: context.orgId,
...context.data
}
};
const eventType = context.eventType;
const timestamp = new Date().toISOString();
const status = deriveStatus(eventType, context.data);
const data = { orgId: context.orgId, ...context.data };
let body: string;
if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) {
body = renderTemplate(webhookConfig.bodyTemplate, {
event: eventType,
timestamp,
status,
data
});
} else {
body = JSON.stringify({ event: eventType, timestamp, status, data });
}
const body = JSON.stringify(payload);
const headers = buildHeaders(webhookConfig);
let lastError: Error | undefined;
@@ -217,3 +223,52 @@ function buildHeaders(
return headers;
}
// ---------------------------------------------------------------------------
// Body template rendering
// ---------------------------------------------------------------------------
interface TemplateContext {
event: string;
timestamp: string;
status: string;
data: Record<string, unknown>;
}
/**
* Render a body template with {{event}}, {{timestamp}}, {{status}}, and
* {{data}} placeholders, mirroring the logic in HttpLogDestination.
*
* {{data}} is replaced first (as raw JSON) so that any literal "{{…}}"
* strings inside data values are not re-expanded.
*/
function renderTemplate(template: string, ctx: TemplateContext): string {
const rendered = template
.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data))
.replace(/\{\{event\}\}/g, escapeJsonString(ctx.event))
.replace(/\{\{timestamp\}\}/g, escapeJsonString(ctx.timestamp))
.replace(/\{\{status\}\}/g, escapeJsonString(ctx.status));
// Validate the rendered result is valid JSON; if not, log a warning and
// fall back to the default payload so the webhook still fires.
try {
JSON.parse(rendered);
return rendered;
} catch {
logger.warn(
`sendAlertWebhook: body template produced invalid JSON for event ` +
`"${ctx.event}" destined for a webhook. Falling back to default ` +
`payload. Check that {{data}} is NOT wrapped in quotes in your template.`
);
return JSON.stringify({
event: ctx.event,
timestamp: ctx.timestamp,
status: ctx.status,
data: ctx.data
});
}
}
function escapeJsonString(value: string): string {
return JSON.stringify(value).slice(1, -1);
}

View File

@@ -45,6 +45,10 @@ export interface WebhookAlertConfig {
headers?: Array<{ key: string; value: string }>;
/** HTTP method (default POST) */
method?: string;
/** Whether to use a custom body template */
useBodyTemplate?: boolean;
/** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */
bodyTemplate?: string;
}
// ---------------------------------------------------------------------------
@@ -60,4 +64,4 @@ export interface AlertContext {
healthCheckId?: number;
/** Human-readable context data included in emails and webhook payloads */
data: Record<string, unknown>;
}
}

View File

@@ -19,12 +19,13 @@ import { eq, and, ne } from "drizzle-orm";
export async function getOrgTierData(
orgId: string
): Promise<{ tier: Tier | null; active: boolean }> {
): Promise<{ tier: Tier | null; active: boolean; isTrial: boolean }> {
let tier: Tier | null = null;
let active = false;
let isTrial = false;
if (build !== "saas") {
return { tier, active };
return { tier, active, isTrial };
}
try {
@@ -35,7 +36,7 @@ export async function getOrgTierData(
.limit(1);
if (!org) {
return { tier, active };
return { tier, active, isTrial };
}
let orgIdToUse = org.orgId;
@@ -44,7 +45,7 @@ export async function getOrgTierData(
logger.warn(
`Org ${orgId} is not a billing org and does not have a billingOrgId`
);
return { tier, active };
return { tier, active, isTrial };
}
orgIdToUse = org.billingOrgId;
}
@@ -57,7 +58,7 @@ export async function getOrgTierData(
.limit(1);
if (!customer) {
return { tier, active };
return { tier, active, isTrial };
}
// Query for active subscriptions that are not license type
@@ -84,11 +85,13 @@ export async function getOrgTierData(
tier = subscription.type;
active = true;
}
isTrial = subscription.trial ?? false;
}
} catch (error) {
// If org not found or error occurs, return null tier and inactive
// This is acceptable behavior as per the function signature
}
return { tier, active };
return { tier, active, isTrial };
}

View File

@@ -13,7 +13,7 @@
import NodeCache from "node-cache";
import logger from "@server/logger";
import { redisManager } from "@server/private/lib/redis";
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
// Create local cache with maxKeys limit to prevent memory leaks
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
@@ -298,3 +298,147 @@ class AdaptiveCache {
// Export singleton instance
export const cache = new AdaptiveCache();
export default cache;
/**
* Regional adaptive cache backed by the in-cluster Redis instance.
* Falls back to a local NodeCache when the regional Redis is unavailable.
* Use this for data that is regional in nature (e.g. status history) so
* reads are served from the same cluster the user is hitting.
*/
const regionalLocalCache = new NodeCache({
stdTTL: 3600,
checkperiod: 120,
maxKeys: 10000
});
class RegionalAdaptiveCache {
private useRedis(): boolean {
return (
regionalRedisManager.isRedisEnabled() &&
regionalRedisManager.getHealthStatus().isHealthy
);
}
async set(key: string, value: any, ttl?: number): Promise<boolean> {
const effectiveTtl = ttl === 0 ? undefined : ttl;
const redisTtl = ttl === 0 ? undefined : (ttl ?? 3600);
if (this.useRedis()) {
try {
const serialized = JSON.stringify(value);
const success = await regionalRedisManager.set(
key,
serialized,
redisTtl
);
if (success) {
logger.debug(`[regional] Set key in Redis: ${key}`);
return true;
}
} catch (error) {
logger.error(
`[regional] Redis set error for key ${key}:`,
error
);
}
}
const success = regionalLocalCache.set(key, value, effectiveTtl || 0);
if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
return success;
}
async get<T = any>(key: string): Promise<T | undefined> {
if (this.useRedis()) {
try {
const value = await regionalRedisManager.get(key);
if (value !== null) {
logger.debug(`[regional] Cache hit in Redis: ${key}`);
return JSON.parse(value) as T;
}
logger.debug(`[regional] Cache miss in Redis: ${key}`);
return undefined;
} catch (error) {
logger.error(
`[regional] Redis get error for key ${key}:`,
error
);
}
}
const value = regionalLocalCache.get<T>(key);
if (value !== undefined) {
logger.debug(`[regional] Cache hit in local cache: ${key}`);
} else {
logger.debug(`[regional] Cache miss in local cache: ${key}`);
}
return value;
}
async del(key: string | string[]): Promise<number> {
const keys = Array.isArray(key) ? key : [key];
let deletedCount = 0;
if (this.useRedis()) {
try {
for (const k of keys) {
const success = await regionalRedisManager.del(k);
if (success) {
deletedCount++;
logger.debug(`[regional] Deleted key from Redis: ${k}`);
}
}
if (deletedCount === keys.length) return deletedCount;
deletedCount = 0;
} catch (error) {
logger.error(`[regional] Redis del error:`, error);
deletedCount = 0;
}
}
for (const k of keys) {
const count = regionalLocalCache.del(k);
if (count > 0) {
deletedCount++;
logger.debug(`[regional] Deleted key from local cache: ${k}`);
}
}
return deletedCount;
}
async has(key: string): Promise<boolean> {
if (this.useRedis()) {
try {
const value = await regionalRedisManager.get(key);
return value !== null;
} catch (error) {
logger.error(
`[regional] Redis has error for key ${key}:`,
error
);
}
}
return regionalLocalCache.has(key);
}
/**
* Returns keys matching the given prefix from whichever backend is active.
* Redis uses a KEYS scan; local cache filters in-memory keys.
*/
async keysWithPrefix(prefix: string): Promise<string[]> {
if (this.useRedis()) {
try {
return await regionalRedisManager.keys(`${prefix}*`);
} catch (error) {
logger.error(`[regional] Redis keys error:`, error);
}
}
return regionalLocalCache.keys().filter((k) => k.startsWith(prefix));
}
getCurrentBackend(): "redis" | "local" {
return this.useRedis() ? "redis" : "local";
}
}
export const regionalCache = new RegionalAdaptiveCache();

View File

@@ -21,174 +21,191 @@ import { getEnvOrYaml } from "@server/lib/getEnvOrYaml";
const portSchema = z.number().positive().gt(0).lte(65535);
export const privateConfigSchema = z.object({
app: z
.object({
region: z.string().optional().default("default"),
base_domain: z.string().optional(),
identity_provider_mode: z.enum(["global", "org"]).optional()
})
.optional()
.default({
region: "default"
}),
server: z
.object({
reo_client_id: z
.string()
.optional()
.transform(getEnvOrYaml("REO_CLIENT_ID")),
fossorial_api: z
.string()
.optional()
.default("https://api.fossorial.io"),
fossorial_api_key: z
.string()
.optional()
.transform(getEnvOrYaml("FOSSORIAL_API_KEY"))
})
.optional()
.prefault({}),
redis: z
.object({
host: z.string(),
port: portSchema,
password: z
.string()
.optional()
.transform(getEnvOrYaml("REDIS_PASSWORD")),
db: z.int().nonnegative().optional().default(0),
replicas: z
.array(
z.object({
export const privateConfigSchema = z
.object({
app: z
.object({
region: z.string().optional().default("default"),
base_domain: z.string().optional(),
identity_provider_mode: z.enum(["global", "org"]).optional()
})
.optional()
.default({
region: "default"
}),
server: z
.object({
reo_client_id: z
.string()
.optional()
.transform(getEnvOrYaml("REO_CLIENT_ID")),
fossorial_api: z
.string()
.optional()
.default("https://api.fossorial.io"),
fossorial_api_key: z
.string()
.optional()
.transform(getEnvOrYaml("FOSSORIAL_API_KEY"))
})
.optional()
.prefault({}),
redis: z
.object({
host: z.string(),
port: portSchema,
password: z
.string()
.optional()
.transform(getEnvOrYaml("REDIS_PASSWORD")),
db: z.int().nonnegative().optional().default(0),
replicas: z
.array(
z.object({
host: z.string(),
port: portSchema,
password: z.string().optional(),
db: z.int().nonnegative().optional().default(0)
})
)
.optional(),
tls: z
.object({
rejectUnauthorized: z.boolean().optional().default(true)
})
.optional(),
regional_redis: z
.object({
host: z.string(),
port: portSchema,
password: z.string().optional(),
db: z.int().nonnegative().optional().default(0)
password: z
.string()
.optional()
.transform(getEnvOrYaml("REGIONAL_REDIS_PASSWORD")),
db: z.int().nonnegative().optional().default(0),
tls: z
.object({
rejectUnauthorized: z
.boolean()
.optional()
.default(true)
})
.optional()
})
)
.optional(),
tls: z
.object({
rejectUnauthorized: z
.boolean()
.optional()
.default(true)
})
.optional()
})
.optional(),
gerbil: z
.object({
local_exit_node_reachable_at: z
.string()
.optional()
.default("http://gerbil:3004")
})
.optional()
.prefault({}),
flags: z
.object({
enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional(),
enable_acme_cert_sync: z.boolean().optional().default(true)
})
.optional()
.prefault({}),
acme: z
.object({
acme_json_path: z
.string()
.optional()
.default("config/letsencrypt/acme.json"),
resolver: z.string().optional().default("letsencrypt"),
sync_interval_ms: z.number().optional().default(5000)
})
.optional(),
branding: z
.object({
app_name: z.string().optional(),
background_image_path: z.string().optional(),
colors: z
.object({
light: colorsSchema.optional(),
dark: colorsSchema.optional()
})
.optional(),
logo: z
.object({
light_path: z.string().optional(),
dark_path: z.string().optional(),
auth_page: z
.object({
width: z.number().optional(),
height: z.number().optional()
})
.optional(),
navbar: z
.object({
width: z.number().optional(),
height: z.number().optional()
})
.optional()
})
.optional(),
footer: z
.array(
z.object({
text: z.string(),
href: z.string().optional()
.optional()
})
.optional(),
gerbil: z
.object({
local_exit_node_reachable_at: z
.string()
.optional()
.default("http://gerbil:3004")
})
.optional()
.prefault({}),
flags: z
.object({
enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional(),
enable_acme_cert_sync: z.boolean().optional().default(true)
})
.optional()
.prefault({}),
acme: z
.object({
acme_json_path: z
.string()
.optional()
.default("config/letsencrypt/acme.json"),
acme_http_endpoint: z.string().optional(),
sync_interval_ms: z.number().optional().default(5000)
})
.optional(),
branding: z
.object({
app_name: z.string().optional(),
background_image_path: z.string().optional(),
colors: z
.object({
light: colorsSchema.optional(),
dark: colorsSchema.optional()
})
)
.optional(),
hide_auth_layout_footer: z.boolean().optional().default(false),
login_page: z
.object({
subtitle_text: z.string().optional()
})
.optional(),
signup_page: z
.object({
subtitle_text: z.string().optional()
})
.optional(),
resource_auth_page: z
.object({
show_logo: z.boolean().optional(),
hide_powered_by: z.boolean().optional(),
title_text: z.string().optional(),
subtitle_text: z.string().optional()
})
.optional(),
emails: z
.object({
signature: z.string().optional(),
colors: z
.object({
primary: z.string().optional()
.optional(),
logo: z
.object({
light_path: z.string().optional(),
dark_path: z.string().optional(),
auth_page: z
.object({
width: z.number().optional(),
height: z.number().optional()
})
.optional(),
navbar: z
.object({
width: z.number().optional(),
height: z.number().optional()
})
.optional()
})
.optional(),
footer: z
.array(
z.object({
text: z.string(),
href: z.string().optional()
})
.optional()
})
.optional()
})
.optional(),
stripe: z
.object({
secret_key: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")),
webhook_secret: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")),
// s3Bucket: z.string(),
// s3Region: z.string().default("us-east-1"),
// localFilePath: z.string().optional()
})
.optional()
})
)
.optional(),
hide_auth_layout_footer: z.boolean().optional().default(false),
login_page: z
.object({
subtitle_text: z.string().optional()
})
.optional(),
signup_page: z
.object({
subtitle_text: z.string().optional()
})
.optional(),
resource_auth_page: z
.object({
show_logo: z.boolean().optional(),
hide_powered_by: z.boolean().optional(),
title_text: z.string().optional(),
subtitle_text: z.string().optional()
})
.optional(),
emails: z
.object({
signature: z.string().optional(),
colors: z
.object({
primary: z.string().optional()
})
.optional()
})
.optional()
})
.optional(),
stripe: z
.object({
secret_key: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")),
webhook_secret: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET"))
// s3Bucket: z.string(),
// s3Region: z.string().default("us-east-1"),
// localFilePath: z.string().optional()
})
.optional()
})
.transform((data) => {
// this to maintain backwards compatibility with the old config file
const identityProviderMode = data.app?.identity_provider_mode;

View File

@@ -109,14 +109,14 @@ class RedisManager {
password: redisConfig.password,
db: redisConfig.db
};
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
if (redisConfig.tls) {
opts.tls = {
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
};
}
return opts;
}
@@ -135,14 +135,14 @@ class RedisManager {
password: replica.password,
db: replica.db || redisConfig.db
};
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
if (redisConfig.tls) {
opts.tls = {
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
};
}
return opts;
}
@@ -855,3 +855,163 @@ class RedisManager {
export const redisManager = new RedisManager();
export const redis = redisManager.getClient();
export default redisManager;
/**
* Lightweight Redis manager for the regional (in-cluster) Redis instance.
* Connects only when `redis.regional_redis` is present in the private config
* and `flags.enable_redis` is true. No pub/sub — designed for low-latency
* caching of regionally-scoped data.
*/
class RegionalRedisManager {
private writeClient: Redis | null = null;
private readClient: Redis | null = null;
private isEnabled: boolean = false;
private isHealthy: boolean = false;
private connectionTimeout: number = 5000;
private commandTimeout: number = 5000;
constructor() {
if (build === "oss") return;
const cfg = privateConfig.getRawPrivateConfig();
if (!cfg.flags.enable_redis || !cfg.redis?.regional_redis) return;
this.isEnabled = true;
this.initializeClients();
}
private getConfig(): RedisOptions {
const r = privateConfig.getRawPrivateConfig().redis!.regional_redis!;
const opts: RedisOptions = {
host: r.host,
port: r.port,
password: r.password,
db: r.db
};
if (r.tls) {
opts.tls = { rejectUnauthorized: r.tls.rejectUnauthorized ?? true };
}
return opts;
}
private initializeClients(): void {
const cfg = this.getConfig();
const baseOpts = {
...cfg,
enableReadyCheck: false,
maxRetriesPerRequest: 3,
keepAlive: 10000,
connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout
};
try {
this.writeClient = new Redis(baseOpts);
// redis-1 (replica) handles reads; fall back to primary if not resolvable
this.readClient = new Redis({
...baseOpts,
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
// Derive replica hostname from the headless service pattern:
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
// If it doesn't look like a k8s service, just use the same host
return h + rest;
})
});
// For simplicity use same host for both; callers can always read from primary
// The real replica routing is handled by the StatefulSet headless service
this.readClient = this.writeClient;
this.writeClient.on("ready", () => {
logger.info("Regional Redis client ready");
this.isHealthy = true;
});
this.writeClient.on("error", (err) => {
logger.error("Regional Redis client error:", err);
this.isHealthy = false;
});
this.writeClient.on("reconnecting", () => {
logger.info("Regional Redis client reconnecting...");
this.isHealthy = false;
});
logger.info("Regional Redis client initialized");
} catch (error) {
logger.error("Failed to initialize regional Redis client:", error);
this.isEnabled = false;
}
}
public isRedisEnabled(): boolean {
return this.isEnabled && this.writeClient !== null && this.isHealthy;
}
public getHealthStatus() {
return { isEnabled: this.isEnabled, isHealthy: this.isHealthy };
}
public async set(
key: string,
value: string,
ttl?: number
): Promise<boolean> {
if (!this.isRedisEnabled() || !this.writeClient) return false;
try {
if (ttl) {
await this.writeClient.setex(key, ttl, value);
} else {
await this.writeClient.set(key, value);
}
return true;
} catch (error) {
logger.error("Regional Redis SET error:", error);
return false;
}
}
public async get(key: string): Promise<string | null> {
if (!this.isRedisEnabled() || !this.readClient) return null;
try {
return await this.readClient.get(key);
} catch (error) {
logger.error("Regional Redis GET error:", error);
return null;
}
}
public async del(key: string): Promise<boolean> {
if (!this.isRedisEnabled() || !this.writeClient) return false;
try {
await this.writeClient.del(key);
return true;
} catch (error) {
logger.error("Regional Redis DEL error:", error);
return false;
}
}
public async keys(pattern: string): Promise<string[]> {
if (!this.isRedisEnabled() || !this.readClient) return [];
try {
return await this.readClient.keys(pattern);
} catch (error) {
logger.error("Regional Redis KEYS error:", error);
return [];
}
}
public async disconnect(): Promise<void> {
try {
if (this.writeClient) {
await this.writeClient.quit();
this.writeClient = null;
}
this.readClient = null;
logger.info("Regional Redis client disconnected");
} catch (error) {
logger.error("Error disconnecting regional Redis client:", error);
}
}
}
export const regionalRedisManager = new RegionalRedisManager();

View File

@@ -277,37 +277,37 @@ export async function getTraefikConfig(
});
});
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
const siteResourcesWithFullDomain = await db
.select({
siteResourceId: siteResources.siteResourceId,
fullDomain: siteResources.fullDomain,
mode: siteResources.mode
})
.from(siteResources)
.innerJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
eq(siteResources.enabled, true),
isNotNull(siteResources.fullDomain),
eq(siteResources.mode, "http"),
eq(siteResources.ssl, true),
or(
eq(sites.exitNodeId, exitNodeId),
and(
isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
eq(sites.type, "local"),
sql`(${build != "saas" ? 1 : 0} = 1)`
)
),
inArray(sites.type, siteTypes)
let siteResourcesWithFullDomain: {
siteResourceId: number;
fullDomain: string | null;
mode: "http" | "host" | "cidr";
}[] = [];
if (build == "enterprise") {
// we dont want to do this on the cloud
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
siteResourcesWithFullDomain = await db
.select({
siteResourceId: siteResources.siteResourceId,
fullDomain: siteResources.fullDomain,
mode: siteResources.mode
})
.from(siteResources)
.innerJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
);
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
eq(siteResources.enabled, true),
isNotNull(siteResources.fullDomain),
eq(siteResources.mode, "http"),
eq(siteResources.ssl, true),
eq(sites.exitNodeId, exitNodeId),
inArray(sites.type, siteTypes)
)
);
}
let validCerts: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {

View File

@@ -24,7 +24,7 @@ import { eq, and } from "drizzle-orm";
import {
fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert
} from "#private/lib/alerts/events/healthCheckEvents";
} from "@server/lib/alerts";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
@@ -73,10 +73,7 @@ export async function triggerHealthCheckAlert(
.from(targetHealthCheck)
.where(
and(
eq(
targetHealthCheck.targetHealthCheckId,
healthCheckId
),
eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
eq(targetHealthCheck.orgId, orgId)
)
)

View File

@@ -25,7 +25,7 @@ import {
fireResourceHealthyAlert,
fireResourceUnhealthyAlert,
fireResourceDegradedAlert
} from "#private/lib/alerts/events/resourceEvents";
} from "@server/lib/alerts";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),

View File

@@ -21,10 +21,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import {
fireSiteOnlineAlert,
fireSiteOfflineAlert
} from "#private/lib/alerts/events/siteEvents";
import { fireSiteOnlineAlert, fireSiteOfflineAlert } from "@server/lib/alerts";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),

View File

@@ -30,8 +30,10 @@ import {
userOrgRoles,
siteProvisioningKeyOrg,
siteProvisioningKeys,
alertRules,
targetHealthCheck
} from "@server/db";
import { and, eq } from "drizzle-orm";
import { and, eq, isNull } from "drizzle-orm";
/**
* Get the maximum allowed retention days for a given tier
@@ -318,6 +320,14 @@ async function disableFeature(
await disableSiteProvisioningKeys(orgId);
break;
case TierFeature.AlertingRules:
await disableAlertingRules(orgId);
break;
case TierFeature.StandaloneHealthChecks:
await disableStandaloneHealthChecks(orgId);
break;
default:
logger.warn(
`Unknown feature ${feature} for org ${orgId}, skipping`
@@ -360,8 +370,7 @@ async function disableFullRbac(orgId: string): Promise<void> {
async function disableSiteProvisioningKeys(orgId: string): Promise<void> {
const rows = await db
.select({
siteProvisioningKeyId:
siteProvisioningKeyOrg.siteProvisioningKeyId
siteProvisioningKeyId: siteProvisioningKeyOrg.siteProvisioningKeyId
})
.from(siteProvisioningKeyOrg)
.where(eq(siteProvisioningKeyOrg.orgId, orgId));
@@ -525,6 +534,29 @@ async function disablePasswordExpirationPolicies(orgId: string): Promise<void> {
logger.info(`Disabled password expiration policies for org ${orgId}`);
}
async function disableAlertingRules(orgId: string): Promise<void> {
await db
.update(alertRules)
.set({ enabled: false })
.where(eq(alertRules.orgId, orgId));
logger.info(`Disabled all alert rules for org ${orgId}`);
}
async function disableStandaloneHealthChecks(orgId: string): Promise<void> {
await db
.update(targetHealthCheck)
.set({ hcEnabled: false })
.where(
and(
eq(targetHealthCheck.orgId, orgId),
isNull(targetHealthCheck.targetId)
)
);
logger.info(`Disabled standalone health checks for org ${orgId}`);
}
async function disableAutoProvisioning(orgId: string): Promise<void> {
// Get all IDP IDs for this org through the idpOrg join table
const orgIdps = await db

View File

@@ -16,6 +16,7 @@ import { customers, db, subscriptions } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import { generateId } from "@server/auth/sessions/app";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
export async function handleCustomerCreated(
customer: Stripe.Customer
@@ -62,6 +63,13 @@ export async function handleCustomerCreated(
expiresAt: trialExpiresAt,
trial: true
});
// update to the business limits for the trial
await handleSubscriptionLifesycle(
customer.metadata.orgId,
"active",
"tier3"
);
});
logger.info(`Customer with ID ${customer.id} created successfully.`);

View File

@@ -174,6 +174,19 @@ export async function handleSubscriptionCreated(
// TODO: update user in Sendy
}
}
// delete the trial subscrition if we have one
await db
.delete(subscriptions)
.where(
and(
eq(
subscriptions.customerId,
subscription.customer as string
),
eq(subscriptions.trial, true)
)
);
} else if (type === "license") {
logger.debug(
`License subscription created for org ${customer.orgId}, no lifecycle handling needed.`

View File

@@ -44,7 +44,7 @@ function getLimitSetForSubscriptionType(
export async function handleSubscriptionLifesycle(
orgId: string,
status: string,
subType: SubscriptionType | null
subType: SubscriptionType | null = null
) {
switch (status) {
case "active":

View File

@@ -79,7 +79,7 @@ export async function createCertificate(
let domainToWrite = domain;
if (
domainRecord.type == "wildcard" &&
domainRecord.type == "wildcard" && // this is to fix the wildcard certs for traefik in self hosted NOT ON THE CLOUD
domainRecord.preferWildcardCert &&
!domain.startsWith("*.")
) {
@@ -89,6 +89,15 @@ export async function createCertificate(
domainToWrite = parts.slice(1).join(".");
domainToWrite = `*.${domainToWrite}`;
}
} else if (domainRecord.type == "ns") {
if (domain == domainRecord.baseDomain) {
domainToWrite = domainRecord.baseDomain;
} else {
const parts = domain.split(".");
if (parts.length > 2) {
domainToWrite = parts.slice(1).join(".");
}
}
}
// No cert found, create a new one in pending state

View File

@@ -165,7 +165,6 @@ authenticated.get(
authenticated.get(
"/org/:orgId/certificate/:domainId/:domain",
verifyValidLicense,
verifyOrgAccess,
verifyCertificateAccess,
verifyUserHasAction(ActionsEnum.getCertificate),

View File

@@ -22,7 +22,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
import { fireHealthCheckUnhealthyAlert } from "#private/lib/alerts";
import { fireHealthCheckUnhealthyAlert } from "@server/lib/alerts";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()

View File

@@ -22,7 +22,11 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, isNull } from "drizzle-orm";
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
import { fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert, fireHealthCheckHealthyAlert } from "#private/lib/alerts";
import {
fireHealthCheckUnhealthyAlert,
fireHealthCheckUnknownAlert,
fireHealthCheckHealthyAlert
} from "@server/lib/alerts";
const paramsSchema = z
.object({
@@ -234,7 +238,10 @@ export async function updateHealthCheck(
)
.returning();
if (updated.hcHealth === "unhealthy" && existingHealthCheck.hcHealth !== "unhealthy") {
if (
updated.hcHealth === "unhealthy" &&
existingHealthCheck.hcHealth !== "unhealthy"
) {
await fireHealthCheckUnhealthyAlert(
updated.orgId,
updated.targetHealthCheckId,
@@ -243,7 +250,10 @@ export async function updateHealthCheck(
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
} else if (updated.hcHealth === "unknown" && existingHealthCheck.hcHealth !== "unknown") {
} else if (
updated.hcHealth === "unknown" &&
existingHealthCheck.hcHealth !== "unknown"
) {
// if the health is unknown, we want to fire an alert to notify users to enable health checks
await fireHealthCheckUnknownAlert(
updated.orgId,
@@ -253,7 +263,10 @@ export async function updateHealthCheck(
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
} else if (updated.hcHealth === "healthy" && existingHealthCheck.hcHealth !== "healthy") {
} else if (
updated.hcHealth === "healthy" &&
existingHealthCheck.hcHealth !== "healthy"
) {
await fireHealthCheckHealthyAlert(
updated.orgId,
updated.targetHealthCheckId,
@@ -264,7 +277,6 @@ export async function updateHealthCheck(
);
}
// Push updated health check to newt if the site is a newt site
const [newt] = await db
.select()

View File

@@ -67,24 +67,20 @@ if (build == "saas") {
verifyApiKeyIsRoot,
certificates.syncCertToNewts
);
authenticated.post(
`/org/:orgId/send-usage-notification`,
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
org.sendUsageNotification
);
authenticated.post(
`/org/:orgId/send-trial-notification`,
verifyApiKeyIsRoot,
org.sendTrialNotification
);
}
authenticated.post(
`/org/:orgId/send-usage-notification`,
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
verifyApiKeyHasAction(ActionsEnum.sendUsageNotification),
logActionAudit(ActionsEnum.sendUsageNotification),
org.sendUsageNotification
);
authenticated.post(
`/org/:orgId/send-trial-notification`,
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.sendTrialNotification),
logActionAudit(ActionsEnum.sendTrialNotification),
org.sendTrialNotification
);
authenticated.delete(
"/idp/:idpId",
verifyApiKeyIsRoot,

View File

@@ -24,13 +24,18 @@ import { fromError } from "zod-validation-error";
import { sendEmail } from "@server/emails";
import NotifyTrialExpiring from "@server/emails/templates/NotifyTrialExpiring";
import config from "@server/lib/config";
import { handleSubscriptionLifesycle } from "../billing/subscriptionLifecycle";
const sendTrialNotificationParamsSchema = z.object({
orgId: z.string()
});
const sendTrialNotificationBodySchema = z.object({
notificationType: z.enum(["trial_ending_5d", "trial_ending_24h", "trial_ended"]),
notificationType: z.enum([
"trial_ending_5d",
"trial_ending_24h",
"trial_ended"
]),
orgName: z.string(),
trialEndsAt: z.number(),
billingLink: z.string().optional()
@@ -69,9 +74,7 @@ async function getOrgAdmins(orgId: string) {
)
);
const byUserId = new Map(
admins.map((a) => [a.userId, a])
);
const byUserId = new Map(admins.map((a) => [a.userId, a]));
const orgAdmins = Array.from(byUserId.values()).filter(
(admin) => admin.email && admin.email.length > 0
);
@@ -108,8 +111,12 @@ export async function sendTrialNotification(
}
const { orgId } = parsedParams.data;
const { notificationType, orgName, trialEndsAt, billingLink: bodyBillingLink } =
parsedBody.data;
const {
notificationType,
orgName,
trialEndsAt,
billingLink: bodyBillingLink
} = parsedBody.data;
// Verify organization exists
const org = await db
@@ -146,13 +153,17 @@ export async function sendTrialNotification(
bodyBillingLink ??
`${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`;
const trialEndsAtFormatted = new Date(trialEndsAt * 1000).toLocaleDateString(
"en-US",
{ year: "numeric", month: "long", day: "numeric" }
);
const trialEndsAtFormatted = new Date(
trialEndsAt * 1000
).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
let daysRemaining: number | null;
let subject: string;
let resetLimits = false;
if (notificationType === "trial_ending_5d") {
daysRemaining = 5;
@@ -163,6 +174,7 @@ export async function sendTrialNotification(
} else {
daysRemaining = null;
subject = "Your trial has ended";
resetLimits = true;
}
let emailsSent = 0;
@@ -201,6 +213,14 @@ export async function sendTrialNotification(
}
}
if (resetLimits) {
// this will only fire if they have not upgraded yet because when upgrading we delete the trial
await handleSubscriptionLifesycle(orgId, "cancled");
logger.debug(
`Trial ended for org ${orgId}, limits reset to free tier`
);
}
return response<SendTrialNotificationResponse>(res, {
data: {
success: true,
@@ -221,4 +241,4 @@ export async function sendTrialNotification(
)
);
}
}
}

View File

@@ -22,6 +22,91 @@ 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 semver from "semver";
let stalePangolinNodeVersion: string | null = null;
async function getLatestPangolinNodeVersion(): Promise<string | null> {
try {
const cachedVersion = await cache.get<string>(
"cache:latestPangolinNodeVersion"
);
if (cachedVersion) {
return cachedVersion;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
const res = await fetch(
"https://api.github.com/repos/fosrl/pangolin-node/tags",
{ signal: controller.signal }
);
clearTimeout(timeoutId);
if (!res.ok) {
logger.warn(
`Failed to fetch latest pangolin-node version from GitHub: ${res.status} ${res.statusText}`
);
return stalePangolinNodeVersion;
}
let tags = await res.json();
if (!Array.isArray(tags) || tags.length === 0) {
logger.warn("No tags found for pangolin-node repository");
return stalePangolinNodeVersion;
}
tags = tags.filter((tag: any) => !tag.name.includes("rc"));
tags.sort((a: any, b: any) => {
const va = semver.coerce(a.name);
const vb = semver.coerce(b.name);
if (!va && !vb) return 0;
if (!va) return 1;
if (!vb) return -1;
return semver.rcompare(va, vb);
});
const seen = new Set<string>();
tags = tags.filter((tag: any) => {
const normalised = semver.coerce(tag.name)?.version;
if (!normalised || seen.has(normalised)) return false;
seen.add(normalised);
return true;
});
if (tags.length === 0) {
logger.warn(
"No valid semver tags found for pangolin-node repository"
);
return stalePangolinNodeVersion;
}
const latestVersion = tags[0].name;
stalePangolinNodeVersion = latestVersion;
await cache.set("cache:latestPangolinNodeVersion", latestVersion, 3600);
return latestVersion;
} catch (error: any) {
if (error.name === "AbortError") {
logger.warn(
"Request to fetch latest pangolin-node version timed out (1.5s)"
);
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
logger.warn(
"Connection timeout while fetching latest pangolin-node version"
);
} else {
logger.warn(
"Error fetching latest pangolin-node version:",
error.message || error
);
}
return stalePangolinNodeVersion;
}
}
const listRemoteExitNodesParamsSchema = z.strictObject({
orgId: z.string()
@@ -118,9 +203,41 @@ export async function listRemoteExitNodes(
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count;
const latestPangolinNodeVersionPromise = getLatestPangolinNodeVersion();
const nodesWithUpdates = remoteExitNodesList.map((node) => ({
...node,
updateAvailable: false
}));
try {
const latestPangolinNodeVersion =
await latestPangolinNodeVersionPromise;
if (latestPangolinNodeVersion) {
nodesWithUpdates.forEach((node) => {
if (node.version) {
try {
node.updateAvailable = semver.lt(
node.version,
latestPangolinNodeVersion
);
} catch {
node.updateAvailable = false;
}
}
});
}
} catch (error) {
logger.warn(
"Failed to check for pangolin-node updates, continuing without update info:",
error
);
}
return response<ListRemoteExitNodesResponse>(res, {
data: {
remoteExitNodes: remoteExitNodesList,
remoteExitNodes: nodesWithUpdates,
pagination: {
total: totalCount,
limit,

View File

@@ -22,7 +22,7 @@ import {
Olm,
olms,
RemoteExitNode,
remoteExitNodes,
remoteExitNodes
} from "@server/db";
import { eq } from "drizzle-orm";
import { db } from "@server/db";
@@ -194,8 +194,6 @@ const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
// Config version tracking map (local to this node, resets on server restart)
const clientConfigVersions: Map<string, number> = new Map();
// Recovery tracking
let isRedisRecoveryInProgress = false;
@@ -406,6 +404,9 @@ const removeClient = async (
const updatedClients = existingClients.filter((client) => client !== ws);
if (updatedClients.length === 0) {
connectedClients.delete(mapKey);
// Remove clientId from clientConfigVersions on disconnect — prevents
// unbounded memory growth from stale entries.
clientConfigVersions.delete(clientId);
if (redisManager.isRedisEnabled()) {
try {
@@ -1097,6 +1098,11 @@ const disconnectClient = async (clientId: string): Promise<boolean> => {
}
});
// Eagerly remove client — close event may not fire if socket is already
// CLOSING, leaving zombie entries.
connectedClients.delete(mapKey);
clientConfigVersions.delete(clientId);
return true;
};

View File

@@ -80,6 +80,10 @@ export interface WebhookAlertConfig {
headers?: Array<{ key: string; value: string }>;
/** HTTP method (default POST) */
method?: string;
/** Whether to use a custom body template */
useBodyTemplate?: boolean;
/** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */
bodyTemplate?: string;
}
// ---------------------------------------------------------------------------

View File

@@ -104,8 +104,9 @@ export async function deleteMyAccount(
(r) => r.isBillingOrg && r.isOwner
)?.orgId;
if (primaryOrgId) {
const { tier, active } = await getOrgTierData(primaryOrgId);
if (active && tier) {
const { tier, active, isTrial } =
await getOrgTierData(primaryOrgId);
if (active && tier && !isTrial) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,

View File

@@ -1003,7 +1003,11 @@ async function checkRules(
isIpInCidr(clientIp, rule.value)
) {
return rule.action as any;
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
} else if (
clientIp &&
rule.match == "IP" &&
clientIp == rule.value
) {
return rule.action as any;
} else if (
path &&
@@ -1013,16 +1017,35 @@ async function checkRules(
return rule.action as any;
} else if (
clientIp &&
rule.match == "COUNTRY" &&
(await isIpInGeoIP(ipCC, rule.value))
rule.match == "COUNTRY"
) {
return rule.action as any;
// COUNTRY=ALL should not affect local/private/CGNAT addresses.
if (
rule.value.toUpperCase() === "ALL" &&
isLocalOrCarrierGradeNatIp(clientIp)
) {
continue;
}
if (await isIpInGeoIP(ipCC, rule.value)) {
return rule.action as any;
}
} else if (
clientIp &&
rule.match == "ASN" &&
(await isIpInAsn(ipAsn, rule.value))
rule.match == "ASN"
) {
return rule.action as any;
// ASN=ALL/AS0 should not affect local/private/CGNAT addresses.
if (
(rule.value.toUpperCase() === "ALL" ||
rule.value.toUpperCase() === "AS0") &&
isLocalOrCarrierGradeNatIp(clientIp)
) {
continue;
}
if (await isIpInAsn(ipAsn, rule.value)) {
return rule.action as any;
}
} else if (
clientIp &&
rule.match == "REGION" &&
@@ -1184,6 +1207,26 @@ async function isIpInGeoIP(
return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase();
}
function isLocalOrCarrierGradeNatIp(ip: string): boolean {
const localAndCgnatCidrs = [
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"::1/128",
"fc00::/7",
"fe80::/10"
];
try {
return localAndCgnatCidrs.some((cidr) => isIpInCidr(ip, cidr));
} catch {
return false;
}
}
async function isIpInAsn(
ipAsn: number | undefined,
checkAsn: string

View File

@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, olms, users } from "@server/db";
import { db, idp, idpOidcConfig, olms, users } from "@server/db";
import { clients, currentFingerprint } from "@server/db";
import { eq, and } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -236,6 +236,9 @@ export type GetClientResponse = NonNullable<
lastSeen: number | null;
} | null;
posture: PostureData | null;
userType: string | null;
idpName: string | null;
idpVariant: string | null;
};
registry.registerPath({
@@ -337,6 +340,30 @@ export async function getClient(
: maskPostureDataWithPlaceholder(rawPosture)
: null;
let userType: string | null = null;
let idpName: string | null = null;
let idpVariant: string | null = null;
if (client.clients.userId) {
const [idpRow] = await db
.select({
userType: users.type,
idpName: idp.name,
idpVariant: idpOidcConfig.variant
})
.from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(eq(users.userId, client.clients.userId))
.limit(1);
if (idpRow) {
userType = idpRow.userType;
idpName = idpRow.idpName;
idpVariant = idpRow.idpVariant;
}
}
const data: GetClientResponse = {
...client.clients,
name: clientName,
@@ -347,7 +374,10 @@ export async function getClient(
userName: client.user?.name ?? null,
userUsername: client.user?.username ?? null,
fingerprint: fingerprintData,
posture: postureData
posture: postureData,
userType,
idpName,
idpVariant
};
return response<GetClientResponse>(res, {

View File

@@ -3,6 +3,8 @@ import {
clients,
currentFingerprint,
db,
idp,
idpOidcConfig,
olms,
orgs,
roleClients,
@@ -165,6 +167,9 @@ function queryUserDevicesBase() {
userId: clients.userId,
username: users.username,
userEmail: users.email,
userType: users.type,
idpName: idp.name,
idpVariant: idpOidcConfig.variant,
niceId: clients.niceId,
agent: olms.agent,
approvalState: clients.approvalState,
@@ -184,6 +189,8 @@ function queryUserDevicesBase() {
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
.leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(users, eq(clients.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
}

View File

@@ -38,10 +38,7 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
assignUserToOrg,
removeUserFromOrg
} from "@server/lib/userOrg";
import { assignUserToOrg, removeUserFromOrg } from "@server/lib/userOrg";
import { unwrapRoleMapping } from "@app/lib/idpRoleMapping";
const ensureTrailingSlash = (url: string): string => {
@@ -336,31 +333,15 @@ export async function validateOidcCallback(
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
allOrgs = idpOrgs.map((o) => o.orgs);
// TODO: when there are multiple orgs we need to do this better!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!1
if (allOrgs.length > 1) {
// for some reason there is more than one org
logger.error(
"More than one organization linked to this IdP. This should not happen with auto-provisioning enabled."
);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Multiple organizations linked to this IdP. Please contact support."
)
);
}
const subscribed = await isSubscribed(
allOrgs[0].orgId,
tierMatrix.autoProvisioning
);
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
for (const org of allOrgs) {
const subscribed = await isSubscribed(
org.orgId,
tierMatrix.autoProvisioning
);
if (!subscribed) {
// filter out the org
allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
}
}
} else {
allOrgs = await db.select().from(orgs);
@@ -405,16 +386,14 @@ export async function validateOidcCallback(
idpOrgRes?.roleMapping || defaultRoleMapping;
if (roleMapping) {
logger.debug("Role Mapping", { roleMapping });
const roleMappingJmes = unwrapRoleMapping(
roleMapping
).evaluationExpression;
const roleMappingJmes =
unwrapRoleMapping(roleMapping).evaluationExpression;
const roleMappingResult = jmespath.search(
claims,
roleMappingJmes
);
const roleNames = normalizeRoleMappingResult(
roleMappingResult
);
const roleNames =
normalizeRoleMappingResult(roleMappingResult);
const supportsMultiRole = await isLicensedOrSubscribed(
org.orgId,
@@ -504,7 +483,14 @@ export async function validateOidcCallback(
}
}
await calculateUserClientsForOrgs(existingUser.userId);
calculateUserClientsForOrgs(existingUser.userId).catch(
(err) => {
logger.error(
"Error calculating user clients after removing all orgs for user with no valid IdP mappings",
{ error: err }
);
}
);
return next(
createHttpError(
@@ -524,12 +510,11 @@ export async function validateOidcCallback(
}
}
const orgUserCounts: { orgId: string; userCount: number }[] = [];
const orgUserCounts: { orgId: string; userCount: number }[] = [];
let userId = existingUser?.userId;
// sync the user with the orgs and roles
await db.transaction(async (trx) => {
let userId = existingUser?.userId;
// create user if not exists
if (!existingUser) {
userId = generateId(15);
@@ -637,7 +622,7 @@ export async function validateOidcCallback(
{
orgId: org.orgId,
userId: userId!,
autoProvisioned: true,
autoProvisioned: true
},
org.roleIds,
trx
@@ -659,8 +644,15 @@ export async function validateOidcCallback(
userCount: userCount.length
});
}
});
db.transaction(async (trx) => {
await calculateUserClientsForOrgs(userId!, trx);
}).catch((err) => {
logger.error(
"Error calculating user clients after syncing orgs and roles for OIDC user",
{ error: err }
);
});
for (const orgCount of orgUserCounts) {
@@ -767,9 +759,7 @@ function hydrateOrgMapping(
return orgMapping.split("{{orgId}}").join(orgId);
}
function normalizeRoleMappingResult(
result: unknown
): string[] {
function normalizeRoleMappingResult(result: unknown): string[] {
if (typeof result === "string") {
const role = result.trim();
return role ? [role] : [];
@@ -779,7 +769,9 @@ function normalizeRoleMappingResult(
return [
...new Set(
result
.filter((value): value is string => typeof value === "string")
.filter(
(value): value is string => typeof value === "string"
)
.map((value) => value.trim())
.filter(Boolean)
)

View File

@@ -1,12 +1,8 @@
import { MessageHandler } from "@server/routers/ws";
import {
db,
Newt,
sites
} from "@server/db";
import { db, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import { fireSiteOfflineAlert } from "#dynamic/lib/alerts";
import { fireSiteOfflineAlert } from "@server/lib/alerts";
/**
* Handles disconnecting messages from sites to show disconnected in the ui
@@ -38,7 +34,13 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
.where(eq(sites.siteId, newt.siteId!))
.returning();
await fireSiteOfflineAlert(site.orgId, site.siteId, site.name, undefined, trx);
await fireSiteOfflineAlert(
site.orgId,
site.siteId,
site.name,
undefined,
trx
);
});
} catch (error) {
logger.error("Error handling disconnecting message", { error });

View File

@@ -1,12 +1,8 @@
import {
db,
newts,
sites
} from "@server/db";
import { db, newts, sites } from "@server/db";
import { hasActiveConnections } from "#dynamic/routers/ws";
import { eq, lt, isNull, and, or, ne, not, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "#dynamic/lib/alerts";
import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "@server/lib/alerts";
// Track if the offline checker interval is running
let offlineCheckerInterval: NodeJS.Timeout | null = null;

View File

@@ -2,7 +2,7 @@ import { db } from "@server/db";
import { sites, clients, olms } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { fireSiteOnlineAlert } from "#dynamic/lib/alerts";
import { fireSiteOnlineAlert } from "@server/lib/alerts";
/**
* Ping Accumulator
@@ -127,7 +127,11 @@ async function flushSitePingsToDb(): Promise<void> {
eq(sites.online, false)
)
)
.returning({ siteId: sites.siteId, orgId: sites.orgId, name: sites.name });
.returning({
siteId: sites.siteId,
orgId: sites.orgId,
name: sites.name
});
// Update lastPing for sites that were already online.
// After the update above, the newly-online sites now have
@@ -148,7 +152,13 @@ async function flushSitePingsToDb(): Promise<void> {
for (const site of newlyOnlineSites) {
await db.transaction(async (trx) => {
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name, undefined, trx);
await fireSiteOnlineAlert(
site.orgId,
site.siteId,
site.name,
undefined,
trx
);
});
}
} catch (error) {

View File

@@ -21,6 +21,7 @@ export type ListRemoteExitNodesResponse = {
remoteExitNodeId: string;
dateCreated: string;
version: string | null;
updateAvailable?: boolean;
exitNodeId: number | null;
name: string;
address: string;

View File

@@ -152,7 +152,7 @@ export type ResourceWithTargets = {
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
online?: boolean; // undefined for local sites
}>;
};
@@ -383,12 +383,8 @@ export async function listResources(
.select({ resourceId: targets.resourceId })
.from(targets)
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.where(
and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))
);
conditions.push(
inArray(resources.resourceId, resourcesWithSite)
);
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
conditions.push(inArray(resources.resourceId, resourcesWithSite));
}
const baseQuery = queryResourcesBase().where(and(...conditions));
@@ -426,7 +422,8 @@ export async function listResources(
hcEnabled: targetHealthCheck.hcEnabled,
siteName: sites.name,
siteNiceId: sites.niceId,
siteOnline: sites.online
siteOnline: sites.online,
siteType: sites.type
})
.from(targets)
.where(inArray(targets.resourceId, resourceIdList))
@@ -481,18 +478,19 @@ export async function listResources(
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
online?: boolean;
}
>();
for (const t of raw) {
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
continue;
}
const isLocal = t.siteType === "local";
siteById.set(t.siteId, {
siteId: t.siteId,
siteName: t.siteName ?? "",
siteNiceId: t.siteNiceId ?? "",
online: Boolean(t.siteOnline)
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
entry.sites = Array.from(siteById.values());

View File

@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, Site, siteNetworks, siteResources } from "@server/db";
import { newts, newtSessions, sites } from "@server/db";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -77,17 +77,20 @@ export async function deleteSite(
.where(eq(siteNetworks.siteId, siteId));
// loop through them
for (const network of await networks) {
const [siteResource] = await trx
.select()
.from(siteResources)
.where(eq(siteResources.networkId, network.networkId));
if (siteResource) {
await rebuildClientAssociationsFromSiteResource(
siteResource,
trx
);
}
const updatedSiteResources = await trx
.select()
.from(siteResources)
.where(
inArray(
siteResources.networkId,
networks.map((n) => n.networkId)
)
);
for (const siteResource of updatedSiteResources) {
await rebuildClientAssociationsFromSiteResource(
siteResource,
trx
);
}
// get the newt on the site by querying the newt table for siteId

View File

@@ -42,9 +42,12 @@ async function query(siteId?: number, niceId?: string, orgId?: string) {
}
}
export type GetSiteResponse = NonNullable<
Awaited<ReturnType<typeof query>>
>["sites"] & { newtId: string | null };
type SiteQueryRow = NonNullable<Awaited<ReturnType<typeof query>>>;
export type GetSiteResponse = SiteQueryRow["sites"] & {
newtId: string | null;
newtVersion: string | null;
};
registry.registerPath({
method: "get",
@@ -100,7 +103,8 @@ export async function getSite(
const data: GetSiteResponse = {
...site.sites,
newtId: site.newt ? site.newt.newtId : null
newtId: site.newt ? site.newt.newtId : null,
newtVersion: site.newt?.version ?? null
};
return response<GetSiteResponse>(res, {

View File

@@ -31,7 +31,9 @@ let staleNewtVersion: string | null = null;
async function getLatestNewtVersion(): Promise<string | null> {
try {
const cachedVersion = await cache.get<string>("cache:latestNewtVersion");
const cachedVersion = await cache.get<string>(
"cache:latestNewtVersion"
);
if (cachedVersion) {
return cachedVersion;
}
@@ -226,7 +228,10 @@ function querySitesBase() {
);
}
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[0] & {
type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
online?: SiteRowBase["online"]; // undefined for local sites
newtUpdateAvailable?: boolean;
};
@@ -338,7 +343,9 @@ export async function listSites(
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
querySitesBase().where(and(...conditions)).as("filtered_sites")
querySitesBase()
.where(and(...conditions))
.as("filtered_sites")
);
const siteListQuery = baseQuery
@@ -397,9 +404,13 @@ export async function listSites(
);
}
const sitesPayload = sitesWithUpdates.map((site) =>
site.type === "local" ? { ...site, online: undefined } : site
);
return response<ListSitesResponse>(res, {
data: {
sites: sitesWithUpdates,
sites: sitesPayload,
pagination: {
total: totalCount,
pageSize,

View File

@@ -46,7 +46,8 @@ const createSiteResourceSchema = z
mode: z.enum(["host", "cidr", "http"]),
ssl: z.boolean().optional(), // only used for http mode
scheme: z.enum(["http", "https"]).optional(),
siteIds: z.array(z.int()),
siteIds: z.array(z.int()).optional(),
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
// proxyPort: z.int().positive().optional(),
destinationPort: z.int().positive().optional(),
destination: z.string().min(1),
@@ -132,6 +133,17 @@ const createSiteResourceSchema = z
message:
"HTTP mode requires scheme (http or https) and a valid destination port"
}
)
.refine(
(data) => {
return (
(data.siteIds !== undefined && data.siteIds.length > 0) ||
data.siteId !== undefined
);
},
{
message: "At least one of siteIds or siteId must be provided"
}
);
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
@@ -187,7 +199,8 @@ export async function createSiteResource(
const {
name,
niceId,
siteIds,
siteIds: siteIdsInput = [],
siteId,
mode,
scheme,
// proxyPort,
@@ -208,6 +221,12 @@ export async function createSiteResource(
subdomain
} = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array
const siteIds = [...siteIdsInput];
if (siteId !== undefined && !siteIds.includes(siteId)) {
siteIds.push(siteId);
}
if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
@@ -389,9 +408,10 @@ export async function createSiteResource(
enabled,
alias: alias ? alias.trim() : null,
aliasAddress,
tcpPortRangeString,
udpPortRangeString,
disableIcmp,
tcpPortRangeString:
mode == "http" ? "443,80" : tcpPortRangeString,
udpPortRangeString: mode == "http" ? "" : udpPortRangeString,
disableIcmp: disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false
domainId,
subdomain: finalSubdomain,
fullDomain
@@ -476,11 +496,6 @@ export async function createSiteResource(
);
}
}
await rebuildClientAssociationsFromSiteResource(
newSiteResource,
trx
); // we need to call this because we added to the admin role
});
if (!newSiteResource) {
@@ -496,10 +511,32 @@ export async function createSiteResource(
`Created site resource ${newSiteResource.siteResourceId} for org ${orgId}`
);
if (ssl && mode === "http" && domainId && fullDomain && build != "oss") {
if (
ssl &&
mode === "http" &&
domainId &&
fullDomain &&
build != "oss"
) {
await createCertificate(domainId, fullDomain, db);
}
// Run in the background after the response is sent. Wrapped in its
// own transaction so it always executes on the primary — avoiding any
// replica-lag issues while still allowing the HTTP response to return
// early.
db.transaction(async (trx) => {
await rebuildClientAssociationsFromSiteResource(
newSiteResource!,
trx
);
}).catch((err) => {
logger.error(
`Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`,
err
);
});
return response(res, {
data: newSiteResource,
success: true,

View File

@@ -63,17 +63,26 @@ export async function deleteSiteResource(
);
}
await db.transaction(async (trx) => {
// Delete the site resource
const [removedSiteResource] = await trx
.delete(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.returning();
// Delete the site resource
const [removedSiteResource] = await db
.delete(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.returning();
// Run in the background after the response is sent. Wrapped in its
// own transaction so it always executes on the primary — avoiding any
// replica-lag issues while still allowing the HTTP response to return
// early.
db.transaction(async (trx) => {
await rebuildClientAssociationsFromSiteResource(
removedSiteResource,
trx
);
}).catch((err) => {
logger.error(
`Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`,
err
);
});
logger.info(`Deleted site resource ${siteResourceId}`);

View File

@@ -98,9 +98,11 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
*/
function aggCol<T>(column: any) {
if (DB_TYPE === "sqlite") {
// json_group_array will include NULLs for left-joined missing rows;
// we filter them out in transformSiteResourceRow keeping arrays aligned.
return sql<T>`json_group_array(${column})`;
}
return sql<T>`array_agg(${column})`;
return sql<T>`COALESCE(array_agg(${column}) FILTER (WHERE ${sites.siteId} IS NOT NULL), '{}')`;
}
/**
@@ -112,16 +114,36 @@ function transformSiteResourceRow(row: any) {
if (DB_TYPE !== "sqlite") {
return row;
}
const siteIdsRaw = JSON.parse(row.siteIds) as (number | null)[];
const siteNamesRaw = JSON.parse(row.siteNames) as (string | null)[];
const siteNiceIdsRaw = JSON.parse(row.siteNiceIds) as (string | null)[];
const siteAddressesRaw = JSON.parse(row.siteAddresses) as (string | null)[];
const siteOnlinesRaw = JSON.parse(row.siteOnlines) as (0 | 1 | null)[];
// When a site resource has no associated sites (left join produced no
// matches), the aggregated arrays will contain a single NULL entry. Strip
// those out, keeping the parallel arrays aligned by siteId presence.
const siteIds: number[] = [];
const siteNames: string[] = [];
const siteNiceIds: string[] = [];
const siteAddresses: (string | null)[] = [];
const siteOnlines: boolean[] = [];
for (let i = 0; i < siteIdsRaw.length; i++) {
if (siteIdsRaw[i] == null) continue;
siteIds.push(siteIdsRaw[i] as number);
siteNames.push((siteNamesRaw[i] ?? "") as string);
siteNiceIds.push((siteNiceIdsRaw[i] ?? "") as string);
siteAddresses.push(siteAddressesRaw[i] ?? null);
siteOnlines.push(siteOnlinesRaw[i] === 1);
}
return {
...row,
siteNames: JSON.parse(row.siteNames) as string[],
siteNiceIds: JSON.parse(row.siteNiceIds) as string[],
siteIds: JSON.parse(row.siteIds) as number[],
siteAddresses: JSON.parse(row.siteAddresses) as (string | null)[],
// SQLite stores booleans as 0/1 integers
siteOnlines: (JSON.parse(row.siteOnlines) as (0 | 1)[]).map(
(v) => v === 1
) as boolean[]
siteNames,
siteNiceIds,
siteIds,
siteAddresses,
siteOnlines
};
}
@@ -158,11 +180,11 @@ function querySiteResourcesBase() {
siteOnlines: aggCol<boolean[]>(sites.online)
})
.from(siteResources)
.innerJoin(
.leftJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.groupBy(siteResources.siteResourceId);
}
@@ -215,6 +237,8 @@ export async function listAllSiteResourcesByOrg(
const conditions = [and(eq(siteResources.orgId, orgId))];
if (siteId != null) {
// Keep inner joins here: filtering by a specific site implies the
// resource must have at least one matching site.
const resourcesForSite = db
.select({ id: siteResources.siteResourceId })
.from(siteResources)

View File

@@ -43,7 +43,8 @@ const updateSiteResourceParamsSchema = z.strictObject({
const updateSiteResourceSchema = z
.strictObject({
name: z.string().min(1).max(255).optional(),
siteIds: z.array(z.int()),
siteIds: z.array(z.int()).optional(),
siteId: z.int().positive().optional(),
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
niceId: z
.string()
@@ -142,6 +143,17 @@ const updateSiteResourceSchema = z
message:
"HTTP mode requires scheme (http or https) and a valid destination port"
}
)
.refine(
(data) => {
return (
(data.siteIds !== undefined && data.siteIds.length > 0) ||
data.siteId !== undefined
);
},
{
message: "At least one of siteIds or siteId must be provided"
}
);
export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
@@ -196,7 +208,8 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data;
const {
name,
siteIds, // because it can change
siteIds: siteIdsInput = [], // because it can change
siteId,
niceId,
mode,
scheme,
@@ -217,6 +230,12 @@ export async function updateSiteResource(
subdomain
} = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array
const siteIds = [...siteIdsInput];
if (siteId !== undefined && !siteIds.includes(siteId)) {
siteIds.push(siteId);
}
// Check if site resource exists
const [existingSiteResource] = await db
.select()
@@ -412,9 +431,6 @@ export async function updateSiteResource(
})
.returning();
// wait some time to allow for messages to be handled
await new Promise((resolve) => setTimeout(resolve, 750));
const sshPamSet =
isLicensedSshPam &&
(authDaemonPort !== undefined ||
@@ -440,9 +456,12 @@ export async function updateSiteResource(
destinationPort,
enabled,
alias: alias ? alias.trim() : null,
tcpPortRangeString,
udpPortRangeString,
disableIcmp,
tcpPortRangeString:
mode == "http" ? "443,80" : tcpPortRangeString,
udpPortRangeString:
mode == "http" ? "" : udpPortRangeString,
disableIcmp:
disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false
domainId,
subdomain: finalSubdomain,
fullDomain,
@@ -534,11 +553,6 @@ export async function updateSiteResource(
}))
);
}
await rebuildClientAssociationsFromSiteResource(
updatedSiteResource,
trx
);
} else {
// Update the site resource
const sshPamSet =
@@ -668,7 +682,24 @@ export async function updateSiteResource(
}
logger.info(`Updated site resource ${siteResourceId}`);
}
});
// Background: wait for removal messages to propagate, then rebuild
// associations for the re-created resource. Own transaction ensures
// execution on the primary against fully committed state.
(async () => {
await db.transaction(async (trx) => {
if (!updatedSiteResource) {
throw new Error("No updated resource found after update");
}
if (sitesChanged) {
await new Promise((resolve) => setTimeout(resolve, 750));
await rebuildClientAssociationsFromSiteResource(
updatedSiteResource,
trx
);
}
await handleMessagingForUpdatedSiteResource(
existingSiteResource,
updatedSiteResource,
@@ -678,7 +709,12 @@ export async function updateSiteResource(
})),
trx
);
}
});
})().catch((err) => {
logger.error(
`Error rebuilding client associations for site resource ${updatedSiteResource?.siteResourceId}:`,
err
);
});
return response(res, {
@@ -734,6 +770,9 @@ export async function handleMessagingForUpdatedSiteResource(
const fullDomainChanged =
existingSiteResource &&
existingSiteResource.fullDomain !== updatedSiteResource.fullDomain;
const sslChanged =
existingSiteResource &&
existingSiteResource.ssl !== updatedSiteResource.ssl;
const portRangesChanged =
existingSiteResource &&
(existingSiteResource.tcpPortRangeString !==
@@ -749,6 +788,7 @@ export async function handleMessagingForUpdatedSiteResource(
destinationChanged ||
aliasChanged ||
fullDomainChanged ||
sslChanged ||
portRangesChanged ||
destinationPortChanged
) {
@@ -765,9 +805,10 @@ export async function handleMessagingForUpdatedSiteResource(
);
}
// Only update targets on newt if destination changed
// Only update targets on newt if these items change
if (
destinationChanged ||
sslChanged || // we need to push a new cert if the ssl changed
portRangesChanged ||
fullDomainChanged || // if the domain changes we need to update the certs and stuff
destinationPortChanged

View File

@@ -23,7 +23,7 @@ import {
fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert,
fireHealthCheckUnknownAlert
} from "#dynamic/lib/alerts";
} from "@server/lib/alerts";
const createTargetParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive())

View File

@@ -6,7 +6,7 @@ import logger from "@server/logger";
import {
fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert
} from "#dynamic/lib/alerts";
} from "@server/lib/alerts";
interface TargetHealthStatus {
status: string;

View File

@@ -56,6 +56,8 @@ function queryTargets(resourceId: number) {
hcStatus: targetHealthCheck.hcStatus,
hcHealth: targetHealthCheck.hcHealth,
hcTlsServerName: targetHealthCheck.hcTlsServerName,
hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold,
hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold,
path: targets.path,
pathMatchType: targets.pathMatchType,
rewritePath: targets.rewritePath,

View File

@@ -10,7 +10,11 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets";
import { fireHealthCheckHealthyAlert, fireHealthCheckUnknownAlert, fireHealthCheckUnhealthyAlert } from "#dynamic/lib/alerts";
import {
fireHealthCheckHealthyAlert,
fireHealthCheckUnknownAlert,
fireHealthCheckUnhealthyAlert
} from "@server/lib/alerts";
import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
@@ -169,7 +173,7 @@ export async function updateTarget(
let updatedTarget: any;
let updatedHc: any;
await db.transaction(async (trx) => {
[updatedTarget] = await trx
[updatedTarget] = await trx
.update(targets)
.set({
siteId: parsedBody.data.siteId,
@@ -181,8 +185,12 @@ export async function updateTarget(
path: parsedBody.data.path,
pathMatchType: parsedBody.data.pathMatchType,
priority: parsedBody.data.priority,
rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath,
rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType
rewritePath: pathMatchTypeRemoved
? null
: parsedBody.data.rewritePath,
rewritePathType: pathMatchTypeRemoved
? null
: parsedBody.data.rewritePathType
})
.where(eq(targets.targetId, targetId))
.returning();
@@ -213,7 +221,8 @@ export async function updateTarget(
// If hcEnabled is being turned on (was false, now true), set to "unhealthy"
// so the target must pass a health check before being considered healthy.
const hcEnabledTurnedOn =
parsedBody.data.hcEnabled === true && existingHc.hcEnabled === false;
parsedBody.data.hcEnabled === true &&
existingHc.hcEnabled === false;
let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined;
if (
@@ -253,7 +262,10 @@ export async function updateTarget(
.where(eq(targetHealthCheck.targetId, targetId))
.returning();
if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") {
if (
updatedHc.hcHealth === "unhealthy" &&
existingHc.hcHealth !== "unhealthy"
) {
logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unhealthy, firing alert`
);
@@ -266,7 +278,10 @@ export async function updateTarget(
false, // dont send the alert because we just want to create the alert, not notify users yet
trx
);
} else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") {
} else if (
updatedHc.hcHealth === "unknown" &&
existingHc.hcHealth !== "unknown"
) {
logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unknown, firing alert`
);
@@ -280,7 +295,10 @@ export async function updateTarget(
false, // dont send the alert because we just want to create the alert, not notify users yet
trx
);
} else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") {
} else if (
updatedHc.hcHealth === "healthy" &&
existingHc.hcHealth !== "healthy"
) {
logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now healthy, firing alert`
);

View File

@@ -3,7 +3,15 @@ import zlib from "zlib";
import { Server as HttpServer } from "http";
import { WebSocket, WebSocketServer } from "ws";
import { Socket } from "net";
import { Newt, newts, NewtSession, olms, Olm, OlmSession, sites } from "@server/db";
import {
Newt,
newts,
NewtSession,
olms,
Olm,
OlmSession,
sites
} from "@server/db";
import { eq } from "drizzle-orm";
import { db } from "@server/db";
import { recordPing } from "@server/routers/newt/pingAccumulator";
@@ -80,6 +88,9 @@ const removeClient = async (
const updatedClients = existingClients.filter((client) => client !== ws);
if (updatedClients.length === 0) {
connectedClients.delete(mapKey);
// Remove clientId from clientConfigVersions — prevents unbounded growth
// from stale entries.
clientConfigVersions.delete(clientId);
logger.info(
`All connections removed for ${clientType.toUpperCase()} ID: ${clientId}`
@@ -218,9 +229,13 @@ const hasActiveConnections = async (clientId: string): Promise<boolean> => {
};
// Get the current config version for a client
const getClientConfigVersion = async (clientId: string): Promise<number | undefined> => {
const getClientConfigVersion = async (
clientId: string
): Promise<number | undefined> => {
const version = clientConfigVersions.get(clientId);
logger.debug(`getClientConfigVersion called for clientId: ${clientId}, returning: ${version} (type: ${typeof version})`);
logger.debug(
`getClientConfigVersion called for clientId: ${clientId}, returning: ${version} (type: ${typeof version})`
);
return version;
};
@@ -507,6 +522,11 @@ const disconnectClient = async (clientId: string): Promise<boolean> => {
}
});
// Eagerly remove client — close event may not fire if socket already
// CLOSING, leaving zombie entries.
connectedClients.delete(mapKey);
clientConfigVersions.delete(clientId);
return true;
};

View File

@@ -23,6 +23,7 @@ import m14 from "./scriptsPg/1.15.4";
import m15 from "./scriptsPg/1.16.0";
import m16 from "./scriptsPg/1.17.0";
import m17 from "./scriptsPg/1.18.0";
import m18 from "./scriptsPg/1.18.3";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -45,7 +46,8 @@ const migrations = [
{ version: "1.15.4", run: m14 },
{ version: "1.16.0", run: m15 },
{ version: "1.17.0", run: m16 },
{ version: "1.18.0", run: m17 }
{ version: "1.18.0", run: m17 },
{ version: "1.18.3", run: m18 }
// Add new migrations here as they are created
] as {
version: string;

View File

@@ -41,6 +41,7 @@ import m35 from "./scriptsSqlite/1.15.4";
import m36 from "./scriptsSqlite/1.16.0";
import m37 from "./scriptsSqlite/1.17.0";
import m38 from "./scriptsSqlite/1.18.0";
import m39 from "./scriptsSqlite/1.18.3";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -79,7 +80,8 @@ const migrations = [
{ version: "1.15.4", run: m35 },
{ version: "1.16.0", run: m36 },
{ version: "1.17.0", run: m37 },
{ version: "1.18.0", run: m38 }
{ version: "1.18.0", run: m38 },
{ version: "1.18.3", run: m39 }
// Add new migrations here as they are created
] as const;

View File

@@ -16,6 +16,9 @@ export default async function migration() {
thc."targetId",
t."siteId",
s."orgId",
r."name" AS "resourceName",
t."ip",
t."port",
thc."hcEnabled",
thc."hcPath",
thc."hcScheme",
@@ -33,13 +36,17 @@ export default async function migration() {
thc."hcTlsServerName"
FROM "targetHealthCheck" thc
JOIN "targets" t ON thc."targetId" = t."targetId"
JOIN "sites" s ON t."siteId" = s."siteId"`
JOIN "sites" s ON t."siteId" = s."siteId"
JOIN "resources" r ON t."resourceId" = r."resourceId"`
);
const existingHealthChecks = healthChecksQuery.rows as {
targetHealthCheckId: number;
targetId: number;
siteId: number;
orgId: string;
resourceName: string;
ip: string;
port: number;
hcEnabled: boolean;
hcPath: string | null;
hcScheme: string | null;
@@ -385,6 +392,7 @@ export default async function migration() {
"targetId",
"orgId",
"siteId",
"name",
"hcEnabled",
"hcPath",
"hcScheme",
@@ -405,6 +413,7 @@ export default async function migration() {
${hc.targetId},
${hc.orgId},
${hc.siteId},
${`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`},
${hc.hcEnabled},
${hc.hcPath},
${hc.hcScheme},
@@ -545,6 +554,72 @@ export default async function migration() {
throw e;
}
// Recompute resource health by aggregating across the resource's targets'
// target health checks, then update the resources.health column to match.
try {
const resourceTargetHealthQuery = await db.execute(
sql`SELECT
r."resourceId" AS "resourceId",
thc."hcHealth" AS "hcHealth"
FROM "resources" r
LEFT JOIN "targets" t ON t."resourceId" = r."resourceId"
LEFT JOIN "targetHealthCheck" thc ON thc."targetId" = t."targetId"`
);
const resourceTargetHealthRows =
resourceTargetHealthQuery.rows as {
resourceId: number;
hcHealth: string | null;
}[];
const resourceHealthMap = new Map<
number,
{ hasHealthy: boolean; hasUnhealthy: boolean; hasUnknown: boolean }
>();
for (const row of resourceTargetHealthRows) {
const entry = resourceHealthMap.get(row.resourceId) ?? {
hasHealthy: false,
hasUnhealthy: false,
hasUnknown: false
};
const status = row.hcHealth ?? "unknown";
if (status === "healthy") entry.hasHealthy = true;
else if (status === "unhealthy") entry.hasUnhealthy = true;
else entry.hasUnknown = true;
resourceHealthMap.set(row.resourceId, entry);
}
let updatedResourceCount = 0;
for (const [resourceId, flags] of resourceHealthMap.entries()) {
let aggregated: "healthy" | "unhealthy" | "degraded" | "unknown";
if (flags.hasHealthy && flags.hasUnhealthy) {
aggregated = "degraded";
} else if (flags.hasHealthy) {
aggregated = "healthy";
} else if (flags.hasUnhealthy) {
aggregated = "unhealthy";
} else {
aggregated = "unknown";
}
await db.execute(sql`
UPDATE "resources"
SET "health" = ${aggregated}
WHERE "resourceId" = ${resourceId}
`);
updatedResourceCount++;
}
console.log(
`Recomputed health for ${updatedResourceCount} resource(s) based on target health checks`
);
} catch (e) {
console.error(
"Error while recomputing resource health from target health checks:",
e
);
throw e;
}
// Seed statusHistory for all existing health checks
try {
const healthChecksQuery = await db.execute(

View File

@@ -0,0 +1,173 @@
import { db } from "@server/db/pg/driver";
import { sql } from "drizzle-orm";
const version = "1.18.3";
export default async function migration() {
console.log(`Running setup script ${version}...`);
// Query existing targetHealthCheck data with joined siteId and orgId before
// the transaction adds the new columns (which start NULL for existing rows).
// We will delete all rows and reinsert them with targetHealthCheckId = targetId
// so the two IDs form a stable 1:1 mapping.
const healthChecksQuery = await db.execute(
sql`SELECT
thc."targetHealthCheckId",
thc."targetId",
t."siteId",
s."orgId",
r."name" AS "resourceName",
t."ip",
t."port"
FROM "targetHealthCheck" thc
JOIN "targets" t ON thc."targetId" = t."targetId"
JOIN "sites" s ON t."siteId" = s."siteId"
JOIN "resources" r ON t."resourceId" = r."resourceId"
WHERE thc."name" IS NULL OR thc."name" = ''`
);
const existingHealthChecks = healthChecksQuery.rows as {
targetHealthCheckId: number;
targetId: number;
siteId: number;
orgId: string;
resourceName: string;
ip: string;
port: number;
}[];
console.log(
`Found ${existingHealthChecks.length} existing targetHealthCheck row(s) to migrate`
);
try {
await db.execute(sql`BEGIN`);
await db.execute(sql`
CREATE TABLE "trialNotifications" (
"notificationId" serial PRIMARY KEY NOT NULL,
"subscriptionId" varchar(255) NOT NULL,
"notificationType" varchar(50) NOT NULL,
"sentAt" bigint NOT NULL
);
`);
await db.execute(sql`
ALTER TABLE "trialNotifications" ADD CONSTRAINT "trialNotifications_subscriptionId_subscriptions_subscriptionId_fk" FOREIGN KEY ("subscriptionId") REFERENCES "public"."subscriptions"("subscriptionId") ON DELETE cascade ON UPDATE no action;
`);
await db.execute(sql`COMMIT`);
console.log("Migrated database");
} catch (e) {
await db.execute(sql`ROLLBACK`);
console.log("Unable to migrate database");
console.log(e);
throw e;
}
if (existingHealthChecks.length > 0) {
// fix the name column
try {
for (const hc of existingHealthChecks) {
await db.execute(sql`
UPDATE "targetHealthCheck"
SET "name" = ${`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`}
WHERE "targetHealthCheckId" = ${hc.targetHealthCheckId}
`);
}
console.log(
`Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs`
);
} catch (e) {
console.error("Error while migrating targetHealthCheck rows:", e);
throw e;
}
}
// Recompute resource health by aggregating across the resource's targets'
// target health checks, then update the resources.health column to match.
try {
const resourceTargetHealthQuery = await db.execute(
sql`SELECT
r."resourceId" AS "resourceId",
r."orgId" AS "orgId",
r."health" AS "currentHealth",
thc."hcHealth" AS "hcHealth"
FROM "resources" r
LEFT JOIN "targets" t ON t."resourceId" = r."resourceId"
LEFT JOIN "targetHealthCheck" thc ON thc."targetId" = t."targetId"`
);
const resourceTargetHealthRows = resourceTargetHealthQuery.rows as {
resourceId: number;
orgId: string;
currentHealth: string | null;
hcHealth: string | null;
}[];
const resourceHealthMap = new Map<
number,
{
hasHealthy: boolean;
hasUnhealthy: boolean;
hasUnknown: boolean;
orgId: string;
currentHealth: string | null;
}
>();
for (const row of resourceTargetHealthRows) {
const entry = resourceHealthMap.get(row.resourceId) ?? {
hasHealthy: false,
hasUnhealthy: false,
hasUnknown: false,
orgId: row.orgId,
currentHealth: row.currentHealth
};
const status = row.hcHealth ?? "unknown";
if (status === "healthy") entry.hasHealthy = true;
else if (status === "unhealthy") entry.hasUnhealthy = true;
else entry.hasUnknown = true;
resourceHealthMap.set(row.resourceId, entry);
}
const now = Math.floor(Date.now() / 1000);
let updatedResourceCount = 0;
for (const [resourceId, entry] of resourceHealthMap.entries()) {
let aggregated: "healthy" | "unhealthy" | "degraded" | "unknown";
if (entry.hasHealthy && entry.hasUnhealthy) {
aggregated = "degraded";
} else if (entry.hasHealthy) {
aggregated = "healthy";
} else if (entry.hasUnhealthy) {
aggregated = "unhealthy";
} else {
aggregated = "unknown";
}
if (entry.currentHealth !== aggregated) {
await db.execute(sql`
UPDATE "resources"
SET "health" = ${aggregated}
WHERE "resourceId" = ${resourceId}
`);
await db.execute(sql`
INSERT INTO "statusHistory" ("entityType", "entityId", "orgId", "status", "timestamp")
VALUES ('resource', ${resourceId}, ${entry.orgId}, ${aggregated}, ${now})
`);
updatedResourceCount++;
}
}
console.log(
`Recomputed health for ${updatedResourceCount} resource(s) based on target health checks`
);
} catch (e) {
console.error(
"Error while recomputing resource health from target health checks:",
e
);
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -22,6 +22,9 @@ export default async function migration() {
thc."targetId",
t."siteId",
s."orgId",
r."name" AS "resourceName",
t."ip",
t."port",
thc."hcEnabled",
thc."hcPath",
thc."hcScheme",
@@ -39,13 +42,17 @@ export default async function migration() {
thc."hcTlsServerName"
FROM 'targetHealthCheck' thc
JOIN 'targets' t ON thc."targetId" = t."targetId"
JOIN 'sites' s ON t."siteId" = s."siteId"`
JOIN 'sites' s ON t."siteId" = s."siteId"
JOIN 'resources' r ON t."resourceId" = r."resourceId"`
)
.all() as {
targetHealthCheckId: number;
targetId: number;
siteId: number;
orgId: string;
resourceName: string;
ip: string;
port: number;
hcEnabled: number;
hcPath: string | null;
hcScheme: string | null;
@@ -255,7 +262,7 @@ export default async function migration() {
).run();
db.prepare(
`
INSERT INTO '__new_siteResources'("siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain") SELECT "siteResourceId", "orgId", NULL, NULL, "niceId", "name", 0, "mode", NULL, "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", NULL, NULL, NULL FROM 'siteResources';
INSERT INTO '__new_siteResources'("siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain") SELECT "siteResourceId", "orgId", NULL, NULL, "niceId", "name", 0, "mode", NULL, "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", COALESCE("tcpPortRangeString", '*'), COALESCE("udpPortRangeString", '*'), COALESCE("disableIcmp", 0), "authDaemonPort", "authDaemonMode", NULL, NULL, NULL FROM 'siteResources';
`
).run();
db.prepare(
@@ -392,6 +399,7 @@ export default async function migration() {
"targetId",
"orgId",
"siteId",
"name",
"hcEnabled",
"hcPath",
"hcScheme",
@@ -407,7 +415,7 @@ export default async function migration() {
"hcStatus",
"hcHealth",
"hcTlsServerName"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
const insertAll = db.transaction(() => {
@@ -417,6 +425,7 @@ export default async function migration() {
hc.targetId,
hc.orgId,
hc.siteId,
`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`,
hc.hcEnabled,
hc.hcPath,
hc.hcScheme,
@@ -509,6 +518,70 @@ export default async function migration() {
`Seeded statusHistory for ${allResources.length} resource(s)`
);
// Recompute resource health by aggregating across the resource's
// targets' target health checks, then update resources.health.
const resourceTargetHealthRows = db
.prepare(
`SELECT
r."resourceId" AS "resourceId",
thc."hcHealth" AS "hcHealth"
FROM 'resources' r
LEFT JOIN 'targets' t ON t."resourceId" = r."resourceId"
LEFT JOIN 'targetHealthCheck' thc ON thc."targetId" = t."targetId"`
)
.all() as {
resourceId: number;
hcHealth: string | null;
}[];
const resourceHealthMap = new Map<
number,
{
hasHealthy: boolean;
hasUnhealthy: boolean;
hasUnknown: boolean;
}
>();
for (const row of resourceTargetHealthRows) {
const entry = resourceHealthMap.get(row.resourceId) ?? {
hasHealthy: false,
hasUnhealthy: false,
hasUnknown: false
};
const status = row.hcHealth ?? "unknown";
if (status === "healthy") entry.hasHealthy = true;
else if (status === "unhealthy") entry.hasUnhealthy = true;
else entry.hasUnknown = true;
resourceHealthMap.set(row.resourceId, entry);
}
const updateResourceHealth = db.prepare(
`UPDATE 'resources' SET "health" = ? WHERE "resourceId" = ?`
);
const recomputeResourceHealth = db.transaction(() => {
for (const [resourceId, flags] of resourceHealthMap.entries()) {
let aggregated:
| "healthy"
| "unhealthy"
| "degraded"
| "unknown";
if (flags.hasHealthy && flags.hasUnhealthy) {
aggregated = "degraded";
} else if (flags.hasHealthy) {
aggregated = "healthy";
} else if (flags.hasUnhealthy) {
aggregated = "unhealthy";
} else {
aggregated = "unknown";
}
updateResourceHealth.run(aggregated, resourceId);
}
});
recomputeResourceHealth();
console.log(
`Recomputed health for ${resourceHealthMap.size} resource(s) based on target health checks`
);
// Seed statusHistory for all existing health checks
const allHealthChecks = db
.prepare(

View File

@@ -0,0 +1,172 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.18.3";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
db.pragma("foreign_keys = OFF");
db.transaction(() => {
db.prepare(
`
CREATE TABLE 'trialNotifications' (
'notificationId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
'subscriptionId' text NOT NULL,
'notificationType' text NOT NULL,
'sentAt' integer NOT NULL,
FOREIGN KEY ('subscriptionId') REFERENCES 'subscriptions'('subscriptionId') ON UPDATE no action ON DELETE cascade
);
`
).run();
})();
db.pragma("foreign_keys = ON");
console.log("Migrated database");
// Fix names for health checks that don't have one
const healthChecksWithoutName = db
.prepare(
`SELECT
thc."targetHealthCheckId",
r."name" AS "resourceName",
t."ip",
t."port"
FROM 'targetHealthCheck' thc
JOIN 'targets' t ON thc."targetId" = t."targetId"
JOIN 'resources' r ON t."resourceId" = r."resourceId"
WHERE thc."name" IS NULL OR thc."name" = ''`
)
.all() as {
targetHealthCheckId: number;
resourceName: string;
ip: string;
port: number;
}[];
console.log(
`Found ${healthChecksWithoutName.length} targetHealthCheck row(s) with missing names`
);
if (healthChecksWithoutName.length > 0) {
const updateName = db.prepare(
`UPDATE 'targetHealthCheck' SET "name" = ? WHERE "targetHealthCheckId" = ?`
);
const updateAllNames = db.transaction(() => {
for (const hc of healthChecksWithoutName) {
updateName.run(
`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`,
hc.targetHealthCheckId
);
}
});
updateAllNames();
console.log(
`Updated names for ${healthChecksWithoutName.length} targetHealthCheck row(s)`
);
}
// Recompute resource health by aggregating across the resource's
// targets' target health checks, then update resources.health and
// insert a statusHistory entry for any resource whose health changed.
const resourceTargetHealthRows = db
.prepare(
`SELECT
r."resourceId" AS "resourceId",
r."orgId" AS "orgId",
r."health" AS "currentHealth",
thc."hcHealth" AS "hcHealth"
FROM 'resources' r
LEFT JOIN 'targets' t ON t."resourceId" = r."resourceId"
LEFT JOIN 'targetHealthCheck' thc ON thc."targetId" = t."targetId"`
)
.all() as {
resourceId: number;
orgId: string;
currentHealth: string | null;
hcHealth: string | null;
}[];
const resourceHealthMap = new Map<
number,
{
hasHealthy: boolean;
hasUnhealthy: boolean;
hasUnknown: boolean;
orgId: string;
currentHealth: string | null;
}
>();
for (const row of resourceTargetHealthRows) {
const entry = resourceHealthMap.get(row.resourceId) ?? {
hasHealthy: false,
hasUnhealthy: false,
hasUnknown: false,
orgId: row.orgId,
currentHealth: row.currentHealth
};
const status = row.hcHealth ?? "unknown";
if (status === "healthy") entry.hasHealthy = true;
else if (status === "unhealthy") entry.hasUnhealthy = true;
else entry.hasUnknown = true;
resourceHealthMap.set(row.resourceId, entry);
}
const updateResourceHealth = db.prepare(
`UPDATE 'resources' SET "health" = ? WHERE "resourceId" = ?`
);
const insertResourceHistory = db.prepare(
`INSERT INTO 'statusHistory' ("entityType", "entityId", "orgId", "status", "timestamp") VALUES (?, ?, ?, ?, ?)`
);
const now = Math.floor(Date.now() / 1000);
let updatedResourceCount = 0;
const recomputeAll = db.transaction(() => {
for (const [resourceId, entry] of resourceHealthMap.entries()) {
let aggregated:
| "healthy"
| "unhealthy"
| "degraded"
| "unknown";
if (entry.hasHealthy && entry.hasUnhealthy) {
aggregated = "degraded";
} else if (entry.hasHealthy) {
aggregated = "healthy";
} else if (entry.hasUnhealthy) {
aggregated = "unhealthy";
} else {
aggregated = "unknown";
}
if (entry.currentHealth !== aggregated) {
updateResourceHealth.run(aggregated, resourceId);
insertResourceHistory.run(
"resource",
resourceId,
entry.orgId,
aggregated,
now
);
updatedResourceCount++;
}
}
});
recomputeAll();
console.log(
`Recomputed health for ${updatedResourceCount} resource(s) based on target health checks`
);
} catch (e) {
console.log("Failed to migrate db:", e);
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -35,6 +35,7 @@ import {
} from "@app/components/Credenza";
import { cn } from "@app/lib/cn";
import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react";
import { Badge } from "@app/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@app/components/ui/alert";
import {
Tooltip,
@@ -55,6 +56,7 @@ import {
tier3LimitSet
} from "@server/lib/billing/limitSet";
import { FeatureId } from "@server/lib/billing/features";
import TrialBillingBanner from "@app/components/TrialBillingBanner";
// Plan tier definitions matching the mockup
type PlanId = "basic" | "home" | "team" | "business" | "enterprise";
@@ -805,6 +807,20 @@ export default function BillingPage() {
return (
<SettingsContainer>
{/* Trial Banner */}
{isTrial && (
<TrialBillingBanner
onUpgrade={() => {
const currentPlan = planOptions.find(
(p) => p.id === currentPlanId
);
if (currentPlan?.tierType) {
handleStartSubscription(currentPlan.tierType);
}
}}
/>
)}
{/* Subscription Status Alert */}
{isProblematicState && statusMessage && (
<Alert variant="destructive" className="mb-6">
@@ -859,8 +875,19 @@ export default function BillingPage() {
)}
>
<div className="flex-1">
<div className="text-2xl">
{plan.name}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-2xl">
{plan.name}
</span>
{isCurrentPlan && isTrial && (
<Badge
variant="outlinePrimary"
className="text-xs"
>
{t("billingTrialBadge") ||
"Free Trial"}
</Badge>
)}
</div>
<div className="mt-1">
<span className="text-xl">

Some files were not shown because too many files have changed in this diff Show More