Compare commits

...

208 Commits

Author SHA1 Message Date
Owen
49ae5eecb6 Filter only approved sites 2026-04-13 21:56:35 -07:00
Owen
646e440dec Merge branch 'dev' into private-http-ha 2026-04-13 20:52:47 -07:00
Owen
03d95874e6 Proxy targets returns an array 2026-04-13 20:44:35 -07:00
Owen
1b9a395432 Add logging for debugging 2026-04-13 17:56:55 -07:00
Owen
3996e14e70 Add comment 2026-04-13 17:56:51 -07:00
Owen
7a40084bf4 Rename for better understanding 2026-04-13 17:21:34 -07:00
Owen
30fd48a14a HA site crud working 2026-04-13 17:17:28 -07:00
Owen
5d51af4330 Rename script 2026-04-13 16:22:53 -07:00
Owen
173a81ead8 Fixing up the crud for multiple sites 2026-04-13 16:22:22 -07:00
Owen
676eacc9cf Invert logic for pangolin dns 2026-04-13 16:06:23 -07:00
Owen
93998f9fd5 Fix ts issue 2026-04-13 12:27:29 -07:00
Owen
c554e69514 Fill the width 2026-04-13 12:11:15 -07:00
Owen
a6e10e55cc Handle grandfather on the front end 2026-04-13 12:08:30 -07:00
Owen
9cb1043545 Push back date 2026-04-13 11:33:51 -07:00
Owen Schwartz
96e33d33b0 Merge pull request #2851 from fosrl/crowdin_dev
New Crowdin updates
2026-04-13 11:25:53 -07:00
Owen Schwartz
ccc7003ac1 New translations en-us.json (Spanish) 2026-04-13 11:24:32 -07:00
Owen Schwartz
93cbd47b5d New translations en-us.json (Norwegian Bokmal) 2026-04-13 11:24:30 -07:00
Owen Schwartz
8b808e44b6 New translations en-us.json (Chinese Simplified) 2026-04-13 11:24:28 -07:00
Owen Schwartz
0644e26297 New translations en-us.json (Turkish) 2026-04-13 11:24:27 -07:00
Owen Schwartz
682653b977 New translations en-us.json (Russian) 2026-04-13 11:24:25 -07:00
Owen Schwartz
0053cfc8fc New translations en-us.json (Portuguese) 2026-04-13 11:24:23 -07:00
Owen Schwartz
5cb62a30cc New translations en-us.json (Polish) 2026-04-13 11:24:21 -07:00
Owen Schwartz
e596a63058 New translations en-us.json (Dutch) 2026-04-13 11:24:19 -07:00
Owen Schwartz
3ec32afb37 New translations en-us.json (Korean) 2026-04-13 11:24:17 -07:00
Owen Schwartz
0189a86757 New translations en-us.json (Italian) 2026-04-13 11:24:15 -07:00
Owen Schwartz
ee32307654 New translations en-us.json (German) 2026-04-13 11:24:14 -07:00
Owen Schwartz
2f08e6b838 New translations en-us.json (Czech) 2026-04-13 11:24:12 -07:00
Owen Schwartz
c8a3fc350d New translations en-us.json (Bulgarian) 2026-04-13 11:24:10 -07:00
Owen Schwartz
dc63ef1284 New translations en-us.json (French) 2026-04-13 11:24:07 -07:00
Owen
92332fb02f Hide the home unless you have it 2026-04-13 11:17:14 -07:00
miloschwartz
acc6a26654 update readme 2026-04-13 11:12:09 -07:00
Owen
2bd4d2faaf Merge branch 'main' into dev 2026-04-13 10:50:12 -07:00
Owen
1e77ead488 Adjust functioning of ee button 2026-04-13 10:49:57 -07:00
Owen
561a9ab379 Merge branch 'private-site-ha' into private-http-ha 2026-04-13 10:25:49 -07:00
Milo Schwartz
c008ef7c1b Merge pull request #2850 from fosrl/miloschwartz-patch-1
Update README.md
2026-04-13 10:04:55 -07:00
Milo Schwartz
02dfeed3ce Update README.md 2026-04-13 13:03:53 -04:00
Owen Schwartz
34cc2e0ed1 Merge pull request #2841 from AdnanSilajdzic/fix/worldmap-typescript-followup
fix(worldmap): correct topojson feature typing
2026-04-13 09:42:05 -07:00
Owen
71497a7887 Merge branch 'dev' into private-site-ha 2026-04-12 17:54:07 -07:00
Owen
aa41a63430 Dont run the acme in saas or when we control dns 2026-04-12 17:50:27 -07:00
Owen
0db55daff6 Merge branch 'private-http' of github.com:fosrl/pangolin into private-http 2026-04-12 17:47:59 -07:00
Owen
9b271950d2 Push down certs when they are detected 2026-04-12 17:31:51 -07:00
Owen
89b6b1fb56 Placeholder screen and certs are working 2026-04-12 16:49:49 -07:00
miloschwartz
f5d0694574 change user devices column name from online to connected 2026-04-12 15:27:14 -07:00
miloschwartz
f91da2ec46 fix no default idp selector showing on ce closes #2813 2026-04-12 15:20:09 -07:00
miloschwartz
89471a0174 include site name in target dropdown in public resources table 2026-04-12 15:09:40 -07:00
Owen
789b991c56 Logging and http working 2026-04-12 15:08:17 -07:00
miloschwartz
0cbcc0c29c remove extra sites query 2026-04-12 14:58:55 -07:00
miloschwartz
b5e239d1ad adjust button size 2026-04-12 12:24:52 -07:00
miloschwartz
5f79e8ebbd Merge branch 'private-http' of https://github.com/fosrl/pangolin into private-http 2026-04-12 12:17:57 -07:00
miloschwartz
1564c4bee7 add multi site selector for ha on private resources 2026-04-12 12:17:45 -07:00
Owen
0cf385b718 CRUD and newt mode http mostly working 2026-04-12 12:15:29 -07:00
Adnan Silajdzic
0cb04d0290 fix(worldmap): correct topojson feature typing 2026-04-12 17:05:53 +00:00
Owen
83ecf53776 Add logging 2026-04-11 21:56:39 -07:00
Owen
5803da4893 Crud working 2026-04-11 21:09:12 -07:00
miloschwartz
e118e5b047 add list alises endpoint 2026-04-11 21:03:35 -07:00
miloschwartz
7e4e8ea266 add niceId to list user resources 2026-04-11 17:56:16 -07:00
Owen
fc4633db91 Add domain component to the site resource 2026-04-11 17:19:18 -07:00
Owen
2f386f8e47 Grandfather in old users 2026-04-11 16:59:43 -07:00
Owen
f4ea572f6b Fix #2828 2026-04-11 16:50:28 -07:00
Owen Schwartz
825df7da63 Merge pull request #2806 from jbelke/fix-invite-email-encoding
Fix invite email encoding
2026-04-11 16:37:49 -07:00
Owen Schwartz
cd34f0a7b0 Merge pull request #2799 from LaurenceJJones/fix/proxy-target-deletion
fix: use targetId as row identifier
2026-04-11 16:35:09 -07:00
Owen Schwartz
b1b22c439a Merge pull request #2825 from AdnanSilajdzic/fix/worldmap-hover-stuck-public
fix(analytics): prevent countries from getting stuck highlighted on world map
2026-04-11 16:32:32 -07:00
Owen
eac747849b Restrict namespaces to paid plans due to abuse 2026-04-11 14:22:00 -07:00
Owen
9e50569c31 Merge branch 'private-http' of github.com:fosrl/pangolin into private-http 2026-04-10 17:23:06 -04:00
Owen
a19f0acfb9 Working 2026-04-10 17:21:54 -04:00
Adnan Silajdzic
1aedf9da0a fix(worldmap): avoid stuck country hover state 2026-04-10 14:37:48 +00:00
miloschwartz
8a47d69d0d fix domain picker 2026-04-09 22:48:43 -04:00
miloschwartz
73482c2a05 disable ssh access tab on http mode 2026-04-09 22:38:04 -04:00
miloschwartz
79751c208d basic ui working 2026-04-09 22:24:39 -04:00
Owen
510931e7d6 Add ssl to schema 2026-04-09 21:02:20 -04:00
Owen
584a8e7d1d Generate certs and add placeholder screen 2026-04-09 20:53:03 -04:00
miloschwartz
a74378e1d3 show domain and destination with port in table 2026-04-09 18:17:08 -04:00
miloschwartz
840684aeba dont show wildcard in domain picker 2026-04-09 17:54:25 -04:00
Owen
c027c8958b Add scheme 2026-04-09 17:54:17 -04:00
miloschwartz
a730f4da1d dont show wildcard in domain picker 2026-04-09 17:54:08 -04:00
miloschwartz
d73796b92e add new modes, port input, and domain picker 2026-04-09 17:49:22 -04:00
Owen
96b9123306 Merge branch 'dev' into private-site-ha 2026-04-09 17:39:45 -04:00
Owen
e4cbf088b4 Working on defining the schema to send down 2026-04-09 17:23:24 -04:00
Owen
333ccb8438 Restrict to make sure there is an alias 2026-04-09 17:10:48 -04:00
miloschwartz
f57012eb90 dont show international domain warning when capital letter present 2026-04-09 17:06:04 -04:00
miloschwartz
34387d9859 simplify wildcard domain on non pangolin-dns 2026-04-09 17:04:28 -04:00
Owen
eb771ceda4 Add http to mode and put destinationPort back 2026-04-09 17:02:08 -04:00
miloschwartz
80f5914fdd add pluto 2026-04-09 16:15:19 -04:00
miloschwartz
eaa70da4dd add pluto 2026-04-09 16:14:46 -04:00
Owen
1efd2af44b Sync acme certs into the database 2026-04-09 15:38:36 -04:00
Owen
466f137590 Fix migration by testing for orphans 2026-04-09 10:29:51 -04:00
Joshua Belke
028df8bf27 fix: remove encodeURIComponent from invite link email parameter
The @ symbol in email addresses was being encoded as %40 when
constructing invite URLs, causing broken or garbled links when
copied/shared by users.

- Remove encodeURIComponent(email) from server-side invite link
  construction in inviteUser.ts (both new invite and regenerate paths)
- Remove encodeURIComponent(email) from client-side redirect URLs in
  InviteStatusCard.tsx (login, signup, and useEffect redirect paths)
- Valid Zod-validated email addresses do not contain characters that
  require URL encoding for safe query parameter use (@ is permitted
  in query strings per RFC 3986 §3.4)
2026-04-07 14:58:27 -04:00
Owen
28ef5238c9 Add CODEOWNERS 2026-04-07 11:36:02 -04:00
Laurence
7d3d5b2b22 use targetid also on proxy create as that also has same issue 2026-04-06 14:17:04 +01:00
Laurence
81eba50c9a fix: use targetId as row identifier
fix: 2797
2026-04-06 14:03:33 +01:00
Owen Schwartz
3436105bec Merge pull request #2784 from fosrl/dev
Try to prevent deadlocks
2026-04-03 23:01:09 -04:00
Owen
d948d2ec33 Try to prevent deadlocks 2026-04-03 22:55:04 -04:00
Owen Schwartz
4b3375ab8e Merge pull request #2783 from fosrl/dev
Fix 1.17.0
2026-04-03 22:42:03 -04:00
Owen
6b8a3c8d77 Revert #2570
Fix #2782
2026-04-03 22:37:42 -04:00
Owen
ba9794c067 Put middleware back
Fix #2781
2026-04-03 22:16:26 -04:00
Owen Schwartz
6ce165bfd5 Merge pull request #2780 from fosrl/dev
1.17.0
2026-04-03 18:19:40 -04:00
Owen
eb4b2daaab Use the right encryption 2026-04-03 17:59:21 -04:00
Owen
8cbc8dec89 Generate address 2026-04-03 17:25:39 -04:00
Owen
e89e60d50b Encrypt the streaming data 2026-04-03 15:33:29 -04:00
Owen
c45308f234 Send to the right place 2026-04-03 15:33:29 -04:00
Owen Schwartz
40205c40c5 Merge pull request #2779 from fosrl/crowdin_dev
New Crowdin updates
2026-04-03 15:00:11 -04:00
Owen Schwartz
f3fe2dd33b New translations en-us.json (Spanish) 2026-04-03 14:58:56 -04:00
Owen Schwartz
8edcc45033 New translations en-us.json (Norwegian Bokmal) 2026-04-03 14:58:55 -04:00
Owen Schwartz
91471a4aca New translations en-us.json (Chinese Simplified) 2026-04-03 14:58:53 -04:00
Owen Schwartz
ae2c37a2f6 New translations en-us.json (Turkish) 2026-04-03 14:58:52 -04:00
Owen Schwartz
c8208f0a88 New translations en-us.json (Russian) 2026-04-03 14:58:50 -04:00
Owen Schwartz
e11dfbd29c New translations en-us.json (Portuguese) 2026-04-03 14:58:49 -04:00
Owen Schwartz
b375d20598 New translations en-us.json (Polish) 2026-04-03 14:58:48 -04:00
Owen Schwartz
c4b82c69f8 New translations en-us.json (Dutch) 2026-04-03 14:58:47 -04:00
Owen Schwartz
c9a00420a0 New translations en-us.json (Korean) 2026-04-03 14:58:45 -04:00
Owen Schwartz
36ef9cd442 New translations en-us.json (Italian) 2026-04-03 14:58:44 -04:00
Owen Schwartz
5e08779ab0 New translations en-us.json (German) 2026-04-03 14:58:42 -04:00
Owen Schwartz
16a0e1ce7b New translations en-us.json (Czech) 2026-04-03 14:58:41 -04:00
Owen Schwartz
8b03484ade New translations en-us.json (Bulgarian) 2026-04-03 14:58:39 -04:00
Owen Schwartz
9da9974adf New translations en-us.json (French) 2026-04-03 14:58:38 -04:00
Owen Schwartz
6f80cf3db2 New translations en-us.json (Spanish) 2026-04-03 13:03:44 -04:00
Owen Schwartz
76d8f44779 New translations en-us.json (Norwegian Bokmal) 2026-04-03 13:03:43 -04:00
Owen Schwartz
700c92efcb New translations en-us.json (Chinese Simplified) 2026-04-03 13:03:41 -04:00
Owen Schwartz
d17e0c9f50 New translations en-us.json (Turkish) 2026-04-03 13:03:39 -04:00
Owen Schwartz
f00b9794f5 New translations en-us.json (Russian) 2026-04-03 13:03:38 -04:00
Owen Schwartz
daff59c93f New translations en-us.json (Portuguese) 2026-04-03 13:03:36 -04:00
Owen Schwartz
aa8954366c New translations en-us.json (Polish) 2026-04-03 13:03:35 -04:00
Owen Schwartz
87464d53bd New translations en-us.json (Dutch) 2026-04-03 13:03:33 -04:00
Owen Schwartz
e04f17c9aa New translations en-us.json (Korean) 2026-04-03 13:03:32 -04:00
Owen Schwartz
b25e3499d8 New translations en-us.json (Italian) 2026-04-03 13:03:30 -04:00
Owen Schwartz
2e6f74a6f8 New translations en-us.json (German) 2026-04-03 13:03:28 -04:00
Owen Schwartz
8eee0ca5a5 New translations en-us.json (Czech) 2026-04-03 13:03:26 -04:00
Owen Schwartz
c2ebc0a0ff New translations en-us.json (Bulgarian) 2026-04-03 13:03:24 -04:00
Owen Schwartz
03c905a7af New translations en-us.json (French) 2026-04-03 13:03:22 -04:00
Owen Schwartz
035644eaf7 Merge pull request #2778 from fosrl/dev
1.17.0-s.2
2026-04-03 12:35:03 -04:00
Owen
8ce45a1acd Update casting again 2026-04-03 12:34:37 -04:00
Owen Schwartz
16e7233a3e Merge pull request #2777 from fosrl/dev
1.17.0-s.1
2026-04-03 12:19:23 -04:00
Owen
a331dd3fb4 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-04-03 12:18:33 -04:00
Owen Schwartz
e3e2938b28 Merge pull request #2761 from fosrl/crowdin_dev
New Crowdin updates
2026-04-03 12:16:31 -04:00
Owen
73e96b1b28 Type cast data 2026-04-03 12:15:39 -04:00
miloschwartz
b8194295ec fix translation syntax err 2026-04-03 11:47:54 -04:00
miloschwartz
382a46dfff fix text formatting in delete dialog 2026-04-03 11:44:56 -04:00
Owen Schwartz
1f74e1b320 Merge pull request #2776 from fosrl/dev
1.17.0-s.0
2026-04-03 11:39:35 -04:00
Owen
fee780cb81 Add siem to migration 2026-04-03 11:32:02 -04:00
Owen
5056cba85d Add siem to migration 2026-04-03 11:32:02 -04:00
miloschwartz
dab38ff82c use debug log on log stream start 2026-04-03 11:16:56 -04:00
Owen
d83fa63af5 Fix to null out the rewrite on the frontend too 2026-04-02 21:58:14 -04:00
Owen
d5837ab718 Fix to use the stored data 2026-04-02 21:57:59 -04:00
Owen Schwartz
f85cfc4c68 New translations en-us.json (Spanish) 2026-04-02 18:35:13 -04:00
Owen Schwartz
0b2aceafe0 New translations en-us.json (Norwegian Bokmal) 2026-04-02 18:35:11 -04:00
Owen Schwartz
059db34a53 New translations en-us.json (Chinese Simplified) 2026-04-02 18:35:10 -04:00
Owen Schwartz
bc1ea86b4e New translations en-us.json (Turkish) 2026-04-02 18:35:09 -04:00
Owen Schwartz
9f2ced1933 New translations en-us.json (Russian) 2026-04-02 18:35:08 -04:00
Owen Schwartz
013cff9b6e New translations en-us.json (Portuguese) 2026-04-02 18:35:06 -04:00
Owen Schwartz
aa19437031 New translations en-us.json (Polish) 2026-04-02 18:35:05 -04:00
Owen Schwartz
e848ef848b New translations en-us.json (Dutch) 2026-04-02 18:35:03 -04:00
Owen Schwartz
bb6605337f New translations en-us.json (Korean) 2026-04-02 18:35:02 -04:00
Owen Schwartz
8df8383468 New translations en-us.json (Italian) 2026-04-02 18:35:01 -04:00
Owen Schwartz
a7e9de3ac4 New translations en-us.json (German) 2026-04-02 18:34:59 -04:00
Owen Schwartz
8df41f514e New translations en-us.json (Czech) 2026-04-02 18:34:58 -04:00
Owen Schwartz
c2bf50b121 New translations en-us.json (Bulgarian) 2026-04-02 18:34:56 -04:00
Owen Schwartz
4e7dcbd7b5 New translations en-us.json (French) 2026-04-02 18:34:55 -04:00
Owen
b7ccb92236 Merge branch 'main' into dev 2026-04-02 17:39:25 -04:00
Owen Schwartz
23a151dd45 Merge pull request #2771 from LaurenceJJones/feature/systemd-install-instructions
enhance: Systemd newt instructions
2026-04-02 12:13:44 -04:00
Laurence
122079ddb2 split unix to linux and macos, show method everything other than windows, change nixos all to flake so makes sense under method 2026-04-02 17:05:50 +01:00
Owen Schwartz
1d0b0ae6ec Merge pull request #2770 from fosrl/revert-2766-feature/systemd-install-instructions
Revert "enhance: Systemd newt unit instructions"
2026-04-02 11:43:15 -04:00
Owen Schwartz
f1a0bc97e3 New translations en-us.json (Spanish) 2026-04-02 11:42:52 -04:00
Owen Schwartz
a57dfd1d12 New translations en-us.json (Norwegian Bokmal) 2026-04-02 11:42:50 -04:00
Owen Schwartz
c0a8304b91 New translations en-us.json (Chinese Simplified) 2026-04-02 11:42:48 -04:00
Owen Schwartz
ab7b968e28 New translations en-us.json (Turkish) 2026-04-02 11:42:47 -04:00
Owen Schwartz
f10b40c3b0 New translations en-us.json (Russian) 2026-04-02 11:42:45 -04:00
Owen Schwartz
7878ac9c76 New translations en-us.json (Portuguese) 2026-04-02 11:42:43 -04:00
Owen Schwartz
0752951842 New translations en-us.json (Polish) 2026-04-02 11:42:41 -04:00
Owen Schwartz
06bb6636a1 New translations en-us.json (Dutch) 2026-04-02 11:42:39 -04:00
Owen Schwartz
2fdd332a31 New translations en-us.json (Korean) 2026-04-02 11:42:38 -04:00
Owen Schwartz
98b1e9546a New translations en-us.json (Italian) 2026-04-02 11:42:36 -04:00
Owen Schwartz
184aa65c6d New translations en-us.json (German) 2026-04-02 11:42:34 -04:00
Owen Schwartz
70b3a432a4 New translations en-us.json (Czech) 2026-04-02 11:42:33 -04:00
Owen Schwartz
fb4fc75bd8 New translations en-us.json (Bulgarian) 2026-04-02 11:42:31 -04:00
Owen Schwartz
0479ed9e7f New translations en-us.json (French) 2026-04-02 11:42:29 -04:00
Owen Schwartz
8f3fbb94d2 New translations en-us.json (Spanish) 2026-04-01 09:58:52 -07:00
Owen Schwartz
e8c35bec1c New translations en-us.json (Norwegian Bokmal) 2026-04-01 09:58:50 -07:00
Owen Schwartz
728e7252eb New translations en-us.json (Chinese Simplified) 2026-04-01 09:58:49 -07:00
Owen Schwartz
1218507f7d New translations en-us.json (Turkish) 2026-04-01 09:58:47 -07:00
Owen Schwartz
a2dff0a35d New translations en-us.json (Russian) 2026-04-01 09:58:46 -07:00
Owen Schwartz
f411180908 New translations en-us.json (Portuguese) 2026-04-01 09:58:44 -07:00
Owen Schwartz
231a19b679 New translations en-us.json (Polish) 2026-04-01 09:58:43 -07:00
Owen Schwartz
58a87a986a New translations en-us.json (Dutch) 2026-04-01 09:58:41 -07:00
Owen Schwartz
61a78ef352 New translations en-us.json (Korean) 2026-04-01 09:58:39 -07:00
Owen Schwartz
e28e5ebb4e New translations en-us.json (Italian) 2026-04-01 09:58:38 -07:00
Owen Schwartz
19cef8c453 New translations en-us.json (German) 2026-04-01 09:58:36 -07:00
Owen Schwartz
1290d6cd5c New translations en-us.json (Czech) 2026-04-01 09:58:35 -07:00
Owen Schwartz
ad301074db New translations en-us.json (Bulgarian) 2026-04-01 09:58:33 -07:00
Owen Schwartz
30a756d254 New translations en-us.json (French) 2026-04-01 09:58:32 -07:00
Owen Schwartz
0fc1aa9191 Merge pull request #2755 from fosrl/dev
Update go version
2026-03-31 16:04:11 -07:00
Owen Schwartz
ddf417f4ca Merge pull request #2753 from fosrl/dev
Update security
2026-03-31 15:27:47 -07:00
Owen Schwartz
d08be59055 Merge pull request #2752 from fosrl/dev
1.17.0-rc.0
2026-03-31 15:24:25 -07:00
Owen Schwartz
322c136d1f Merge pull request #2748 from jaydeep-pipaliya/fix/empty-targets-toast-message
fix: show contextual toast when saving with no targets
2026-03-31 15:11:12 -07:00
jaydeep-pipaliya
e06f2f47b1 fix: show contextual toast when saving with no targets
Instead of always showing "Settings updated" when saving, show
"Targets cleared" when the target list is empty. This gives the user
accurate feedback without blocking the save action.

Fixes #586
2026-03-31 11:48:56 +05:30
Owen
02033f611f First pass at HA 2026-03-23 11:44:02 -07:00
Owen
1366901e24 Adjust build functions 2026-03-22 14:40:57 -07:00
Owen
c4f48f5748 WIP - more conversion 2026-03-22 14:29:47 -07:00
Owen
c48bc71443 Update crud endpoints and ui 2026-03-22 14:18:34 -07:00
Owen
d85496453f Change SSH WIP 2026-03-21 10:40:12 -07:00
Owen
21b91374a3 Merge branch 'private-site-ha' of github.com:fosrl/pangolin into private-site-ha 2026-03-20 17:24:27 -07:00
Owen
a1ce7f54a0 Continue to rebase 2026-03-20 09:17:10 -07:00
Owen
87524fe8ae Remove siteSiteResources 2026-03-19 21:53:52 -07:00
Owen
2093bb5357 Remove siteSiteResources 2026-03-19 21:44:59 -07:00
Owen
6f2e37948c Its many to one now 2026-03-19 21:30:00 -07:00
Owen
b7421e47cc Switch to using networks 2026-03-19 21:22:04 -07:00
Owen
7cbe3d42a1 Working on refactoring 2026-03-19 12:10:04 -07:00
Owen
d8b511b198 Adjust create and update to be many to one 2026-03-18 20:54:49 -07:00
Owen
102a235407 Adjust schema for many to one site resources 2026-03-18 20:54:38 -07:00
123 changed files with 7921 additions and 4318 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @oschwartz10612 @miloschwartz

View File

@@ -35,43 +35,53 @@
</div> </div>
<p align="center">
<a href="https://docs.pangolin.net/careers/join-us">
<img src="https://img.shields.io/badge/🚀_We're_Hiring!-Join_Our_Team-brightgreen?style=for-the-badge" alt="We're Hiring!" />
</a>
</p>
<p align="center"> <p align="center">
<strong> <strong>
Get started with Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a> Get started with Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
</strong> </strong>
</p> </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, all with zero-trust security and granular access control. 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 ## Installation
- Check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to install and set up Pangolin. - Get started for free with [Pangolin Cloud](https://app.pangolin.net/).
- Install from the [DigitalOcean marketplace](https://marketplace.digitalocean.com/apps/pangolin-ce-1?refcode=edf0480eeb81) for a one-click pre-configured installer. - Or, check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to self-host Pangolin.
- Install from the [DigitalOcean marketplace](https://marketplace.digitalocean.com/apps/pangolin-ce-1?refcode=edf0480eeb81) for a one-click pre-configured installer.
<img src="public/screenshots/hero.png" /> <img src="public/screenshots/hero.png" alt="Pangolin" width="100%" />
## Deployment Options ## Deployment Options
| <img width=500 /> | Description | - **Pangolin Cloud** — Fully managed service - no infrastructure required.
|-----------------|--------------| - **Self-Host: Community Edition** — Free, open source, and licensed under AGPL-3.
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing - no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/understanding-nodes) and connect to our control plane. | - **Self-Host: Enterprise Edition** — Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue.
| **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. |
| **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. |
## Key Features ## Key Features
| <img width=500 /> | <img width=500 /> | ### Connect remote networks with sites and NAT traversal
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|
| **Connect remote networks with sites**<br /><br />Pangolin's lightweight site connectors create secure tunnels from remote networks without requiring public IP addresses or open ports. Sites make any network anywhere available for authorized access. | <img src="public/screenshots/sites.png" width=500 /><tr></tr> | Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
| **Browser-based reverse proxy access**<br /><br />Expose web applications through identity and context-aware tunneled reverse proxies. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. Users access applications through any web browser with authentication and granular access control. | <img src="public/clip.gif" width=500 /><tr></tr> |
| **Client-based private resource access**<br /><br />Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. | <img src="public/screenshots/private-resources.png" width=500 /><tr></tr> | <img src="public/screenshots/sites.png" alt="Sites" width="100%" />
| **Zero-trust granular access**<br /><br />Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications and services you explicitly define, reducing security risk and attack surface. | <img src="public/screenshots/user-devices.png" width=500 /><tr></tr> |
### Browser-based reverse proxy access
Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
### Client-based private resource access
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
### Give users and roles access to resources
Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
## Download Clients ## Download Clients
@@ -87,7 +97,7 @@ Download the Pangolin client for your platform:
### Sign up now ### Sign up now
Create an account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud. A generous free tier is available. Create a free account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud.
### Check out the docs ### Check out the docs
@@ -102,7 +112,3 @@ Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License
## Contributions ## Contributions
Please see [CONTRIBUTING](./CONTRIBUTING.md) in the repository for guidelines and best practices. Please see [CONTRIBUTING](./CONTRIBUTING.md) in the repository for guidelines and best practices.
---
WireGuard® is a registered trademark of Jason A. Donenfeld.

View File

@@ -86,6 +86,8 @@ entryPoints:
http: http:
tls: tls:
certResolver: "letsencrypt" certResolver: "letsencrypt"
middlewares:
- crowdsec@file
encodedCharacters: encodedCharacters:
allowEncodedSlash: true allowEncodedSlash: true
allowEncodedQuestionMark: true allowEncodedQuestionMark: true

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "Ключът за осигуряване е актуализиран", "provisioningKeysUpdated": "Ключът за осигуряване е актуализиран",
"provisioningKeysUpdatedDescription": "Вашите промени бяха запазени.", "provisioningKeysUpdatedDescription": "Вашите промени бяха запазени.",
"provisioningKeysBannerTitle": "Ключове за осигуряване на сайта", "provisioningKeysBannerTitle": "Ключове за осигуряване на сайта",
"provisioningKeysBannerDescription": "Генерирайте ключ за осигуряване и го използвайте с Newt конектора за автоматично създаване на сайтове при първото стартиране няма нужда от създаване на отделни идентификационни данни за всеки сайт.", "provisioningKeysBannerDescription": "Генерирайте ключ за осигуряване и го използвайте със съединителя Newt за автоматично създаване на сайтове при първоначално стартиране - не е необходимо да се създават отделни идентификационни данни за всеки сайт.",
"provisioningKeysBannerButtonText": "Научете повече", "provisioningKeysBannerButtonText": "Научете повече",
"pendingSitesBannerTitle": "Чакащи сайтове", "pendingSitesBannerTitle": "Чакащи сайтове",
"pendingSitesBannerDescription": "Сайтовете, които се свързват чрез ключ за осигуряване, се появяват тук за преглед. Одобрете всеки сайт, преди да стане активен и да получи достъп до вашите ресурси.", "pendingSitesBannerDescription": "Сайтовете, които се свързват с ключ за осигуряване, ще се появят тук за преглед.",
"pendingSitesBannerButtonText": "Научете повече", "pendingSitesBannerButtonText": "Научете повече",
"apiKeysSettings": "Настройки на {apiKeyName}", "apiKeysSettings": "Настройки на {apiKeyName}",
"userTitle": "Управление на всички потребители", "userTitle": "Управление на всички потребители",
@@ -405,6 +405,10 @@
"licenseErrorKeyActivate": "Неуспешно активиране на лицензионния ключ", "licenseErrorKeyActivate": "Неуспешно активиране на лицензионния ключ",
"licenseErrorKeyActivateDescription": "Възникна грешка при активирането на лицензионния ключ.", "licenseErrorKeyActivateDescription": "Възникна грешка при активирането на лицензионния ключ.",
"licenseAbout": "Относно лицензите", "licenseAbout": "Относно лицензите",
"licenseBannerTitle": "Активирайте своята корпоративна лицензия",
"licenseBannerDescription": "Отключете корпоративните функции за вашият хостинг на Pangolin. Закупете лицензионен ключ, за да активирате премиум възможности, след това го добавете по-долу.",
"licenseBannerGetLicense": "Вземете лиценз",
"licenseBannerViewDocs": "Преглед на документацията",
"communityEdition": "Комюнити издание", "communityEdition": "Комюнити издание",
"licenseAboutDescription": "Това е за бизнес и корпоративни потребители, които използват Pangolin в търговска среда. Ако използвате Pangolin за лична употреба, можете да игнорирате този раздел.", "licenseAboutDescription": "Това е за бизнес и корпоративни потребители, които използват Pangolin в търговска среда. Ако използвате Pangolin за лична употреба, можете да игнорирате този раздел.",
"licenseKeyActivated": "Лицензионният ключ е активиран", "licenseKeyActivated": "Лицензионният ключ е активиран",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "Моля, въведете валиден номер на порт", "targetErrorInvalidPortDescription": "Моля, въведете валиден номер на порт",
"targetErrorNoSite": "Няма избран сайт", "targetErrorNoSite": "Няма избран сайт",
"targetErrorNoSiteDescription": "Моля, изберете сайт за целта", "targetErrorNoSiteDescription": "Моля, изберете сайт за целта",
"targetTargetsCleared": "Мишените са премахнати",
"targetTargetsClearedDescription": "Всички цели са били премахнати от този ресурс",
"targetCreated": "Целта е създадена", "targetCreated": "Целта е създадена",
"targetCreatedDescription": "Целта беше успешно създадена", "targetCreatedDescription": "Целта беше успешно създадена",
"targetErrorCreate": "Неуспешно създаване на целта", "targetErrorCreate": "Неуспешно създаване на целта",
@@ -2112,8 +2118,10 @@
"selectDomainForOrgAuthPage": "Изберете домейн за страницата за удостоверяване на организацията", "selectDomainForOrgAuthPage": "Изберете домейн за страницата за удостоверяване на организацията",
"domainPickerProvidedDomain": "Предоставен домейн", "domainPickerProvidedDomain": "Предоставен домейн",
"domainPickerFreeProvidedDomain": "Безплатен предоставен домейн", "domainPickerFreeProvidedDomain": "Безплатен предоставен домейн",
"domainPickerFreeDomainsPaidFeature": "Предоставените домейни са платена функция. Абонирайте се, за да получите домейн, включен във вашия план - няма нужда да използвате вашия собствен.",
"domainPickerVerified": "Проверено", "domainPickerVerified": "Проверено",
"domainPickerUnverified": "Непроверено", "domainPickerUnverified": "Непроверено",
"domainPickerManual": "Ръчно",
"domainPickerInvalidSubdomainStructure": "Този поддомен съдържа невалидни знаци или структура. Ще бъде автоматично пречистен при запазване.", "domainPickerInvalidSubdomainStructure": "Този поддомен съдържа невалидни знаци или структура. Ще бъде автоматично пречистен при запазване.",
"domainPickerError": "Грешка", "domainPickerError": "Грешка",
"domainPickerErrorLoadDomains": "Неуспешно зареждане на домейни на организацията", "domainPickerErrorLoadDomains": "Неуспешно зареждане на домейни на организацията",
@@ -2346,7 +2354,7 @@
"description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка." "description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка."
} }
}, },
"personalUseOnly": "Само за лична употреба (безплатен лиценз без плащане)", "personalUseOnly": "Само за лична употреба (безплатен лиценз - без проверка)",
"buttons": { "buttons": {
"continueToCheckout": "Продължете към плащане" "continueToCheckout": "Продължете към плащане"
}, },
@@ -2607,6 +2615,9 @@
"machineClients": "Машинни клиенти", "machineClients": "Машинни клиенти",
"install": "Инсталирай", "install": "Инсталирай",
"run": "Изпълни", "run": "Изпълни",
"envFile": "Файл за среда",
"serviceFile": "Файл за услуга",
"enableAndStart": "Активиране и стартиране",
"clientNameDescription": "Показваното име на клиента, което може да се промени по-късно.", "clientNameDescription": "Показваното име на клиента, което може да се промени по-късно.",
"clientAddress": "Клиентски адрес (Разширено)", "clientAddress": "Клиентски адрес (Разширено)",
"setupFailedToFetchSubnet": "Неуспешно извличане на подмрежа по подразбиране", "setupFailedToFetchSubnet": "Неуспешно извличане на подмрежа по подразбиране",
@@ -2845,10 +2856,10 @@
"httpDestAuthNoneTitle": "Без удостоверяване", "httpDestAuthNoneTitle": "Без удостоверяване",
"httpDestAuthNoneDescription": "Изпращане на заявки без заглавие за удостоверяване.", "httpDestAuthNoneDescription": "Изпращане на заявки без заглавие за удостоверяване.",
"httpDestAuthBearerTitle": "Bearer Токен", "httpDestAuthBearerTitle": "Bearer Токен",
"httpDestAuthBearerDescription": "Добавя заглавие за удостоверяване Bearer <token> към всяка заявка.", "httpDestAuthBearerDescription": "Добавя заглавие Authorization: Bearer '<token>' към всяка заявка.",
"httpDestAuthBearerPlaceholder": "Вашият API ключ или токен", "httpDestAuthBearerPlaceholder": "Вашият API ключ или токен",
"httpDestAuthBasicTitle": "Основно удостоверяване", "httpDestAuthBasicTitle": "Основно удостоверяване",
"httpDestAuthBasicDescription": "Добавя заглавие за удостоверяване Basic <credentials> към всяка заявка. Осигурете идентификационни данни като потребителско име:парола.", "httpDestAuthBasicDescription": "Добавя заглавие Authorization: Basic '<credentials>'. Осигурете идентификационни данни като потребителско име:парола.",
"httpDestAuthBasicPlaceholder": "потребителско име:парола", "httpDestAuthBasicPlaceholder": "потребителско име:парола",
"httpDestAuthCustomTitle": "Персонализирано заглавие", "httpDestAuthCustomTitle": "Персонализирано заглавие",
"httpDestAuthCustomDescription": "Посочете персонализирано име и стойност на заглавието за удостоверяване (например X-API-Key).", "httpDestAuthCustomDescription": "Посочете персонализирано име и стойност на заглавието за удостоверяване (например X-API-Key).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "Zajišťovací klíč byl aktualizován", "provisioningKeysUpdated": "Zajišťovací klíč byl aktualizován",
"provisioningKeysUpdatedDescription": "Vaše změny byly uloženy.", "provisioningKeysUpdatedDescription": "Vaše změny byly uloženy.",
"provisioningKeysBannerTitle": "Klíče pro poskytování webu", "provisioningKeysBannerTitle": "Klíče pro poskytování webu",
"provisioningKeysBannerDescription": "Vygenerujte konfigurační klíč a používejte jej pomocí nového konektoru k automatickému vytváření stránek při prvním startu není třeba nastavovat samostatné přihlašovací údaje pro každý web.", "provisioningKeysBannerDescription": "Vygenerujte klíč pro zřízení a použijte ho s Newt konektorem k automatickému vytvoření stránek při prvním spuštění není potřeba nastavit samostatné přihlašovací údaje pro každou stránku.",
"provisioningKeysBannerButtonText": "Zjistit více", "provisioningKeysBannerButtonText": "Zjistit více",
"pendingSitesBannerTitle": "Nevyřízené weby", "pendingSitesBannerTitle": "Nevyřízené weby",
"pendingSitesBannerDescription": "Zde se zobrazují stránky, které se připojují pomocí doplňovacího klíče. Schválte každý web předtím, než bude aktivní, a získejte přístup k vašim zdrojům.", "pendingSitesBannerDescription": "Stránky, které se připojují pomocí klíče pro zřízení, se zde objeví ke kontrole.",
"pendingSitesBannerButtonText": "Zjistit více", "pendingSitesBannerButtonText": "Zjistit více",
"apiKeysSettings": "Nastavení {apiKeyName}", "apiKeysSettings": "Nastavení {apiKeyName}",
"userTitle": "Spravovat všechny uživatele", "userTitle": "Spravovat všechny uživatele",
@@ -405,6 +405,10 @@
"licenseErrorKeyActivate": "Nepodařilo se aktivovat licenční klíč", "licenseErrorKeyActivate": "Nepodařilo se aktivovat licenční klíč",
"licenseErrorKeyActivateDescription": "Došlo k chybě při aktivaci licenčního klíče.", "licenseErrorKeyActivateDescription": "Došlo k chybě při aktivaci licenčního klíče.",
"licenseAbout": "O licencích", "licenseAbout": "O licencích",
"licenseBannerTitle": "Aktivovat vaši firemní licenci",
"licenseBannerDescription": "Odemkněte firemní funkce pro vaši samohostovanou instanci Pangolin. Zakupte si licenční klíč pro aktivaci prémiových možností a poté jej přidejte níže.",
"licenseBannerGetLicense": "Zakoupit licenci",
"licenseBannerViewDocs": "Zobrazit dokumentaci",
"communityEdition": "Komunitní edice", "communityEdition": "Komunitní edice",
"licenseAboutDescription": "To je pro obchodní a podnikové uživatele, kteří používají Pangolin v komerčním prostředí. Pokud používáte Pangolin pro osobní použití, můžete tuto sekci ignorovat.", "licenseAboutDescription": "To je pro obchodní a podnikové uživatele, kteří používají Pangolin v komerčním prostředí. Pokud používáte Pangolin pro osobní použití, můžete tuto sekci ignorovat.",
"licenseKeyActivated": "Licenční klíč aktivován", "licenseKeyActivated": "Licenční klíč aktivován",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "Zadejte platné číslo portu", "targetErrorInvalidPortDescription": "Zadejte platné číslo portu",
"targetErrorNoSite": "Není vybrán žádný web", "targetErrorNoSite": "Není vybrán žádný web",
"targetErrorNoSiteDescription": "Vyberte prosím web pro cíl", "targetErrorNoSiteDescription": "Vyberte prosím web pro cíl",
"targetTargetsCleared": "Cíle vymazány",
"targetTargetsClearedDescription": "Všechny cíle byly odstraněny z tohoto zdroje",
"targetCreated": "Cíl byl vytvořen", "targetCreated": "Cíl byl vytvořen",
"targetCreatedDescription": "Cíl byl úspěšně vytvořen", "targetCreatedDescription": "Cíl byl úspěšně vytvořen",
"targetErrorCreate": "Nepodařilo se vytvořit cíl", "targetErrorCreate": "Nepodařilo se vytvořit cíl",
@@ -2112,8 +2118,10 @@
"selectDomainForOrgAuthPage": "Vyberte doménu pro ověřovací stránku organizace", "selectDomainForOrgAuthPage": "Vyberte doménu pro ověřovací stránku organizace",
"domainPickerProvidedDomain": "Poskytnutá doména", "domainPickerProvidedDomain": "Poskytnutá doména",
"domainPickerFreeProvidedDomain": "Zdarma poskytnutá doména", "domainPickerFreeProvidedDomain": "Zdarma poskytnutá doména",
"domainPickerFreeDomainsPaidFeature": "Poskytnuté domény jsou placenou funkcí. Předplaťte si plán, abyste získali doménu zahrnutou v plánu nemusíte si přinést vlastní.",
"domainPickerVerified": "Ověřeno", "domainPickerVerified": "Ověřeno",
"domainPickerUnverified": "Neověřeno", "domainPickerUnverified": "Neověřeno",
"domainPickerManual": "Ruční nastavení",
"domainPickerInvalidSubdomainStructure": "Tato subdoména obsahuje neplatné znaky nebo strukturu. Bude automaticky sanitována při uložení.", "domainPickerInvalidSubdomainStructure": "Tato subdoména obsahuje neplatné znaky nebo strukturu. Bude automaticky sanitována při uložení.",
"domainPickerError": "Chyba", "domainPickerError": "Chyba",
"domainPickerErrorLoadDomains": "Nepodařilo se načíst domény organizace", "domainPickerErrorLoadDomains": "Nepodařilo se načíst domény organizace",
@@ -2346,7 +2354,7 @@
"description": "Podnikové funkce, 50 uživatelů, 50 míst a prioritní podpory." "description": "Podnikové funkce, 50 uživatelů, 50 míst a prioritní podpory."
} }
}, },
"personalUseOnly": "Pouze osobní použití (bezplatná licence bez platby)", "personalUseOnly": "Pouze pro osobní použití (zdarma licence - bez ověření)",
"buttons": { "buttons": {
"continueToCheckout": "Pokračovat do pokladny" "continueToCheckout": "Pokračovat do pokladny"
}, },
@@ -2607,6 +2615,9 @@
"machineClients": "Strojoví klienti", "machineClients": "Strojoví klienti",
"install": "Instalovat", "install": "Instalovat",
"run": "Spustit", "run": "Spustit",
"envFile": "Konfigurační soubor prostředí",
"serviceFile": "Služební soubor",
"enableAndStart": "Povolit a spustit",
"clientNameDescription": "Zobrazované jméno klienta, které lze později změnit.", "clientNameDescription": "Zobrazované jméno klienta, které lze později změnit.",
"clientAddress": "Adresa klienta (Rozšířeno)", "clientAddress": "Adresa klienta (Rozšířeno)",
"setupFailedToFetchSubnet": "Nepodařilo se načíst výchozí podsíť", "setupFailedToFetchSubnet": "Nepodařilo se načíst výchozí podsíť",
@@ -2845,10 +2856,10 @@
"httpDestAuthNoneTitle": "Žádné ověření", "httpDestAuthNoneTitle": "Žádné ověření",
"httpDestAuthNoneDescription": "Odešle žádosti bez záhlaví autorizace.", "httpDestAuthNoneDescription": "Odešle žádosti bez záhlaví autorizace.",
"httpDestAuthBearerTitle": "Token na doručitele", "httpDestAuthBearerTitle": "Token na doručitele",
"httpDestAuthBearerDescription": "Přidá autorizaci: Hlavička Bearer <token> ke každému požadavku.", "httpDestAuthBearerDescription": "Přidává hlavičku Authorization: Bearer '<token>' k každému požadavku.",
"httpDestAuthBearerPlaceholder": "Váš API klíč nebo token", "httpDestAuthBearerPlaceholder": "Váš API klíč nebo token",
"httpDestAuthBasicTitle": "Základní ověření", "httpDestAuthBasicTitle": "Základní ověření",
"httpDestAuthBasicDescription": "Přidá autorizaci: Základní <credentials> hlavička. Poskytněte přihlašovací údaje jako uživatelské jméno:password.", "httpDestAuthBasicDescription": "Přidává hlavičku Authorization: Basic '<credentials>'. Poskytněte přihlašovací údaje ve formátu uživatelské jméno:heslo.",
"httpDestAuthBasicPlaceholder": "uživatelské jméno:heslo", "httpDestAuthBasicPlaceholder": "uživatelské jméno:heslo",
"httpDestAuthCustomTitle": "Vlastní záhlaví", "httpDestAuthCustomTitle": "Vlastní záhlaví",
"httpDestAuthCustomDescription": "Zadejte název a hodnotu vlastního HTTP hlavičky pro ověření (např. X-API-Key).", "httpDestAuthCustomDescription": "Zadejte název a hodnotu vlastního HTTP hlavičky pro ověření (např. X-API-Key).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "Bereitstellungsschlüssel aktualisiert", "provisioningKeysUpdated": "Bereitstellungsschlüssel aktualisiert",
"provisioningKeysUpdatedDescription": "Ihre Änderungen wurden gespeichert.", "provisioningKeysUpdatedDescription": "Ihre Änderungen wurden gespeichert.",
"provisioningKeysBannerTitle": "Website-Bereitstellungsschlüssel", "provisioningKeysBannerTitle": "Website-Bereitstellungsschlüssel",
"provisioningKeysBannerDescription": "Generieren Sie einen Bereitstellungsschlüssel und verwenden Sie ihn mit dem Newt-Konnektor, um beim ersten Start automatisch Sites zu erstellen keine Notwendigkeit, separate Anmeldeinformationen für jede Seite einzurichten.", "provisioningKeysBannerDescription": "Generieren Sie einen Bereitstellungsschlüssel und verwenden Sie ihn mit dem Newt-Connector, um Standorte beim ersten Start automatisch zu erstellen - keine Notwendigkeit, separate Anmeldedaten für jede Seite einzurichten.",
"provisioningKeysBannerButtonText": "Mehr erfahren", "provisioningKeysBannerButtonText": "Mehr erfahren",
"pendingSitesBannerTitle": "Ausstehende Seiten", "pendingSitesBannerTitle": "Ausstehende Seiten",
"pendingSitesBannerDescription": "Sites, die sich mit einem Bereitstellungsschlüssel verbinden, erscheinen hier zur Überprüfung. Bestätigen Sie jede Site, bevor sie aktiv wird und erhalten Zugriff auf Ihre Ressourcen.", "pendingSitesBannerDescription": "Websites, die mit einem Bereitstellungsschlüssel verbunden sind, erscheinen hier zur Überprüfung.",
"pendingSitesBannerButtonText": "Mehr erfahren", "pendingSitesBannerButtonText": "Mehr erfahren",
"apiKeysSettings": "{apiKeyName} Einstellungen", "apiKeysSettings": "{apiKeyName} Einstellungen",
"userTitle": "Alle Benutzer verwalten", "userTitle": "Alle Benutzer verwalten",
@@ -405,6 +405,10 @@
"licenseErrorKeyActivate": "Fehler beim Aktivieren des Lizenzschlüssels", "licenseErrorKeyActivate": "Fehler beim Aktivieren des Lizenzschlüssels",
"licenseErrorKeyActivateDescription": "Beim Aktivieren des Lizenzschlüssels ist ein Fehler aufgetreten.", "licenseErrorKeyActivateDescription": "Beim Aktivieren des Lizenzschlüssels ist ein Fehler aufgetreten.",
"licenseAbout": "Über Lizenzierung", "licenseAbout": "Über Lizenzierung",
"licenseBannerTitle": "Aktivieren Sie Ihre Enterprise-Lizenz",
"licenseBannerDescription": "Schalten Sie Unternehmensfunktionen für Ihre selbstgehostete Pangolin-Instanz frei. Kaufen Sie einen Lizenzschlüssel, um Premium-Funktionen zu aktivieren, und fügen Sie ihn dann unten hinzu.",
"licenseBannerGetLicense": "Lizenz erhalten",
"licenseBannerViewDocs": "Dokumentation anzeigen",
"communityEdition": "Community-Edition", "communityEdition": "Community-Edition",
"licenseAboutDescription": "Dies ist für Geschäfts- und Unternehmensanwender, die Pangolin in einem kommerziellen Umfeld einsetzen. Wenn Sie Pangolin für den persönlichen Gebrauch verwenden, können Sie diesen Abschnitt ignorieren.", "licenseAboutDescription": "Dies ist für Geschäfts- und Unternehmensanwender, die Pangolin in einem kommerziellen Umfeld einsetzen. Wenn Sie Pangolin für den persönlichen Gebrauch verwenden, können Sie diesen Abschnitt ignorieren.",
"licenseKeyActivated": "Lizenzschlüssel aktiviert", "licenseKeyActivated": "Lizenzschlüssel aktiviert",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "Bitte geben Sie eine gültige Portnummer ein", "targetErrorInvalidPortDescription": "Bitte geben Sie eine gültige Portnummer ein",
"targetErrorNoSite": "Kein Standort ausgewählt", "targetErrorNoSite": "Kein Standort ausgewählt",
"targetErrorNoSiteDescription": "Bitte wähle einen Standort für das Ziel aus", "targetErrorNoSiteDescription": "Bitte wähle einen Standort für das Ziel aus",
"targetTargetsCleared": "Ziele gelöscht",
"targetTargetsClearedDescription": "Alle Ziele wurden aus dieser Ressource entfernt",
"targetCreated": "Ziel erstellt", "targetCreated": "Ziel erstellt",
"targetCreatedDescription": "Ziel wurde erfolgreich erstellt", "targetCreatedDescription": "Ziel wurde erfolgreich erstellt",
"targetErrorCreate": "Fehler beim Erstellen des Ziels", "targetErrorCreate": "Fehler beim Erstellen des Ziels",
@@ -2112,8 +2118,10 @@
"selectDomainForOrgAuthPage": "Wählen Sie eine Domain für die Authentifizierungsseite der Organisation", "selectDomainForOrgAuthPage": "Wählen Sie eine Domain für die Authentifizierungsseite der Organisation",
"domainPickerProvidedDomain": "Angegebene Domain", "domainPickerProvidedDomain": "Angegebene Domain",
"domainPickerFreeProvidedDomain": "Kostenlose Domain", "domainPickerFreeProvidedDomain": "Kostenlose Domain",
"domainPickerFreeDomainsPaidFeature": "Bereitgestellte Domains sind ein kostenpflichtiges Feature. Abonnieren Sie, um eine Domain in Ihrem Tarif zu erhalten keine Notwendigkeit, Ihre eigene mitzubringen.",
"domainPickerVerified": "Verifiziert", "domainPickerVerified": "Verifiziert",
"domainPickerUnverified": "Nicht verifiziert", "domainPickerUnverified": "Nicht verifiziert",
"domainPickerManual": "Manuell",
"domainPickerInvalidSubdomainStructure": "Diese Subdomain enthält ungültige Zeichen oder Struktur. Sie wird beim Speichern automatisch bereinigt.", "domainPickerInvalidSubdomainStructure": "Diese Subdomain enthält ungültige Zeichen oder Struktur. Sie wird beim Speichern automatisch bereinigt.",
"domainPickerError": "Fehler", "domainPickerError": "Fehler",
"domainPickerErrorLoadDomains": "Fehler beim Laden der Organisations-Domains", "domainPickerErrorLoadDomains": "Fehler beim Laden der Organisations-Domains",
@@ -2346,7 +2354,7 @@
"description": "Enterprise Features, 50 Benutzer, 50 Sites und Prioritätsunterstützung." "description": "Enterprise Features, 50 Benutzer, 50 Sites und Prioritätsunterstützung."
} }
}, },
"personalUseOnly": "Nur persönliche Nutzung (kostenlose Lizenz keine Kasse)", "personalUseOnly": "Nur persönliche Nutzung (kostenlose Lizenz - kein Checkout)",
"buttons": { "buttons": {
"continueToCheckout": "Weiter zur Kasse" "continueToCheckout": "Weiter zur Kasse"
}, },
@@ -2607,6 +2615,9 @@
"machineClients": "Maschinen-Clients", "machineClients": "Maschinen-Clients",
"install": "Installieren", "install": "Installieren",
"run": "Ausführen", "run": "Ausführen",
"envFile": "Umgebungsdatei",
"serviceFile": "Servicedatei",
"enableAndStart": "Aktivieren und Starten",
"clientNameDescription": "Der Anzeigename des Clients, der später geändert werden kann.", "clientNameDescription": "Der Anzeigename des Clients, der später geändert werden kann.",
"clientAddress": "Clientadresse (Erweitert)", "clientAddress": "Clientadresse (Erweitert)",
"setupFailedToFetchSubnet": "Fehler beim Abrufen des Standard-Subnetzes", "setupFailedToFetchSubnet": "Fehler beim Abrufen des Standard-Subnetzes",
@@ -2845,10 +2856,10 @@
"httpDestAuthNoneTitle": "Keine Authentifizierung", "httpDestAuthNoneTitle": "Keine Authentifizierung",
"httpDestAuthNoneDescription": "Sendet Anfragen ohne Autorisierungs-Header.", "httpDestAuthNoneDescription": "Sendet Anfragen ohne Autorisierungs-Header.",
"httpDestAuthBearerTitle": "Bären-Token", "httpDestAuthBearerTitle": "Bären-Token",
"httpDestAuthBearerDescription": "Fügt eine Berechtigung hinzu: Bearer <token> Header zu jeder Anfrage.", "httpDestAuthBearerDescription": "Fügt jedem Anfrage-Header eine \"Authorization: Bearer '<token>'\" hinzu.",
"httpDestAuthBearerPlaceholder": "Ihr API-Schlüssel oder Token", "httpDestAuthBearerPlaceholder": "Ihr API-Schlüssel oder Token",
"httpDestAuthBasicTitle": "Einfacher Auth", "httpDestAuthBasicTitle": "Einfacher Auth",
"httpDestAuthBasicDescription": "Fügt eine Autorisierung hinzu: Basic <credentials> Kopfzeile hinzu. Geben Sie Anmeldedaten als Benutzername:password an.", "httpDestAuthBasicDescription": "Fügt einen \"Authorization: Basic '<credentials>'\"-Header hinzu. Geben Sie die Anmeldedaten als Benutzername:Passwort an.",
"httpDestAuthBasicPlaceholder": "benutzername:password", "httpDestAuthBasicPlaceholder": "benutzername:password",
"httpDestAuthCustomTitle": "Eigene Kopfzeile", "httpDestAuthCustomTitle": "Eigene Kopfzeile",
"httpDestAuthCustomDescription": "Geben Sie einen eigenen HTTP-Header-Namen und einen Wert für die Authentifizierung an (z.B. X-API-Key).", "httpDestAuthCustomDescription": "Geben Sie einen eigenen HTTP-Header-Namen und einen Wert für die Authentifizierung an (z.B. X-API-Key).",

View File

@@ -405,6 +405,10 @@
"licenseErrorKeyActivate": "Failed to activate license key", "licenseErrorKeyActivate": "Failed to activate license key",
"licenseErrorKeyActivateDescription": "An error occurred while activating the license key.", "licenseErrorKeyActivateDescription": "An error occurred while activating the license key.",
"licenseAbout": "About Licensing", "licenseAbout": "About Licensing",
"licenseBannerTitle": "Enable Your Enterprise License",
"licenseBannerDescription": "Unlock enterprise features for your self-hosted Pangolin instance. Purchase a license key to activate premium capabilities, then add it below.",
"licenseBannerGetLicense": "Get a License",
"licenseBannerViewDocs": "View Documentation",
"communityEdition": "Community Edition", "communityEdition": "Community Edition",
"licenseAboutDescription": "This is for business and enterprise users who are using Pangolin in a commercial environment. If you are using Pangolin for personal use, you can ignore this section.", "licenseAboutDescription": "This is for business and enterprise users who are using Pangolin in a commercial environment. If you are using Pangolin for personal use, you can ignore this section.",
"licenseKeyActivated": "License key activated", "licenseKeyActivated": "License key activated",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "Please enter a valid port number", "targetErrorInvalidPortDescription": "Please enter a valid port number",
"targetErrorNoSite": "No site selected", "targetErrorNoSite": "No site selected",
"targetErrorNoSiteDescription": "Please select a site for the target", "targetErrorNoSiteDescription": "Please select a site for the target",
"targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "All targets have been removed from this resource",
"targetCreated": "Target created", "targetCreated": "Target created",
"targetCreatedDescription": "Target has been created successfully", "targetCreatedDescription": "Target has been created successfully",
"targetErrorCreate": "Failed to create target", "targetErrorCreate": "Failed to create target",
@@ -1815,6 +1821,11 @@
"editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModePort": "Port",
"editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeHost": "Host",
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Scheme",
"editInternalResourceDialogEnableSsl": "Enable SSL",
"editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
"editInternalResourceDialogDestination": "Destination", "editInternalResourceDialogDestination": "Destination",
"editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
"editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.", "editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
@@ -1830,6 +1841,7 @@
"createInternalResourceDialogName": "Name", "createInternalResourceDialogName": "Name",
"createInternalResourceDialogSite": "Site", "createInternalResourceDialogSite": "Site",
"selectSite": "Select site...", "selectSite": "Select site...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
"noSitesFound": "No sites found.", "noSitesFound": "No sites found.",
"createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogProtocol": "Protocol",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -1858,11 +1870,19 @@
"createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModePort": "Port",
"createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeHost": "Host",
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Scheme",
"createInternalResourceDialogScheme": "Scheme",
"createInternalResourceDialogEnableSsl": "Enable SSL",
"createInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
"createInternalResourceDialogDestination": "Destination", "createInternalResourceDialogDestination": "Destination",
"createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
"createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", "createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", "createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
"internalResourceDownstreamSchemeRequired": "Scheme is required for HTTP resources",
"internalResourceHttpPortRequired": "Destination port is required for HTTP resources",
"siteConfiguration": "Configuration", "siteConfiguration": "Configuration",
"siteAcceptClientConnections": "Accept Client Connections", "siteAcceptClientConnections": "Accept Client Connections",
"siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.", "siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.",
@@ -2111,9 +2131,11 @@
"addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.", "addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.",
"selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page", "selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page",
"domainPickerProvidedDomain": "Provided Domain", "domainPickerProvidedDomain": "Provided Domain",
"domainPickerFreeProvidedDomain": "Free Provided Domain", "domainPickerFreeProvidedDomain": "Provided Domain",
"domainPickerFreeDomainsPaidFeature": "Provided domains are a paid feature. Subscribe to get a domain included with your plan — no need to bring your own.",
"domainPickerVerified": "Verified", "domainPickerVerified": "Verified",
"domainPickerUnverified": "Unverified", "domainPickerUnverified": "Unverified",
"domainPickerManual": "Manual",
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.", "domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
"domainPickerError": "Error", "domainPickerError": "Error",
"domainPickerErrorLoadDomains": "Failed to load organization domains", "domainPickerErrorLoadDomains": "Failed to load organization domains",
@@ -2420,6 +2442,7 @@
"validPassword": "Valid Password", "validPassword": "Valid Password",
"validEmail": "Valid email", "validEmail": "Valid email",
"validSSO": "Valid SSO", "validSSO": "Valid SSO",
"connectedClient": "Connected Client",
"resourceBlocked": "Resource Blocked", "resourceBlocked": "Resource Blocked",
"droppedByRule": "Dropped by Rule", "droppedByRule": "Dropped by Rule",
"noSessions": "No Sessions", "noSessions": "No Sessions",
@@ -2607,6 +2630,9 @@
"machineClients": "Machine Clients", "machineClients": "Machine Clients",
"install": "Install", "install": "Install",
"run": "Run", "run": "Run",
"envFile": "Environment File",
"serviceFile": "Service File",
"enableAndStart": "Enable and Start",
"clientNameDescription": "The display name of the client that can be changed later.", "clientNameDescription": "The display name of the client that can be changed later.",
"clientAddress": "Client Address (Advanced)", "clientAddress": "Client Address (Advanced)",
"setupFailedToFetchSubnet": "Failed to fetch default subnet", "setupFailedToFetchSubnet": "Failed to fetch default subnet",
@@ -2654,8 +2680,12 @@
"editInternalResourceDialogAddUsers": "Add Users", "editInternalResourceDialogAddUsers": "Add Users",
"editInternalResourceDialogAddClients": "Add Clients", "editInternalResourceDialogAddClients": "Add Clients",
"editInternalResourceDialogDestinationLabel": "Destination", "editInternalResourceDialogDestinationLabel": "Destination",
"editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.", "editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it. Selecting multiple sites will create a high availability resource that can be accessed from any of the selected sites.",
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
"createInternalResourceDialogHttpConfiguration": "HTTP configuration",
"createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.",
"editInternalResourceDialogHttpConfiguration": "HTTP configuration",
"editInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.",
"editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogTcp": "TCP",
"editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogUdp": "UDP",
"editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogIcmp": "ICMP",
@@ -2694,6 +2724,8 @@
"maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.", "maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.",
"maintenancePageMessageDescription": "Detailed message explaining the maintenance", "maintenancePageMessageDescription": "Detailed message explaining the maintenance",
"maintenancePageTimeTitle": "Estimated Completion Time (Optional)", "maintenancePageTimeTitle": "Estimated Completion Time (Optional)",
"privateMaintenanceScreenTitle": "Private Placeholder Screen",
"privateMaintenanceScreenMessage": "This domain is being used on a private resource. Please connect using the Pangolin client to access this resource.",
"maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM", "maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM",
"maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed", "maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed",
"editDomain": "Edit Domain", "editDomain": "Edit Domain",
@@ -2845,10 +2877,10 @@
"httpDestAuthNoneTitle": "No Authentication", "httpDestAuthNoneTitle": "No Authentication",
"httpDestAuthNoneDescription": "Sends requests without an Authorization header.", "httpDestAuthNoneDescription": "Sends requests without an Authorization header.",
"httpDestAuthBearerTitle": "Bearer Token", "httpDestAuthBearerTitle": "Bearer Token",
"httpDestAuthBearerDescription": "Adds an Authorization: Bearer <token> header to each request.", "httpDestAuthBearerDescription": "Adds an Authorization: Bearer '<token>' header to each request.",
"httpDestAuthBearerPlaceholder": "Your API key or token", "httpDestAuthBearerPlaceholder": "Your API key or token",
"httpDestAuthBasicTitle": "Basic Auth", "httpDestAuthBasicTitle": "Basic Auth",
"httpDestAuthBasicDescription": "Adds an Authorization: Basic <credentials> header. Provide credentials as username:password.", "httpDestAuthBasicDescription": "Adds an Authorization: Basic '<credentials>' header. Provide credentials as username:password.",
"httpDestAuthBasicPlaceholder": "username:password", "httpDestAuthBasicPlaceholder": "username:password",
"httpDestAuthCustomTitle": "Custom Header", "httpDestAuthCustomTitle": "Custom Header",
"httpDestAuthCustomDescription": "Specify a custom HTTP header name and value for authentication (e.g. X-API-Key).", "httpDestAuthCustomDescription": "Specify a custom HTTP header name and value for authentication (e.g. X-API-Key).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "Clave de aprovisionamiento actualizada", "provisioningKeysUpdated": "Clave de aprovisionamiento actualizada",
"provisioningKeysUpdatedDescription": "Sus cambios han sido guardados.", "provisioningKeysUpdatedDescription": "Sus cambios han sido guardados.",
"provisioningKeysBannerTitle": "Claves de aprovisionamiento del sitio", "provisioningKeysBannerTitle": "Claves de aprovisionamiento del sitio",
"provisioningKeysBannerDescription": "Generar una clave de aprovisionamiento y usarla con el conector Newt para crear automáticamente sitios en el primer inicio no es necesario configurar credenciales separadas para cada sitio.", "provisioningKeysBannerDescription": "Genere una clave de aprovisionamiento y utilícela con el conector Newt para crear automáticamente sitios en el primer inicio: no es necesario configurar credenciales separadas para cada sitio.",
"provisioningKeysBannerButtonText": "Saber más", "provisioningKeysBannerButtonText": "Saber más",
"pendingSitesBannerTitle": "Sitios pendientes", "pendingSitesBannerTitle": "Sitios pendientes",
"pendingSitesBannerDescription": "Los sitios que se conectan usando una clave de aprovisionamiento aparecen aquí para su revisión. Aprobar cada sitio antes de que se active y obtenga acceso a sus recursos.", "pendingSitesBannerDescription": "Los sitios que se conectan utilizando una clave de aprovisionamiento aparecen aquí para su revisión.",
"pendingSitesBannerButtonText": "Saber más", "pendingSitesBannerButtonText": "Saber más",
"apiKeysSettings": "Ajustes {apiKeyName}", "apiKeysSettings": "Ajustes {apiKeyName}",
"userTitle": "Administrar todos los usuarios", "userTitle": "Administrar todos los usuarios",
@@ -405,6 +405,10 @@
"licenseErrorKeyActivate": "Error al activar la clave de licencia", "licenseErrorKeyActivate": "Error al activar la clave de licencia",
"licenseErrorKeyActivateDescription": "Se ha producido un error al activar la clave de licencia.", "licenseErrorKeyActivateDescription": "Se ha producido un error al activar la clave de licencia.",
"licenseAbout": "Acerca de la licencia", "licenseAbout": "Acerca de la licencia",
"licenseBannerTitle": "Habilitar su Licencia Enterprise",
"licenseBannerDescription": "Desbloquea funciones empresariales para tu instancia autohospedada de Pangolin. Compra una clave de licencia para activar capacidades premium, luego agréguela a continuación.",
"licenseBannerGetLicense": "Obtener una Licencia",
"licenseBannerViewDocs": "Ver Documentación",
"communityEdition": "Edición comunitaria", "communityEdition": "Edición comunitaria",
"licenseAboutDescription": "Esto es para usuarios empresariales y empresariales que utilizan Pangolin en un entorno comercial. Si estás usando Pangolin para uso personal, puedes ignorar esta sección.", "licenseAboutDescription": "Esto es para usuarios empresariales y empresariales que utilizan Pangolin en un entorno comercial. Si estás usando Pangolin para uso personal, puedes ignorar esta sección.",
"licenseKeyActivated": "Clave de licencia activada", "licenseKeyActivated": "Clave de licencia activada",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "Por favor, introduzca un número de puerto válido", "targetErrorInvalidPortDescription": "Por favor, introduzca un número de puerto válido",
"targetErrorNoSite": "Ningún sitio seleccionado", "targetErrorNoSite": "Ningún sitio seleccionado",
"targetErrorNoSiteDescription": "Por favor, seleccione un sitio para el objetivo", "targetErrorNoSiteDescription": "Por favor, seleccione un sitio para el objetivo",
"targetTargetsCleared": "Objetivos eliminados",
"targetTargetsClearedDescription": "Todos los objetivos han sido eliminados de este recurso",
"targetCreated": "Objetivo creado", "targetCreated": "Objetivo creado",
"targetCreatedDescription": "El objetivo se ha creado correctamente", "targetCreatedDescription": "El objetivo se ha creado correctamente",
"targetErrorCreate": "Error al crear el objetivo", "targetErrorCreate": "Error al crear el objetivo",
@@ -2112,8 +2118,10 @@
"selectDomainForOrgAuthPage": "Seleccione un dominio para la página de autenticación de la organización", "selectDomainForOrgAuthPage": "Seleccione un dominio para la página de autenticación de la organización",
"domainPickerProvidedDomain": "Dominio proporcionado", "domainPickerProvidedDomain": "Dominio proporcionado",
"domainPickerFreeProvidedDomain": "Dominio proporcionado gratis", "domainPickerFreeProvidedDomain": "Dominio proporcionado gratis",
"domainPickerFreeDomainsPaidFeature": "Los dominios proporcionados son una función de pago. Suscríbete para obtener un dominio incluido con tu plan — no necesitas traer el tuyo propio.",
"domainPickerVerified": "Verificado", "domainPickerVerified": "Verificado",
"domainPickerUnverified": "Sin verificar", "domainPickerUnverified": "Sin verificar",
"domainPickerManual": "Manual",
"domainPickerInvalidSubdomainStructure": "Este subdominio contiene caracteres o estructura no válidos. Se limpiará automáticamente al guardar.", "domainPickerInvalidSubdomainStructure": "Este subdominio contiene caracteres o estructura no válidos. Se limpiará automáticamente al guardar.",
"domainPickerError": "Error", "domainPickerError": "Error",
"domainPickerErrorLoadDomains": "Error al cargar los dominios de la organización", "domainPickerErrorLoadDomains": "Error al cargar los dominios de la organización",
@@ -2346,7 +2354,7 @@
"description": "Características de la empresa, 50 usuarios, 50 sitios y soporte prioritario." "description": "Características de la empresa, 50 usuarios, 50 sitios y soporte prioritario."
} }
}, },
"personalUseOnly": "Solo uso personal (licencia gratuita, sin pago)", "personalUseOnly": "Solo uso personal (licencia gratuita - sin salida)",
"buttons": { "buttons": {
"continueToCheckout": "Continuar con el pago" "continueToCheckout": "Continuar con el pago"
}, },
@@ -2607,6 +2615,9 @@
"machineClients": "Clientes de la máquina", "machineClients": "Clientes de la máquina",
"install": "Instalar", "install": "Instalar",
"run": "Ejecutar", "run": "Ejecutar",
"envFile": "Archivo de Entorno",
"serviceFile": "Archivo de Servicio",
"enableAndStart": "Habilitar y empezar",
"clientNameDescription": "El nombre mostrado del cliente que se puede cambiar más adelante.", "clientNameDescription": "El nombre mostrado del cliente que se puede cambiar más adelante.",
"clientAddress": "Dirección del cliente (Avanzado)", "clientAddress": "Dirección del cliente (Avanzado)",
"setupFailedToFetchSubnet": "No se pudo obtener la subred por defecto", "setupFailedToFetchSubnet": "No se pudo obtener la subred por defecto",
@@ -2845,10 +2856,10 @@
"httpDestAuthNoneTitle": "Sin autenticación", "httpDestAuthNoneTitle": "Sin autenticación",
"httpDestAuthNoneDescription": "Envía solicitudes sin un encabezado de autorización.", "httpDestAuthNoneDescription": "Envía solicitudes sin un encabezado de autorización.",
"httpDestAuthBearerTitle": "Tóken de portador", "httpDestAuthBearerTitle": "Tóken de portador",
"httpDestAuthBearerDescription": "Añade una autorización: portador <token> encabezado a cada solicitud.", "httpDestAuthBearerDescription": "Añade un encabezado Authorization: Bearer '<token>' a cada solicitud.",
"httpDestAuthBearerPlaceholder": "Tu clave o token API", "httpDestAuthBearerPlaceholder": "Tu clave o token API",
"httpDestAuthBasicTitle": "Auth Básica", "httpDestAuthBasicTitle": "Auth Básica",
"httpDestAuthBasicDescription": "Añade una Autorización: encabezado básico <credentials> . Proporcione credenciales como nombre de usuario: contraseña.", "httpDestAuthBasicDescription": "Añade un encabezado Authorization: Basic '<credenciales>'. Proporcione las credenciales como nombredeusuario:contraseña.",
"httpDestAuthBasicPlaceholder": "usuario:contraseña", "httpDestAuthBasicPlaceholder": "usuario:contraseña",
"httpDestAuthCustomTitle": "Cabecera personalizada", "httpDestAuthCustomTitle": "Cabecera personalizada",
"httpDestAuthCustomDescription": "Especifique un nombre de cabecera HTTP personalizado y un valor para la autenticación (por ejemplo, X-API-Key).", "httpDestAuthCustomDescription": "Especifique un nombre de cabecera HTTP personalizado y un valor para la autenticación (por ejemplo, X-API-Key).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "Clé de provisioning mise à jour", "provisioningKeysUpdated": "Clé de provisioning mise à jour",
"provisioningKeysUpdatedDescription": "Vos modifications ont été enregistrées.", "provisioningKeysUpdatedDescription": "Vos modifications ont été enregistrées.",
"provisioningKeysBannerTitle": "Clés de provisioning du site", "provisioningKeysBannerTitle": "Clés de provisioning du site",
"provisioningKeysBannerDescription": "Générez une clé de provisioning et utilisez-la avec le connecteur Newt pour créer automatiquement des sites au premier démarrage — pas besoin de configurer des identifiants distincts pour chaque site.", "provisioningKeysBannerDescription": "Générez une clé de provisionnement et utilisez-la avec le connecteur Newt pour créer automatiquement des sites lors du premier démarrage - sans besoin de configurer des identifiants séparés pour chaque site.",
"provisioningKeysBannerButtonText": "En savoir plus", "provisioningKeysBannerButtonText": "En savoir plus",
"pendingSitesBannerTitle": "Sites en attente", "pendingSitesBannerTitle": "Sites en attente",
"pendingSitesBannerDescription": "Les sites qui se connectent à l'aide d'une clé de provisioning apparaissent ici pour être revus. Approuver chaque site avant qu'il ne devienne actif et qu'il accède à vos ressources.", "pendingSitesBannerDescription": "Les sites qui se connectent en utilisant une clé de provisionnement apparaissent ici pour révision.",
"pendingSitesBannerButtonText": "En savoir plus", "pendingSitesBannerButtonText": "En savoir plus",
"apiKeysSettings": "Paramètres de {apiKeyName}", "apiKeysSettings": "Paramètres de {apiKeyName}",
"userTitle": "Gérer tous les utilisateurs", "userTitle": "Gérer tous les utilisateurs",
@@ -405,6 +405,10 @@
"licenseErrorKeyActivate": "Échec de l'activation de la clé de licence", "licenseErrorKeyActivate": "Échec de l'activation de la clé de licence",
"licenseErrorKeyActivateDescription": "Une erreur s'est produite lors de l'activation de la clé de licence.", "licenseErrorKeyActivateDescription": "Une erreur s'est produite lors de l'activation de la clé de licence.",
"licenseAbout": "À propos de la licence", "licenseAbout": "À propos de la licence",
"licenseBannerTitle": "Activer Votre Licence Entreprise",
"licenseBannerDescription": "Débloquez les fonctionnalités d'entreprise pour votre instance autohébergée de Pangolin. Achetez une clé de licence pour activer les capacités premium, puis ajoutez-la ci-dessous.",
"licenseBannerGetLicense": "Obtenez une Licence",
"licenseBannerViewDocs": "Afficher la Documentation",
"communityEdition": "Edition Communautaire", "communityEdition": "Edition Communautaire",
"licenseAboutDescription": "Ceci est destiné aux entreprises qui utilisent Pangolin dans un environnement commercial. Si vous utilisez Pangolin pour un usage personnel, vous pouvez ignorer cette section.", "licenseAboutDescription": "Ceci est destiné aux entreprises qui utilisent Pangolin dans un environnement commercial. Si vous utilisez Pangolin pour un usage personnel, vous pouvez ignorer cette section.",
"licenseKeyActivated": "Clé de licence activée", "licenseKeyActivated": "Clé de licence activée",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "Veuillez entrer un numéro de port valide", "targetErrorInvalidPortDescription": "Veuillez entrer un numéro de port valide",
"targetErrorNoSite": "Aucun site sélectionné", "targetErrorNoSite": "Aucun site sélectionné",
"targetErrorNoSiteDescription": "Veuillez sélectionner un site pour la cible", "targetErrorNoSiteDescription": "Veuillez sélectionner un site pour la cible",
"targetTargetsCleared": "Cibles effacées",
"targetTargetsClearedDescription": "Toutes les cibles ont été retirées de cette ressource",
"targetCreated": "Cible créée", "targetCreated": "Cible créée",
"targetCreatedDescription": "La cible a été créée avec succès", "targetCreatedDescription": "La cible a été créée avec succès",
"targetErrorCreate": "Impossible de créer la cible", "targetErrorCreate": "Impossible de créer la cible",
@@ -2112,8 +2118,10 @@
"selectDomainForOrgAuthPage": "Sélectionnez un domaine pour la page d'authentification de l'organisation", "selectDomainForOrgAuthPage": "Sélectionnez un domaine pour la page d'authentification de l'organisation",
"domainPickerProvidedDomain": "Domaine fourni", "domainPickerProvidedDomain": "Domaine fourni",
"domainPickerFreeProvidedDomain": "Domaine fourni gratuitement", "domainPickerFreeProvidedDomain": "Domaine fourni gratuitement",
"domainPickerFreeDomainsPaidFeature": "Les domaines fournis sont une fonctionnalité payante. Abonnez-vous pour obtenir un domaine inclus avec votre plan — plus besoin de fournir le vôtre.",
"domainPickerVerified": "Vérifié", "domainPickerVerified": "Vérifié",
"domainPickerUnverified": "Non vérifié", "domainPickerUnverified": "Non vérifié",
"domainPickerManual": "Manuel",
"domainPickerInvalidSubdomainStructure": "Ce sous-domaine contient des caractères ou une structure non valide. Il sera automatiquement nettoyé lorsque vous enregistrez.", "domainPickerInvalidSubdomainStructure": "Ce sous-domaine contient des caractères ou une structure non valide. Il sera automatiquement nettoyé lorsque vous enregistrez.",
"domainPickerError": "Erreur", "domainPickerError": "Erreur",
"domainPickerErrorLoadDomains": "Impossible de charger les domaines de l'organisation", "domainPickerErrorLoadDomains": "Impossible de charger les domaines de l'organisation",
@@ -2346,7 +2354,7 @@
"description": "Fonctionnalités d'entreprise, 50 utilisateurs, 50 sites et une prise en charge prioritaire." "description": "Fonctionnalités d'entreprise, 50 utilisateurs, 50 sites et une prise en charge prioritaire."
} }
}, },
"personalUseOnly": "Utilisation personnelle uniquement (licence gratuite — sans checkout)", "personalUseOnly": "Usage personnel uniquement (licence gratuite - pas de validation)",
"buttons": { "buttons": {
"continueToCheckout": "Continuer vers le paiement" "continueToCheckout": "Continuer vers le paiement"
}, },
@@ -2607,6 +2615,9 @@
"machineClients": "Clients Machines", "machineClients": "Clients Machines",
"install": "Installer", "install": "Installer",
"run": "Exécuter", "run": "Exécuter",
"envFile": "Fichier Environnement",
"serviceFile": "Fichier de Service",
"enableAndStart": "Activer et Démarrer",
"clientNameDescription": "Le nom d'affichage du client qui peut être modifié plus tard.", "clientNameDescription": "Le nom d'affichage du client qui peut être modifié plus tard.",
"clientAddress": "Adresse du client (Avancé)", "clientAddress": "Adresse du client (Avancé)",
"setupFailedToFetchSubnet": "Impossible de récupérer le sous-réseau par défaut", "setupFailedToFetchSubnet": "Impossible de récupérer le sous-réseau par défaut",
@@ -2845,10 +2856,10 @@
"httpDestAuthNoneTitle": "Aucune authentification", "httpDestAuthNoneTitle": "Aucune authentification",
"httpDestAuthNoneDescription": "Envoie des requêtes sans en-tête d'autorisation.", "httpDestAuthNoneDescription": "Envoie des requêtes sans en-tête d'autorisation.",
"httpDestAuthBearerTitle": "Jeton de Porteur", "httpDestAuthBearerTitle": "Jeton de Porteur",
"httpDestAuthBearerDescription": "Ajoute un en-tête Authorization: Bearer <token> à chaque requête.", "httpDestAuthBearerDescription": "Ajoute un en-tête Authorization: Bearer '<token>' à chaque requête.",
"httpDestAuthBearerPlaceholder": "Votre clé API ou votre jeton", "httpDestAuthBearerPlaceholder": "Votre clé API ou votre jeton",
"httpDestAuthBasicTitle": "Authentification basique", "httpDestAuthBasicTitle": "Authentification basique",
"httpDestAuthBasicDescription": "Ajoute une autorisation : en-tête de base <credentials> . Fournissez des informations d'identification comme nom d'utilisateur:mot de passe.", "httpDestAuthBasicDescription": "Ajoute un en-tête Authorization: Basic '<credentials>'. Fournissez les identifiants sous la forme nom d'utilisateur:mot de passe.",
"httpDestAuthBasicPlaceholder": "nom d'utilisateur:mot de passe", "httpDestAuthBasicPlaceholder": "nom d'utilisateur:mot de passe",
"httpDestAuthCustomTitle": "En-tête personnalisé", "httpDestAuthCustomTitle": "En-tête personnalisé",
"httpDestAuthCustomDescription": "Spécifiez un nom d'en-tête HTTP personnalisé et une valeur pour l'authentification (par exemple X-API-Key).", "httpDestAuthCustomDescription": "Spécifiez un nom d'en-tête HTTP personnalisé et une valeur pour l'authentification (par exemple X-API-Key).",

View File

@@ -1,19 +1,19 @@
{ {
"setupCreate": "Creare l'organizzazione, il sito e le risorse", "setupCreate": "Creare l'organizzazione, il sito e le risorse",
"headerAuthCompatibilityInfo": "Abilita questo per forzare una risposta 401 Unauthorized quando manca un token di autenticazione. Questo è richiesto per browser o librerie HTTP specifiche che non inviano credenziali senza una sfida del server.", "headerAuthCompatibilityInfo": "Abilita questa funzionalità per forzare una risposta 401 Unauthorized quando manca un token di autenticazione. Questo è richiesto per browser o librerie HTTP specifiche che non inviano credenziali senza una sfida del server.",
"headerAuthCompatibility": "Compatibilità estesa", "headerAuthCompatibility": "Compatibilità estesa",
"setupNewOrg": "Nuova Organizzazione", "setupNewOrg": "Nuova Organizzazione",
"setupCreateOrg": "Crea Organizzazione", "setupCreateOrg": "Crea Organizzazione",
"setupCreateResources": "Crea Risorse", "setupCreateResources": "Crea Risorse",
"setupOrgName": "Nome Dell'Organizzazione", "setupOrgName": "Nome dell'Organizzazione",
"orgDisplayName": "Questo è il nome visualizzato dell'organizzazione.", "orgDisplayName": "Questo è il nome visualizzato dell'organizzazione.",
"orgId": "Id Organizzazione", "orgId": "Id Organizzazione",
"setupIdentifierMessage": "Questo è l'identificatore univoco per l'organizzazione.", "setupIdentifierMessage": "Questo è l'identificatore univoco per l'organizzazione.",
"setupErrorIdentifier": "L'ID dell'organizzazione è già utilizzato. Si prega di sceglierne uno diverso.", "setupErrorIdentifier": "L'ID dell'organizzazione è già utilizzato. Si prega di sceglierne uno diverso.",
"componentsErrorNoMemberCreate": "Al momento non sei un membro di nessuna organizzazione. Crea un'organizzazione per iniziare.", "componentsErrorNoMemberCreate": "Al momento non sei un membro di nessuna organizzazione. Crea un'organizzazione per iniziare.",
"componentsErrorNoMember": "Attualmente non sei membro di nessuna organizzazione.", "componentsErrorNoMember": "Attualmente non sei membro di nessuna organizzazione.",
"welcome": "Benvenuti a Pangolin", "welcome": "Benvenuto su Pangolin!",
"welcomeTo": "Benvenuto a", "welcomeTo": "Benvenuto su Pangolin!",
"componentsCreateOrg": "Crea un'organizzazione", "componentsCreateOrg": "Crea un'organizzazione",
"componentsMember": "Sei un membro di {count, plural, =0 {nessuna organizzazione} one {un'organizzazione} other {# organizzazioni}}.", "componentsMember": "Sei un membro di {count, plural, =0 {nessuna organizzazione} one {un'organizzazione} other {# organizzazioni}}.",
"componentsInvalidKey": "Rilevata chiave di licenza non valida o scaduta. Segui i termini di licenza per continuare a utilizzare tutte le funzionalità.", "componentsInvalidKey": "Rilevata chiave di licenza non valida o scaduta. Segui i termini di licenza per continuare a utilizzare tutte le funzionalità.",
@@ -27,7 +27,7 @@
"inviteLoginUser": "Assicurati di aver effettuato l'accesso come utente corretto.", "inviteLoginUser": "Assicurati di aver effettuato l'accesso come utente corretto.",
"inviteErrorNoUser": "Siamo spiacenti, ma sembra che l'invito che stai cercando di accedere non sia per un utente che esiste.", "inviteErrorNoUser": "Siamo spiacenti, ma sembra che l'invito che stai cercando di accedere non sia per un utente che esiste.",
"inviteCreateUser": "Si prega di creare un account prima.", "inviteCreateUser": "Si prega di creare un account prima.",
"goHome": "Vai A Home", "goHome": "Vai alla Home",
"inviteLogInOtherUser": "Accedi come utente diverso", "inviteLogInOtherUser": "Accedi come utente diverso",
"createAnAccount": "Crea un account", "createAnAccount": "Crea un account",
"inviteNotAccepted": "Invito Non Accettato", "inviteNotAccepted": "Invito Non Accettato",
@@ -51,7 +51,7 @@
"edit": "Modifica", "edit": "Modifica",
"siteConfirmDelete": "Conferma Eliminazione Sito", "siteConfirmDelete": "Conferma Eliminazione Sito",
"siteDelete": "Elimina Sito", "siteDelete": "Elimina Sito",
"siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli obiettivi associati al sito verranno rimossi.", "siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli oggetti associati al sito verranno rimossi.",
"siteQuestionRemove": "Sei sicuro di voler rimuovere il sito dall'organizzazione?", "siteQuestionRemove": "Sei sicuro di voler rimuovere il sito dall'organizzazione?",
"siteManageSites": "Gestisci Siti", "siteManageSites": "Gestisci Siti",
"siteDescription": "Creare e gestire siti per abilitare la connettività a reti private", "siteDescription": "Creare e gestire siti per abilitare la connettività a reti private",
@@ -75,9 +75,9 @@
"siteLoadWGConfig": "Caricamento configurazione WireGuard...", "siteLoadWGConfig": "Caricamento configurazione WireGuard...",
"siteDocker": "Espandi per i dettagli di distribuzione Docker", "siteDocker": "Espandi per i dettagli di distribuzione Docker",
"toggle": "Attiva/disattiva", "toggle": "Attiva/disattiva",
"dockerCompose": "Composizione Docker", "dockerCompose": "Docker Compose",
"dockerRun": "Corsa Docker", "dockerRun": "Corsa Docker",
"siteLearnLocal": "I siti locali non tunnel, saperne di più", "siteLearnLocal": "I siti locali non effettuano il tunnel, per saperne di più",
"siteConfirmCopy": "Ho copiato la configurazione", "siteConfirmCopy": "Ho copiato la configurazione",
"searchSitesProgress": "Cerca siti...", "searchSitesProgress": "Cerca siti...",
"siteAdd": "Aggiungi Sito", "siteAdd": "Aggiungi Sito",
@@ -88,29 +88,29 @@
"operatingSystem": "Sistema Operativo", "operatingSystem": "Sistema Operativo",
"commands": "Comandi", "commands": "Comandi",
"recommended": "Consigliato", "recommended": "Consigliato",
"siteNewtDescription": "Per la migliore esperienza utente, utilizzare Newt. Utilizza WireGuard sotto il cofano e ti permette di indirizzare le tue risorse private tramite il loro indirizzo LAN sulla tua rete privata dall'interno della dashboard Pangolin.", "siteNewtDescription": "Per la migliore esperienza utente utilizzare Newt, che usa WireGuard sotto il cofano e ti permette di indirizzare le tue risorse private tramite il loro indirizzo LAN sulla tua rete privata dall'interno della dashboard Pangolin.",
"siteRunsInDocker": "Esegue nel Docker", "siteRunsInDocker": "Esegue nel Docker",
"siteRunsInShell": "Esegue in shell su macOS, Linux e Windows", "siteRunsInShell": "Esegue in shell su macOS, Linux e Windows",
"siteErrorDelete": "Errore nell'eliminare il sito", "siteErrorDelete": "Errore nella eliminazione del sito",
"siteErrorUpdate": "Impossibile aggiornare il sito", "siteErrorUpdate": "Impossibile aggiornare il sito",
"siteErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento del sito.", "siteErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento del sito.",
"siteUpdated": "Sito aggiornato", "siteUpdated": "Sito aggiornato",
"siteUpdatedDescription": "Il sito è stato aggiornato.", "siteUpdatedDescription": "Il sito è stato aggiornato.",
"siteGeneralDescription": "Configura le impostazioni generali per questo sito", "siteGeneralDescription": "Configura le impostazioni generali per questo sito",
"siteSettingDescription": "Configura le impostazioni del sito", "siteSettingDescription": "Configura le impostazioni del sito",
"siteSetting": "Impostazioni {siteName}", "siteSetting": "Impostazioni del sito {siteName}",
"siteNewtTunnel": "Nuovo Sito (Consigliato)", "siteNewtTunnel": "Nuovo Sito (Consigliato)",
"siteNewtTunnelDescription": "Modo più semplice per creare un entrypoint in qualsiasi rete. Nessuna configurazione aggiuntiva.", "siteNewtTunnelDescription": "Modo più semplice per creare un entrypoint in qualsiasi rete. Nessuna configurazione aggiuntiva.",
"siteWg": "WireGuard Base", "siteWg": "WireGuard Base",
"siteWgDescription": "Usa qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.", "siteWgDescription": "Usa un qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.",
"siteWgDescriptionSaas": "Usa qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta. FUNZIONA SOLO SU NODI AUTO-OSPITATI", "siteWgDescriptionSaas": "Usa un qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.",
"siteLocalDescription": "Solo risorse locali. Nessun tunneling.", "siteLocalDescription": "Solo risorse locali. Nessun tunneling.",
"siteLocalDescriptionSaas": "Solo risorse locali. Nessun tunneling. Disponibile solo su nodi remoti.", "siteLocalDescriptionSaas": "Solo risorse locali. Nessun tunneling. Disponibile solo su nodi remoti.",
"siteSeeAll": "Vedi Tutti I Siti", "siteSeeAll": "Vedi Tutti I Siti",
"siteTunnelDescription": "Determinare come si desidera connettersi al sito", "siteTunnelDescription": "Selezionare la modalità con la quale si desidera connettersi al sito",
"siteNewtCredentials": "Credenziali", "siteNewtCredentials": "Credenziali",
"siteNewtCredentialsDescription": "Questo è come il sito si autenticerà con il server", "siteNewtCredentialsDescription": "Questo è come il sito si autenticherà con il server",
"remoteNodeCredentialsDescription": "Questo è come il nodo remoto si autenticherà con il server", "remoteNodeCredentialsDescription": "Questo è il modo in cui il nodo remoto si autenticherà con il server",
"siteCredentialsSave": "Salva le credenziali", "siteCredentialsSave": "Salva le credenziali",
"siteCredentialsSaveDescription": "Potrai vederlo solo una volta. Assicurati di copiarlo in un luogo sicuro.", "siteCredentialsSaveDescription": "Potrai vederlo solo una volta. Assicurati di copiarlo in un luogo sicuro.",
"siteInfo": "Informazioni Sito", "siteInfo": "Informazioni Sito",
@@ -140,8 +140,8 @@
"shareCreateDescription": "Chiunque con questo link può accedere alla risorsa", "shareCreateDescription": "Chiunque con questo link può accedere alla risorsa",
"shareTitleOptional": "Titolo (facoltativo)", "shareTitleOptional": "Titolo (facoltativo)",
"expireIn": "Scadenza In", "expireIn": "Scadenza In",
"neverExpire": "Mai scadere", "neverExpire": "Nessuna scadenza",
"shareExpireDescription": "Il tempo di scadenza è per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.", "shareExpireDescription": "Il tempo di scadenza indica per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.",
"shareSeeOnce": "Potrai vedere questo link solo una volta. Assicurati di copiarlo.", "shareSeeOnce": "Potrai vedere questo link solo una volta. Assicurati di copiarlo.",
"shareAccessHint": "Chiunque abbia questo link può accedere alla risorsa. Condividilo con cura.", "shareAccessHint": "Chiunque abbia questo link può accedere alla risorsa. Condividilo con cura.",
"shareTokenUsage": "Vedi Utilizzo Token Di Accesso", "shareTokenUsage": "Vedi Utilizzo Token Di Accesso",
@@ -161,9 +161,9 @@
"never": "Mai", "never": "Mai",
"shareErrorSelectResource": "Seleziona una risorsa", "shareErrorSelectResource": "Seleziona una risorsa",
"proxyResourceTitle": "Gestisci Risorse Pubbliche", "proxyResourceTitle": "Gestisci Risorse Pubbliche",
"proxyResourceDescription": "Creare e gestire risorse accessibili al pubblico tramite un browser web", "proxyResourceDescription": "Creare e gestire risorse pubbliche accessibili tramite un browser web",
"proxyResourcesBannerTitle": "Accesso Pubblico Basato sul Web", "proxyResourcesBannerTitle": "Accesso Pubblico Basato sul Web",
"proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili a chiunque su Internet tramite un browser web. A differenza delle risorse private, non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.", "proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili da chiunque tramite Internet da un browser web. A differenza delle risorse private non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.",
"clientResourceTitle": "Gestisci Risorse Private", "clientResourceTitle": "Gestisci Risorse Private",
"clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso", "clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso",
"privateResourcesBannerTitle": "Accesso Privato Zero-Trust", "privateResourcesBannerTitle": "Accesso Privato Zero-Trust",
@@ -174,12 +174,12 @@
"authentication": "Autenticazione", "authentication": "Autenticazione",
"protected": "Protetto", "protected": "Protetto",
"notProtected": "Non Protetto", "notProtected": "Non Protetto",
"resourceMessageRemove": "Una volta rimossa, la risorsa non sarà più accessibile. Tutti gli obiettivi associati alla risorsa saranno rimossi.", "resourceMessageRemove": "Una volta rimossa la risorsa non sarà più accessibile. Tutti gli oggetti target associati alla risorsa saranno rimossi.",
"resourceQuestionRemove": "Sei sicuro di voler rimuovere la risorsa dall'organizzazione?", "resourceQuestionRemove": "Sei sicuro di voler rimuovere la risorsa dall'organizzazione?",
"resourceHTTP": "Risorsa HTTPS", "resourceHTTP": "Risorsa HTTPS",
"resourceHTTPDescription": "Richieste proxy su HTTPS usando un nome di dominio completo.", "resourceHTTPDescription": "Richieste proxy su HTTPS usando un nome di dominio completo.",
"resourceRaw": "Risorsa Raw TCP/UDP", "resourceRaw": "Risorsa Raw TCP/UDP",
"resourceRawDescription": "Richieste proxy su TCP/UDP grezzo utilizzando un numero di porta.", "resourceRawDescription": "Richieste proxy su TCP/UDP raw utilizzando un numero di porta.",
"resourceRawDescriptionCloud": "Richiesta proxy su TCP/UDP grezzo utilizzando un numero di porta. Richiede siti per connettersi a un nodo remoto.", "resourceRawDescriptionCloud": "Richiesta proxy su TCP/UDP grezzo utilizzando un numero di porta. Richiede siti per connettersi a un nodo remoto.",
"resourceCreate": "Crea Risorsa", "resourceCreate": "Crea Risorsa",
"resourceCreateDescription": "Segui i passaggi seguenti per creare una nuova risorsa", "resourceCreateDescription": "Segui i passaggi seguenti per creare una nuova risorsa",
@@ -192,7 +192,7 @@
"selectCountry": "Seleziona paese", "selectCountry": "Seleziona paese",
"searchCountries": "Cerca paesi...", "searchCountries": "Cerca paesi...",
"noCountryFound": "Nessun paese trovato.", "noCountryFound": "Nessun paese trovato.",
"siteSelectionDescription": "Questo sito fornirà connettività all'obiettivo.", "siteSelectionDescription": "Questo sito fornirà connettività all'oggetto target.",
"resourceType": "Tipo Di Risorsa", "resourceType": "Tipo Di Risorsa",
"resourceTypeDescription": "Determinare come accedere alla risorsa", "resourceTypeDescription": "Determinare come accedere alla risorsa",
"resourceHTTPSSettings": "Impostazioni HTTPS", "resourceHTTPSSettings": "Impostazioni HTTPS",
@@ -206,13 +206,13 @@
"protocol": "Protocollo", "protocol": "Protocollo",
"protocolSelect": "Seleziona un protocollo", "protocolSelect": "Seleziona un protocollo",
"resourcePortNumber": "Numero Porta", "resourcePortNumber": "Numero Porta",
"resourcePortNumberDescription": "Il numero di porta esterna per le richieste di proxy.", "resourcePortNumberDescription": "Il numero di porta esterna per le richieste proxy.",
"back": "Indietro", "back": "Indietro",
"cancel": "Annulla", "cancel": "Annulla",
"resourceConfig": "Snippet Di Configurazione", "resourceConfig": "Snippet Di Configurazione",
"resourceConfigDescription": "Copia e incolla questi snippet di configurazione per configurare la risorsa TCP/UDP", "resourceConfigDescription": "Copia e incolla questi snippet di configurazione per configurare la risorsa TCP/UDP",
"resourceAddEntrypoints": "Traefik: Aggiungi Ingresso", "resourceAddEntrypoints": "Traefik: Aggiungi Entrypoint",
"resourceExposePorts": "Gerbil: espone le porte in Docker componi", "resourceExposePorts": "Gerbil: espone le porte in Docker Compose",
"resourceLearnRaw": "Scopri come configurare le risorse TCP/UDP", "resourceLearnRaw": "Scopri come configurare le risorse TCP/UDP",
"resourceBack": "Torna alle risorse", "resourceBack": "Torna alle risorse",
"resourceGoTo": "Vai alla Risorsa", "resourceGoTo": "Vai alla Risorsa",
@@ -228,7 +228,7 @@
"rules": "Regole", "rules": "Regole",
"resourceSettingDescription": "Configura le impostazioni sulla risorsa", "resourceSettingDescription": "Configura le impostazioni sulla risorsa",
"resourceSetting": "Impostazioni {resourceName}", "resourceSetting": "Impostazioni {resourceName}",
"alwaysAllow": "Autenticazione Bypass", "alwaysAllow": "Bypass Autenticazione",
"alwaysDeny": "Blocca Accesso", "alwaysDeny": "Blocca Accesso",
"passToAuth": "Passa all'autenticazione", "passToAuth": "Passa all'autenticazione",
"orgSettingsDescription": "Configura le impostazioni dell'organizzazione", "orgSettingsDescription": "Configura le impostazioni dell'organizzazione",
@@ -237,11 +237,11 @@
"saveGeneralSettings": "Salva Impostazioni Generali", "saveGeneralSettings": "Salva Impostazioni Generali",
"saveSettings": "Salva Impostazioni", "saveSettings": "Salva Impostazioni",
"orgDangerZone": "Zona Pericolosa", "orgDangerZone": "Zona Pericolosa",
"orgDangerZoneDescription": "Una volta che si elimina questo org, non c'è ritorno. Si prega di essere certi.", "orgDangerZoneDescription": "Una volta che si elimina questa org non sarà possibile tornare indietro, assicurarsi quindi di essere certi della decisione.",
"orgDelete": "Elimina Organizzazione", "orgDelete": "Elimina Organizzazione",
"orgDeleteConfirm": "Conferma Elimina Organizzazione", "orgDeleteConfirm": "Conferma Elimina Organizzazione",
"orgMessageRemove": "Questa azione è irreversibile e cancellerà tutti i dati associati.", "orgMessageRemove": "Questa azione è irreversibile e cancellerà tutti i dati associati.",
"orgMessageConfirm": "Per confermare, digita il nome dell'organizzazione qui sotto.", "orgMessageConfirm": "Per confermare digita il nome dell'organizzazione qui sotto.",
"orgQuestionRemove": "Sei sicuro di voler rimuovere l'organizzazione?", "orgQuestionRemove": "Sei sicuro di voler rimuovere l'organizzazione?",
"orgUpdated": "Organizzazione aggiornata", "orgUpdated": "Organizzazione aggiornata",
"orgUpdatedDescription": "L'organizzazione è stata aggiornata.", "orgUpdatedDescription": "L'organizzazione è stata aggiornata.",
@@ -254,10 +254,10 @@
"orgDeleted": "Organizzazione eliminata", "orgDeleted": "Organizzazione eliminata",
"orgDeletedMessage": "L'organizzazione e i suoi dati sono stati eliminati.", "orgDeletedMessage": "L'organizzazione e i suoi dati sono stati eliminati.",
"deleteAccount": "Elimina Account", "deleteAccount": "Elimina Account",
"deleteAccountDescription": "Elimina definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.", "deleteAccountDescription": "Elimina definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questa operazione non può essere annullata.",
"deleteAccountButton": "Elimina Account", "deleteAccountButton": "Elimina Account",
"deleteAccountConfirmTitle": "Elimina Account", "deleteAccountConfirmTitle": "Elimina Account",
"deleteAccountConfirmMessage": "Questo cancellerà definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.", "deleteAccountConfirmMessage": "Questa operazione cancellerà definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questa operazione non può essere annullata.",
"deleteAccountConfirmString": "elimina account", "deleteAccountConfirmString": "elimina account",
"deleteAccountSuccess": "Account Eliminato", "deleteAccountSuccess": "Account Eliminato",
"deleteAccountSuccessMessage": "Il tuo account è stato eliminato.", "deleteAccountSuccessMessage": "Il tuo account è stato eliminato.",
@@ -272,7 +272,7 @@
"accessUserCreate": "Crea Utente", "accessUserCreate": "Crea Utente",
"accessUserRemove": "Rimuovi Utente", "accessUserRemove": "Rimuovi Utente",
"username": "Nome utente", "username": "Nome utente",
"identityProvider": "Provider Di Identità", "identityProvider": "Provider Identità",
"role": "Ruolo", "role": "Ruolo",
"nameRequired": "Il nome è obbligatorio", "nameRequired": "Il nome è obbligatorio",
"accessRolesManage": "Gestisci Ruoli", "accessRolesManage": "Gestisci Ruoli",
@@ -328,8 +328,8 @@
"apiKeysDelete": "Elimina Chiave API", "apiKeysDelete": "Elimina Chiave API",
"apiKeysManage": "Gestisci Chiavi API", "apiKeysManage": "Gestisci Chiavi API",
"apiKeysDescription": "Le chiavi API sono utilizzate per autenticarsi con l'API di integrazione", "apiKeysDescription": "Le chiavi API sono utilizzate per autenticarsi con l'API di integrazione",
"provisioningKeysTitle": "Chiave Di Provvedimento", "provisioningKeysTitle": "Chiave di provisioning",
"provisioningKeysManage": "Gestisci Chiavi Di Provvedimento", "provisioningKeysManage": "Gestisci Chiavi di provisioning",
"provisioningKeysDescription": "Le chiavi di provisioning vengono utilizzate per autenticare il provisioning automatico del sito per la tua organizzazione.", "provisioningKeysDescription": "Le chiavi di provisioning vengono utilizzate per autenticare il provisioning automatico del sito per la tua organizzazione.",
"provisioningManage": "Accantonamento", "provisioningManage": "Accantonamento",
"provisioningDescription": "Gestire le chiavi di provisioning e rivedere i siti in attesa di approvazione.", "provisioningDescription": "Gestire le chiavi di provisioning e rivedere i siti in attesa di approvazione.",
@@ -337,25 +337,25 @@
"siteApproveSuccess": "Sito approvato con successo", "siteApproveSuccess": "Sito approvato con successo",
"siteApproveError": "Errore nell'approvazione del sito", "siteApproveError": "Errore nell'approvazione del sito",
"provisioningKeys": "Chiavi Di Provvedimento", "provisioningKeys": "Chiavi Di Provvedimento",
"searchProvisioningKeys": "Cerca i tasti di provisioning ...", "searchProvisioningKeys": "Cerca le chiavi di provisioning...",
"provisioningKeysAdd": "Genera Chiave Di Provvedimento", "provisioningKeysAdd": "Genera Chiave di provisioning",
"provisioningKeysErrorDelete": "Errore nell'eliminare la chiave di provisioning", "provisioningKeysErrorDelete": "Errore nell'eliminazione della chiave di provisioning",
"provisioningKeysErrorDeleteMessage": "Errore nell'eliminare la chiave di provisioning", "provisioningKeysErrorDeleteMessage": "Errore nell'eliminazione della chiave di provisioning",
"provisioningKeysQuestionRemove": "Sei sicuro di voler rimuovere questa chiave di provisioning dall'organizzazione?", "provisioningKeysQuestionRemove": "Sei sicuro di voler rimuovere questa chiave di provisioning dall'organizzazione?",
"provisioningKeysMessageRemove": "Una volta rimossa, la chiave non può più essere utilizzata per il provisioning.", "provisioningKeysMessageRemove": "Una volta rimossa, la chiave non può più essere utilizzata per il provisioning.",
"provisioningKeysDeleteConfirm": "Conferma Elimina Chiave Provvisoria", "provisioningKeysDeleteConfirm": "Conferma Eliminazione della chiave di provisioning",
"provisioningKeysDelete": "Elimina chiave di provisioning", "provisioningKeysDelete": "Elimina chiave di provisioning",
"provisioningKeysCreate": "Genera Chiave Di Provvedimento", "provisioningKeysCreate": "Genera Chiave di provisioning",
"provisioningKeysCreateDescription": "Genera una nuova chiave di provisioning per l'organizzazione", "provisioningKeysCreateDescription": "Genera una nuova chiave di provisioning per l'organizzazione",
"provisioningKeysSeeAll": "Vedi tutte le chiavi di provisioning", "provisioningKeysSeeAll": "Vedi tutte le chiavi di provisioning",
"provisioningKeysSave": "Salva la chiave di provisioning", "provisioningKeysSave": "Salva la chiave di provisioning",
"provisioningKeysSaveDescription": "Sarai in grado di vedere solo una volta. Copiarlo in un posto sicuro.", "provisioningKeysSaveDescription": "Sarai in grado di vedere solo una volta. Copiarlo in un posto sicuro.",
"provisioningKeysErrorCreate": "Errore nella creazione della chiave di provisioning", "provisioningKeysErrorCreate": "Errore nella creazione della chiave di provisioning",
"provisioningKeysList": "Nuova chiave di provisioning", "provisioningKeysList": "Nuova chiave di provisioning",
"provisioningKeysMaxBatchSize": "Dimensione massima lotto", "provisioningKeysMaxBatchSize": "Dimensione massima batch",
"provisioningKeysUnlimitedBatchSize": "Dimensione illimitata del lotto (nessun limite)", "provisioningKeysUnlimitedBatchSize": "Dimensione illimitata del batch (nessun limite)",
"provisioningKeysMaxBatchUnlimited": "Illimitato", "provisioningKeysMaxBatchUnlimited": "Illimitato",
"provisioningKeysMaxBatchSizeInvalid": "Inserisci un lotto massimo valido (11.000.000).", "provisioningKeysMaxBatchSizeInvalid": "Inserisci una dimensione massima valida del batch (11.000.000).",
"provisioningKeysValidUntil": "Valido fino al", "provisioningKeysValidUntil": "Valido fino al",
"provisioningKeysValidUntilHint": "Lasciare vuoto per nessuna scadenza.", "provisioningKeysValidUntilHint": "Lasciare vuoto per nessuna scadenza.",
"provisioningKeysValidUntilInvalid": "Inserisci una data e ora valide.", "provisioningKeysValidUntilInvalid": "Inserisci una data e ora valide.",
@@ -363,18 +363,18 @@
"provisioningKeysLastUsed": "Ultimo utilizzo", "provisioningKeysLastUsed": "Ultimo utilizzo",
"provisioningKeysNoExpiry": "Nessuna scadenza", "provisioningKeysNoExpiry": "Nessuna scadenza",
"provisioningKeysNeverUsed": "Mai", "provisioningKeysNeverUsed": "Mai",
"provisioningKeysEdit": "Modifica Chiave Di Provvedimento", "provisioningKeysEdit": "Modifica Chiave di provisioning",
"provisioningKeysEditDescription": "Aggiorna la dimensione massima del lotto e il tempo di scadenza per questa chiave.", "provisioningKeysEditDescription": "Aggiorna la dimensione massima del batch e il tempo di scadenza per questa chiave.",
"provisioningKeysApproveNewSites": "Approva nuovi siti", "provisioningKeysApproveNewSites": "Approva nuovi siti",
"provisioningKeysApproveNewSitesDescription": "Approvare automaticamente i siti che si registrano con questa chiave.", "provisioningKeysApproveNewSitesDescription": "Approvare automaticamente i siti che si registrano con questa chiave.",
"provisioningKeysUpdateError": "Errore nell'aggiornamento della chiave di provisioning", "provisioningKeysUpdateError": "Errore nell'aggiornamento della chiave di provisioning",
"provisioningKeysUpdated": "Chiave di accantonamento aggiornata", "provisioningKeysUpdated": "Chiave di provisioning aggiornata",
"provisioningKeysUpdatedDescription": "Le tue modifiche sono state salvate.", "provisioningKeysUpdatedDescription": "Le tue modifiche sono state salvate.",
"provisioningKeysBannerTitle": "Chiavi Di Provvedimento Sito", "provisioningKeysBannerTitle": "Chiavi di provisioning del Sito",
"provisioningKeysBannerDescription": "Generare una chiave di provisioning e usarla con il connettore Newt per creare automaticamente siti al primo avvio non è necessario impostare credenziali separate per ogni sito.", "provisioningKeysBannerDescription": "Genera una chiave di provisioning e usala con il connettore Newt per creare automaticamente i siti al primo avvio - non è necessario configurare credenziali separate per ogni sito.",
"provisioningKeysBannerButtonText": "Scopri di più", "provisioningKeysBannerButtonText": "Scopri di più",
"pendingSitesBannerTitle": "Siti In Attesa", "pendingSitesBannerTitle": "Siti In Attesa",
"pendingSitesBannerDescription": "I siti che si connettono utilizzando una chiave di provisioning appaiono qui per la revisione. Approva ogni sito prima che diventi attivo e ottenga l'accesso alle tue risorse.", "pendingSitesBannerDescription": "I siti che si connettono utilizzando una chiave di provisioning vengono visualizzati qui per la revisione.",
"pendingSitesBannerButtonText": "Scopri di più", "pendingSitesBannerButtonText": "Scopri di più",
"apiKeysSettings": "Impostazioni {apiKeyName}", "apiKeysSettings": "Impostazioni {apiKeyName}",
"userTitle": "Gestisci Tutti Gli Utenti", "userTitle": "Gestisci Tutti Gli Utenti",
@@ -386,7 +386,7 @@
"userErrorDelete": "Errore nell'eliminare l'utente", "userErrorDelete": "Errore nell'eliminare l'utente",
"userDeleteConfirm": "Conferma Eliminazione Utente", "userDeleteConfirm": "Conferma Eliminazione Utente",
"userDeleteServer": "Elimina utente dal server", "userDeleteServer": "Elimina utente dal server",
"userMessageRemove": "L'utente verrà rimosso da tutte le organizzazioni ed essere completamente rimosso dal server.", "userMessageRemove": "L'utente verrà rimosso da tutte le organizzazioni e verrà completamente rimosso dal server.",
"userQuestionRemove": "Sei sicuro di voler eliminare definitivamente l'utente dal server?", "userQuestionRemove": "Sei sicuro di voler eliminare definitivamente l'utente dal server?",
"licenseKey": "Chiave Di Licenza", "licenseKey": "Chiave Di Licenza",
"valid": "Valido", "valid": "Valido",
@@ -404,9 +404,13 @@
"licenseKeyDeletedDescription": "La chiave di licenza è stata eliminata.", "licenseKeyDeletedDescription": "La chiave di licenza è stata eliminata.",
"licenseErrorKeyActivate": "Attivazione della chiave di licenza non riuscita", "licenseErrorKeyActivate": "Attivazione della chiave di licenza non riuscita",
"licenseErrorKeyActivateDescription": "Si è verificato un errore nell'attivazione della chiave di licenza.", "licenseErrorKeyActivateDescription": "Si è verificato un errore nell'attivazione della chiave di licenza.",
"licenseAbout": "Informazioni Su Licenze", "licenseAbout": "Informazioni sul Licensing",
"licenseBannerTitle": "Attiva la tua Licenza Enterprise",
"licenseBannerDescription": "Sblocca le funzionalità enterprise per la tua istanza Pangolin auto-ospitata. Acquista una chiave di licenza per attivare le capacità premium e poi aggiungila qui sotto.",
"licenseBannerGetLicense": "Ottieni una Licenza",
"licenseBannerViewDocs": "Visualizza Documentazione",
"communityEdition": "Edizione Community", "communityEdition": "Edizione Community",
"licenseAboutDescription": "Questo è per gli utenti aziendali e aziendali che utilizzano Pangolin in un ambiente commerciale. Se stai usando Pangolin per uso personale, puoi ignorare questa sezione.", "licenseAboutDescription": "Questa sezione è per gli utenti aziendali e aziendali che utilizzano Pangolin in un ambiente commerciale. Se stai usando Pangolin per uso personale, puoi ignorare questa sezione.",
"licenseKeyActivated": "Chiave di licenza attivata", "licenseKeyActivated": "Chiave di licenza attivata",
"licenseKeyActivatedDescription": "La chiave di licenza è stata attivata correttamente.", "licenseKeyActivatedDescription": "La chiave di licenza è stata attivata correttamente.",
"licenseErrorKeyRecheck": "Impossibile ricontrollare le chiavi di licenza", "licenseErrorKeyRecheck": "Impossibile ricontrollare le chiavi di licenza",
@@ -429,7 +433,7 @@
"licenseHostDescription": "Gestisci la chiave di licenza principale per l'host.", "licenseHostDescription": "Gestisci la chiave di licenza principale per l'host.",
"licensedNot": "Non Licenziato", "licensedNot": "Non Licenziato",
"hostId": "ID Host", "hostId": "ID Host",
"licenseReckeckAll": "Ricontrolla Tutte Le Tasti", "licenseReckeckAll": "Ricontrolla Tutte le chiavi",
"licenseSiteUsage": "Utilizzo Siti", "licenseSiteUsage": "Utilizzo Siti",
"licenseSiteUsageDecsription": "Visualizza il numero di siti che utilizzano questa licenza.", "licenseSiteUsageDecsription": "Visualizza il numero di siti che utilizzano questa licenza.",
"licenseNoSiteLimit": "Non c'è alcun limite al numero di siti che utilizzano un host senza licenza.", "licenseNoSiteLimit": "Non c'è alcun limite al numero di siti che utilizzano un host senza licenza.",
@@ -480,7 +484,7 @@
"userOrgRemoved": "Utente rimosso", "userOrgRemoved": "Utente rimosso",
"userOrgRemovedDescription": "L'utente {email} è stato rimosso dall'organizzazione.", "userOrgRemovedDescription": "L'utente {email} è stato rimosso dall'organizzazione.",
"userQuestionOrgRemove": "Sei sicuro di voler rimuovere questo utente dall'organizzazione?", "userQuestionOrgRemove": "Sei sicuro di voler rimuovere questo utente dall'organizzazione?",
"userMessageOrgRemove": "Una volta rimosso, questo utente non avrà più accesso all'organizzazione. Puoi sempre reinvitarlo in seguito, ma dovrà accettare nuovamente l'invito.", "userMessageOrgRemove": "Una volta rimosso questo utente non avrà più accesso all'organizzazione. Puoi sempre reinvitarlo in seguito, ma dovrà accettare nuovamente l'invito.",
"userRemoveOrgConfirm": "Conferma Rimozione Utente", "userRemoveOrgConfirm": "Conferma Rimozione Utente",
"userRemoveOrg": "Rimuovi Utente dall'Organizzazione", "userRemoveOrg": "Rimuovi Utente dall'Organizzazione",
"users": "Utenti", "users": "Utenti",
@@ -532,13 +536,13 @@
"approve": "Approva", "approve": "Approva",
"approved": "Approvato", "approved": "Approvato",
"denied": "Negato", "denied": "Negato",
"deniedApproval": "Omologazione Negata", "deniedApproval": "Approvazione Negata",
"all": "Tutti", "all": "Tutti",
"deny": "Nega", "deny": "Nega",
"viewDetails": "Visualizza Dettagli", "viewDetails": "Visualizza Dettagli",
"requestingNewDeviceApproval": "ha richiesto un nuovo dispositivo", "requestingNewDeviceApproval": "ha richiesto un nuovo dispositivo",
"resetFilters": "Ripristina Filtri", "resetFilters": "Ripristina Filtri",
"totalBlocked": "Richieste Bloccate Da Pangolino", "totalBlocked": "Richieste Bloccate Da Pangolin",
"totalRequests": "Totale Richieste", "totalRequests": "Totale Richieste",
"requestsByCountry": "Richieste Per Paese", "requestsByCountry": "Richieste Per Paese",
"requestsByDay": "Richieste Per Giorno", "requestsByDay": "Richieste Per Giorno",
@@ -546,7 +550,7 @@
"allowed": "Consentito", "allowed": "Consentito",
"topCountries": "Paesi Principali", "topCountries": "Paesi Principali",
"accessRoleSelect": "Seleziona ruolo", "accessRoleSelect": "Seleziona ruolo",
"inviteEmailSentDescription": "È stata inviata un'email all'utente con il link di accesso qui sotto. Devono accedere al link per accettare l'invito.", "inviteEmailSentDescription": "È stata inviata un'email all'utente con il link di accesso qui sotto. L'utente deve accedere al link per accettare l'invito.",
"inviteSentDescription": "L'utente è stato invitato. Deve accedere al link qui sotto per accettare l'invito.", "inviteSentDescription": "L'utente è stato invitato. Deve accedere al link qui sotto per accettare l'invito.",
"inviteExpiresIn": "L'invito scadrà tra {days, plural, one {# giorno} other {# giorni}}.", "inviteExpiresIn": "L'invito scadrà tra {days, plural, one {# giorno} other {# giorni}}.",
"idpTitle": "Informazioni Generali", "idpTitle": "Informazioni Generali",
@@ -562,7 +566,7 @@
"userSaved": "Utente salvato", "userSaved": "Utente salvato",
"userSavedDescription": "L'utente è stato aggiornato.", "userSavedDescription": "L'utente è stato aggiornato.",
"autoProvisioned": "Auto Provisioned", "autoProvisioned": "Auto Provisioned",
"autoProvisionSettings": "Impostazioni Automatiche Di Fornitura", "autoProvisionSettings": "Impostazioni Automatiche di provisioning",
"autoProvisionedDescription": "Permetti a questo utente di essere gestito automaticamente dal provider di identità", "autoProvisionedDescription": "Permetti a questo utente di essere gestito automaticamente dal provider di identità",
"accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione", "accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione",
"accessControlsSubmit": "Salva Controlli di Accesso", "accessControlsSubmit": "Salva Controlli di Accesso",
@@ -576,9 +580,9 @@
"proxyErrorInvalidHeader": "Valore dell'intestazione Host personalizzata non valido. Usa il formato nome dominio o salva vuoto per rimuovere l'intestazione Host personalizzata.", "proxyErrorInvalidHeader": "Valore dell'intestazione Host personalizzata non valido. Usa il formato nome dominio o salva vuoto per rimuovere l'intestazione Host personalizzata.",
"proxyErrorTls": "Nome Server TLS non valido. Usa il formato nome dominio o salva vuoto per rimuovere il Nome Server TLS.", "proxyErrorTls": "Nome Server TLS non valido. Usa il formato nome dominio o salva vuoto per rimuovere il Nome Server TLS.",
"proxyEnableSSL": "Abilita SSL", "proxyEnableSSL": "Abilita SSL",
"proxyEnableSSLDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure agli obiettivi.", "proxyEnableSSLDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alle risorse interne target.",
"target": "Target", "target": "Target",
"configureTarget": "Configura Obiettivi", "configureTarget": "Configura Risorse Interne",
"targetErrorFetch": "Impossibile recuperare i target", "targetErrorFetch": "Impossibile recuperare i target",
"targetErrorFetchDescription": "Si è verificato un errore durante il recupero dei target", "targetErrorFetchDescription": "Si è verificato un errore durante il recupero dei target",
"siteErrorFetch": "Impossibile recuperare la risorsa", "siteErrorFetch": "Impossibile recuperare la risorsa",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "Inserisci un numero di porta valido", "targetErrorInvalidPortDescription": "Inserisci un numero di porta valido",
"targetErrorNoSite": "Nessun sito selezionato", "targetErrorNoSite": "Nessun sito selezionato",
"targetErrorNoSiteDescription": "Si prega di selezionare un sito per l'obiettivo", "targetErrorNoSiteDescription": "Si prega di selezionare un sito per l'obiettivo",
"targetTargetsCleared": "Obiettivi cancellati",
"targetTargetsClearedDescription": "Tutti gli obiettivi sono stati rimossi da questa risorsa",
"targetCreated": "Destinazione creata", "targetCreated": "Destinazione creata",
"targetCreatedDescription": "L'obiettivo è stato creato con successo", "targetCreatedDescription": "L'obiettivo è stato creato con successo",
"targetErrorCreate": "Impossibile creare l'obiettivo", "targetErrorCreate": "Impossibile creare l'obiettivo",
@@ -2112,8 +2118,10 @@
"selectDomainForOrgAuthPage": "Seleziona un dominio per la pagina di autenticazione dell'organizzazione", "selectDomainForOrgAuthPage": "Seleziona un dominio per la pagina di autenticazione dell'organizzazione",
"domainPickerProvidedDomain": "Dominio Fornito", "domainPickerProvidedDomain": "Dominio Fornito",
"domainPickerFreeProvidedDomain": "Dominio Fornito Gratuito", "domainPickerFreeProvidedDomain": "Dominio Fornito Gratuito",
"domainPickerFreeDomainsPaidFeature": "I domini forniti sono una funzionalità a pagamento. Abbonati per ricevere un dominio incluso con il tuo piano — non è necessario portare il proprio.",
"domainPickerVerified": "Verificato", "domainPickerVerified": "Verificato",
"domainPickerUnverified": "Non Verificato", "domainPickerUnverified": "Non Verificato",
"domainPickerManual": "Manuale",
"domainPickerInvalidSubdomainStructure": "Questo sottodominio contiene caratteri o struttura non validi. Sarà sanificato automaticamente quando si salva.", "domainPickerInvalidSubdomainStructure": "Questo sottodominio contiene caratteri o struttura non validi. Sarà sanificato automaticamente quando si salva.",
"domainPickerError": "Errore", "domainPickerError": "Errore",
"domainPickerErrorLoadDomains": "Impossibile caricare i domini dell'organizzazione", "domainPickerErrorLoadDomains": "Impossibile caricare i domini dell'organizzazione",
@@ -2346,7 +2354,7 @@
"description": "Funzionalità aziendali, 50 utenti, 50 siti e supporto prioritario." "description": "Funzionalità aziendali, 50 utenti, 50 siti e supporto prioritario."
} }
}, },
"personalUseOnly": "Solo uso personale (licenza gratuita nessun checkout)", "personalUseOnly": "Uso personale esclusivo (licenza gratuita - nessun pagamento)",
"buttons": { "buttons": {
"continueToCheckout": "Continua al Checkout" "continueToCheckout": "Continua al Checkout"
}, },
@@ -2607,6 +2615,9 @@
"machineClients": "Machine Clients", "machineClients": "Machine Clients",
"install": "Installa", "install": "Installa",
"run": "Esegui", "run": "Esegui",
"envFile": "File di ambiente",
"serviceFile": "File di servizio",
"enableAndStart": "Abilita e avvia",
"clientNameDescription": "Il nome visualizzato del client che può essere modificato in seguito.", "clientNameDescription": "Il nome visualizzato del client che può essere modificato in seguito.",
"clientAddress": "Indirizzo Client (Avanzato)", "clientAddress": "Indirizzo Client (Avanzato)",
"setupFailedToFetchSubnet": "Recupero della sottorete predefinita non riuscito", "setupFailedToFetchSubnet": "Recupero della sottorete predefinita non riuscito",
@@ -2845,10 +2856,10 @@
"httpDestAuthNoneTitle": "Nessuna Autenticazione", "httpDestAuthNoneTitle": "Nessuna Autenticazione",
"httpDestAuthNoneDescription": "Invia richieste senza intestazione autorizzazione.", "httpDestAuthNoneDescription": "Invia richieste senza intestazione autorizzazione.",
"httpDestAuthBearerTitle": "Token Del Portatore", "httpDestAuthBearerTitle": "Token Del Portatore",
"httpDestAuthBearerDescription": "Aggiunge un'intestazione Autorizzazione: Bearer <token> ad ogni richiesta.", "httpDestAuthBearerDescription": "Aggiunge un'intestazione Authorization: Bearer '<token>' a ogni richiesta.",
"httpDestAuthBearerPlaceholder": "La tua chiave API o token", "httpDestAuthBearerPlaceholder": "La tua chiave API o token",
"httpDestAuthBasicTitle": "Autenticazione Base", "httpDestAuthBasicTitle": "Autenticazione Base",
"httpDestAuthBasicDescription": "Aggiunge un'autorizzazione: intestazione di base <credentials> . Fornisce le credenziali come username:password.", "httpDestAuthBasicDescription": "Aggiunge un'intestazione Authorization: Basic '<credentials>'. Fornire le credenziali come username:password.",
"httpDestAuthBasicPlaceholder": "username:password", "httpDestAuthBasicPlaceholder": "username:password",
"httpDestAuthCustomTitle": "Intestazione Personalizzata", "httpDestAuthCustomTitle": "Intestazione Personalizzata",
"httpDestAuthCustomDescription": "Specifica un nome e un valore di intestazione HTTP personalizzati per l'autenticazione (ad esempio X-API-Key).", "httpDestAuthCustomDescription": "Specifica un nome e un valore di intestazione HTTP personalizzati per l'autenticazione (ad esempio X-API-Key).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "프로비저닝 키가 업데이트되었습니다", "provisioningKeysUpdated": "프로비저닝 키가 업데이트되었습니다",
"provisioningKeysUpdatedDescription": "변경 사항이 저장되었습니다.", "provisioningKeysUpdatedDescription": "변경 사항이 저장되었습니다.",
"provisioningKeysBannerTitle": "사이트 프로비저닝 키", "provisioningKeysBannerTitle": "사이트 프로비저닝 키",
"provisioningKeysBannerDescription": "프로비저닝 키를 생성하 Newt 커넥터와 함께 사용실행자동으로 사이트를 생성하세요 — 각 사이트마다 별도의 인증을 설정할 필요 없습니다.", "provisioningKeysBannerDescription": "프로비저닝 키를 생성하 Newt 커넥터와 함께 사용하여시작사이트를 자동 생성 - 각 사이트에 대한 별도 자격 증명이 필요 없습니다.",
"provisioningKeysBannerButtonText": "자세히 알아보기", "provisioningKeysBannerButtonText": "자세히 알아보기",
"pendingSitesBannerTitle": "대기중인 사이트", "pendingSitesBannerTitle": "대기중인 사이트",
"pendingSitesBannerDescription": "프로비저닝 키를 사용하여 연결하는 사이트 검토 대기 중입니다. 사이트가 활성화되어 리소스에 액세스하기 전에 각 사이트를 승인하세요.", "pendingSitesBannerDescription": "프로비저닝 키를 사용하여 연결 사이트 검토를 위해 여기에 표시됩니다.",
"pendingSitesBannerButtonText": "자세히 알아보기", "pendingSitesBannerButtonText": "자세히 알아보기",
"apiKeysSettings": "{apiKeyName} 설정", "apiKeysSettings": "{apiKeyName} 설정",
"userTitle": "모든 사용자 관리", "userTitle": "모든 사용자 관리",
@@ -405,6 +405,10 @@
"licenseErrorKeyActivate": "라이센스 키 활성화에 실패했습니다.", "licenseErrorKeyActivate": "라이센스 키 활성화에 실패했습니다.",
"licenseErrorKeyActivateDescription": "라이센스 키를 활성화하는 동안 오류가 발생했습니다", "licenseErrorKeyActivateDescription": "라이센스 키를 활성화하는 동안 오류가 발생했습니다",
"licenseAbout": "라이센스에 대한 정보", "licenseAbout": "라이센스에 대한 정보",
"licenseBannerTitle": "기업 라이선스 활성화",
"licenseBannerDescription": "자체 호스팅된 Pangolin 인스턴스에서 기업 기능을 잠금 해제하십시오. 라이선스 키를 구입하여 프리미엄 기능을 활성화하고 아래에 추가하십시오.",
"licenseBannerGetLicense": "라이선스 획득",
"licenseBannerViewDocs": "문서 보기",
"communityEdition": "커뮤니티 에디션", "communityEdition": "커뮤니티 에디션",
"licenseAboutDescription": "이것은 상업적 환경에서 Pangolin을 사용하는 비즈니스 및 기업 사용자용입니다. 개인 용도로 Pangolin을 사용하는 경우 이 섹션을 무시할 수 있습니다.", "licenseAboutDescription": "이것은 상업적 환경에서 Pangolin을 사용하는 비즈니스 및 기업 사용자용입니다. 개인 용도로 Pangolin을 사용하는 경우 이 섹션을 무시할 수 있습니다.",
"licenseKeyActivated": "라이센스 키가 활성화되었습니다", "licenseKeyActivated": "라이센스 키가 활성화되었습니다",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "유효한 포트 번호를 입력하세요.", "targetErrorInvalidPortDescription": "유효한 포트 번호를 입력하세요.",
"targetErrorNoSite": "선택된 사이트 없음", "targetErrorNoSite": "선택된 사이트 없음",
"targetErrorNoSiteDescription": "대상을 위해 사이트를 선택하세요.", "targetErrorNoSiteDescription": "대상을 위해 사이트를 선택하세요.",
"targetTargetsCleared": "대상이 제거됨",
"targetTargetsClearedDescription": "이 리소스에서 모든 대상이 제거되었습니다",
"targetCreated": "대상 생성", "targetCreated": "대상 생성",
"targetCreatedDescription": "대상이 성공적으로 생성되었습니다.", "targetCreatedDescription": "대상이 성공적으로 생성되었습니다.",
"targetErrorCreate": "대상 생성 실패", "targetErrorCreate": "대상 생성 실패",
@@ -2112,8 +2118,10 @@
"selectDomainForOrgAuthPage": "조직 인증 페이지에 대한 도메인을 선택하세요.", "selectDomainForOrgAuthPage": "조직 인증 페이지에 대한 도메인을 선택하세요.",
"domainPickerProvidedDomain": "제공된 도메인", "domainPickerProvidedDomain": "제공된 도메인",
"domainPickerFreeProvidedDomain": "무료 제공된 도메인", "domainPickerFreeProvidedDomain": "무료 제공된 도메인",
"domainPickerFreeDomainsPaidFeature": "제공된 도메인은 유료 기능입니다. 요금제에 도메인이 포함되도록 구독하세요. — 별도로 도메인을 준비할 필요 없습니다.",
"domainPickerVerified": "검증됨", "domainPickerVerified": "검증됨",
"domainPickerUnverified": "검증되지 않음", "domainPickerUnverified": "검증되지 않음",
"domainPickerManual": "수동",
"domainPickerInvalidSubdomainStructure": "이 하위 도메인은 잘못된 문자 또는 구조를 포함하고 있습니다. 저장 시 자동으로 정리됩니다.", "domainPickerInvalidSubdomainStructure": "이 하위 도메인은 잘못된 문자 또는 구조를 포함하고 있습니다. 저장 시 자동으로 정리됩니다.",
"domainPickerError": "오류", "domainPickerError": "오류",
"domainPickerErrorLoadDomains": "조직 도메인 로드 실패", "domainPickerErrorLoadDomains": "조직 도메인 로드 실패",
@@ -2346,7 +2354,7 @@
"description": "기업 기능, 50명의 사용자, 50개의 사이트, 우선 지원." "description": "기업 기능, 50명의 사용자, 50개의 사이트, 우선 지원."
} }
}, },
"personalUseOnly": "개인 사용 전용 (무료 라이— 체크아웃 없음)", "personalUseOnly": "개인용으로만 사용 (무료 라이- 결제 없음)",
"buttons": { "buttons": {
"continueToCheckout": "결제로 진행" "continueToCheckout": "결제로 진행"
}, },
@@ -2607,6 +2615,9 @@
"machineClients": "기계 클라이언트", "machineClients": "기계 클라이언트",
"install": "설치", "install": "설치",
"run": "실행", "run": "실행",
"envFile": "환경 파일",
"serviceFile": "서비스 파일",
"enableAndStart": "활성화 및 시작",
"clientNameDescription": "나중에 변경할 수 있는 클라이언트의 표시 이름입니다.", "clientNameDescription": "나중에 변경할 수 있는 클라이언트의 표시 이름입니다.",
"clientAddress": "클라이언트 주소(고급)", "clientAddress": "클라이언트 주소(고급)",
"setupFailedToFetchSubnet": "기본값 로드 실패", "setupFailedToFetchSubnet": "기본값 로드 실패",
@@ -2845,10 +2856,10 @@
"httpDestAuthNoneTitle": "인증 없음", "httpDestAuthNoneTitle": "인증 없음",
"httpDestAuthNoneDescription": "Authorization 헤더 없이 요청을 보냅니다.", "httpDestAuthNoneDescription": "Authorization 헤더 없이 요청을 보냅니다.",
"httpDestAuthBearerTitle": "Bearer 토큰", "httpDestAuthBearerTitle": "Bearer 토큰",
"httpDestAuthBearerDescription": "모든 요청에 Authorization: Bearer <token> 헤더를 추가합니다.", "httpDestAuthBearerDescription": " 요청에 Authorization: Bearer '<token>' 헤더를 추가합니다.",
"httpDestAuthBearerPlaceholder": "API 키 또는 토큰", "httpDestAuthBearerPlaceholder": "API 키 또는 토큰",
"httpDestAuthBasicTitle": "기본 인증", "httpDestAuthBasicTitle": "기본 인증",
"httpDestAuthBasicDescription": "Authorization: Basic <credentials> 헤더를 추가합니다. 자격 증명은 username:password 형식으로 제공하세요.", "httpDestAuthBasicDescription": "Authorization: Basic '<credentials>' 헤더를 추가합니다. 자격 증명은 사용자 이름:비밀번호로 제공합니다.",
"httpDestAuthBasicPlaceholder": "사용자 이름:비밀번호", "httpDestAuthBasicPlaceholder": "사용자 이름:비밀번호",
"httpDestAuthCustomTitle": "사용자 정의 헤더", "httpDestAuthCustomTitle": "사용자 정의 헤더",
"httpDestAuthCustomDescription": "인증을 위한 사용자 정의 HTTP 헤더 이름 및 값을 지정하세요 (예: X-API-Key).", "httpDestAuthCustomDescription": "인증을 위한 사용자 정의 HTTP 헤더 이름 및 값을 지정하세요 (예: X-API-Key).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "Foreslå nøkkel oppdatert", "provisioningKeysUpdated": "Foreslå nøkkel oppdatert",
"provisioningKeysUpdatedDescription": "Dine endringer er lagret.", "provisioningKeysUpdatedDescription": "Dine endringer er lagret.",
"provisioningKeysBannerTitle": "Sidens bestemmende nøkler", "provisioningKeysBannerTitle": "Sidens bestemmende nøkler",
"provisioningKeysBannerDescription": "Generer en foreløpig nøkkel og bruk den med Nyhetskontakten for å automatisk opprette sider ved første oppstart — trenger ikke å sette opp separat innloggingsinformasjon for hver side.", "provisioningKeysBannerDescription": "Generer en provisjonsnøkkel og bruk den med Newt-kontakten for automatisk opprettelse av nettsteder ved første oppstart - ingen behov for å sette opp separate legitimasjoner for hvert nettsted.",
"provisioningKeysBannerButtonText": "Lær mer", "provisioningKeysBannerButtonText": "Lær mer",
"pendingSitesBannerTitle": "Ventende nettsteder", "pendingSitesBannerTitle": "Ventende nettsteder",
"pendingSitesBannerDescription": "Nettsteder som kobler deg til ved hjelp av en bestemmelsestekst, vises her for gjennomgang. Godkjenn hvert nettsted før det blir aktivt og får tilgang til ressursene dine.", "pendingSitesBannerDescription": "Nettsteder som kobler seg til ved bruk av en provisjonsnøkkel vises her for vurdering.",
"pendingSitesBannerButtonText": "Lær mer", "pendingSitesBannerButtonText": "Lær mer",
"apiKeysSettings": "{apiKeyName} Innstillinger", "apiKeysSettings": "{apiKeyName} Innstillinger",
"userTitle": "Administrer alle brukere", "userTitle": "Administrer alle brukere",
@@ -405,6 +405,10 @@
"licenseErrorKeyActivate": "Aktivering av lisensnøkkel feilet", "licenseErrorKeyActivate": "Aktivering av lisensnøkkel feilet",
"licenseErrorKeyActivateDescription": "Det oppstod en feil under aktivering av lisensnøkkelen.", "licenseErrorKeyActivateDescription": "Det oppstod en feil under aktivering av lisensnøkkelen.",
"licenseAbout": "Om Lisensiering", "licenseAbout": "Om Lisensiering",
"licenseBannerTitle": "Aktiver din bedriftslisens",
"licenseBannerDescription": "Lås opp bedriftsfunksjoner for din egenvertede Pangolin-instans. Kjøp en lisensnøkkel for å aktivere premium-funksjoner og legg den inn nedenfor.",
"licenseBannerGetLicense": "Få en lisens",
"licenseBannerViewDocs": "Vis dokumentasjon",
"communityEdition": "Fellesskapsutgave", "communityEdition": "Fellesskapsutgave",
"licenseAboutDescription": "Dette er for bedrifts- og foretaksbrukere som bruker Pangolin i et kommersielt miljø. Hvis du bruker Pangolin til personlig bruk, kan du ignorere denne seksjonen.", "licenseAboutDescription": "Dette er for bedrifts- og foretaksbrukere som bruker Pangolin i et kommersielt miljø. Hvis du bruker Pangolin til personlig bruk, kan du ignorere denne seksjonen.",
"licenseKeyActivated": "Lisensnøkkel aktivert", "licenseKeyActivated": "Lisensnøkkel aktivert",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "Vennligst skriv inn et gyldig portnummer", "targetErrorInvalidPortDescription": "Vennligst skriv inn et gyldig portnummer",
"targetErrorNoSite": "Ingen nettsted valgt", "targetErrorNoSite": "Ingen nettsted valgt",
"targetErrorNoSiteDescription": "Velg et nettsted for målet", "targetErrorNoSiteDescription": "Velg et nettsted for målet",
"targetTargetsCleared": "Mål ryddet",
"targetTargetsClearedDescription": "Alle mål har blitt fjernet fra denne ressursen",
"targetCreated": "Mål opprettet", "targetCreated": "Mål opprettet",
"targetCreatedDescription": "Målet har blitt opprettet", "targetCreatedDescription": "Målet har blitt opprettet",
"targetErrorCreate": "Kunne ikke opprette målet", "targetErrorCreate": "Kunne ikke opprette målet",
@@ -2112,8 +2118,10 @@
"selectDomainForOrgAuthPage": "Velg et domene for organisasjonens autentiseringsside", "selectDomainForOrgAuthPage": "Velg et domene for organisasjonens autentiseringsside",
"domainPickerProvidedDomain": "Gitt domene", "domainPickerProvidedDomain": "Gitt domene",
"domainPickerFreeProvidedDomain": "Gratis oppgitt domene", "domainPickerFreeProvidedDomain": "Gratis oppgitt domene",
"domainPickerFreeDomainsPaidFeature": "Angitte domener er en betalingsfunksjon. Abonner for å få et domene inkludert i din plan ingen behov for å ta med ditt eget.",
"domainPickerVerified": "Bekreftet", "domainPickerVerified": "Bekreftet",
"domainPickerUnverified": "Uverifisert", "domainPickerUnverified": "Uverifisert",
"domainPickerManual": "Manuell",
"domainPickerInvalidSubdomainStructure": "Dette underdomenet inneholder ugyldige tegn eller struktur. Det vil automatisk bli utsatt når du lagrer.", "domainPickerInvalidSubdomainStructure": "Dette underdomenet inneholder ugyldige tegn eller struktur. Det vil automatisk bli utsatt når du lagrer.",
"domainPickerError": "Feil", "domainPickerError": "Feil",
"domainPickerErrorLoadDomains": "Kan ikke laste organisasjonens domener", "domainPickerErrorLoadDomains": "Kan ikke laste organisasjonens domener",
@@ -2346,7 +2354,7 @@
"description": "Enterprise features, 50 brukere, 50 nettsteder og prioritetsstøtte." "description": "Enterprise features, 50 brukere, 50 nettsteder og prioritetsstøtte."
} }
}, },
"personalUseOnly": "Kun personlig bruk (gratis lisens - ingen utsjekking)", "personalUseOnly": "Kun personlig bruk (gratis lisens - ingen kasse)",
"buttons": { "buttons": {
"continueToCheckout": "Fortsett til kassen" "continueToCheckout": "Fortsett til kassen"
}, },
@@ -2607,6 +2615,9 @@
"machineClients": "Maskinklienter", "machineClients": "Maskinklienter",
"install": "Installer", "install": "Installer",
"run": "Kjør", "run": "Kjør",
"envFile": "Miljøfil",
"serviceFile": "Tjenestefil",
"enableAndStart": "Aktiver og start",
"clientNameDescription": "Visningsnavnet til klienten som kan endres senere.", "clientNameDescription": "Visningsnavnet til klienten som kan endres senere.",
"clientAddress": "Klientadresse (avansert)", "clientAddress": "Klientadresse (avansert)",
"setupFailedToFetchSubnet": "Kunne ikke hente standard undernett", "setupFailedToFetchSubnet": "Kunne ikke hente standard undernett",
@@ -2845,10 +2856,10 @@
"httpDestAuthNoneTitle": "Ingen godkjenning", "httpDestAuthNoneTitle": "Ingen godkjenning",
"httpDestAuthNoneDescription": "Sender forespørsler uten autorisasjonsoverskrift.", "httpDestAuthNoneDescription": "Sender forespørsler uten autorisasjonsoverskrift.",
"httpDestAuthBearerTitle": "Bærer Symbol", "httpDestAuthBearerTitle": "Bærer Symbol",
"httpDestAuthBearerDescription": "Legger til en autorisasjon: Bearer <token> header til hver forespørsel.", "httpDestAuthBearerDescription": "Legger til en Autorisasjon: Bearer '<token>' header til hver forespørsel.",
"httpDestAuthBearerPlaceholder": "Din API-nøkkel eller token", "httpDestAuthBearerPlaceholder": "Din API-nøkkel eller token",
"httpDestAuthBasicTitle": "Standard Auth", "httpDestAuthBasicTitle": "Standard Auth",
"httpDestAuthBasicDescription": "Legger til en godkjenning: Grunnleggende <credentials> overskrift. Angi legitimasjon som brukernavn:passord.", "httpDestAuthBasicDescription": "Legger til en Autorisasjon: Basic '<credentials>' header. Gi legitimasjon som brukernavn:passord.",
"httpDestAuthBasicPlaceholder": "brukernavn:passord", "httpDestAuthBasicPlaceholder": "brukernavn:passord",
"httpDestAuthCustomTitle": "Egendefinert topptekst", "httpDestAuthCustomTitle": "Egendefinert topptekst",
"httpDestAuthCustomDescription": "Angi et egendefinert HTTP headers navn og verdi for autentisering (f.eks X-API-Key).", "httpDestAuthCustomDescription": "Angi et egendefinert HTTP headers navn og verdi for autentisering (f.eks X-API-Key).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "Provisie sleutel bijgewerkt", "provisioningKeysUpdated": "Provisie sleutel bijgewerkt",
"provisioningKeysUpdatedDescription": "Uw wijzigingen zijn opgeslagen.", "provisioningKeysUpdatedDescription": "Uw wijzigingen zijn opgeslagen.",
"provisioningKeysBannerTitle": "Bewerkingssleutels voor websites", "provisioningKeysBannerTitle": "Bewerkingssleutels voor websites",
"provisioningKeysBannerDescription": "Genereer een provisioning-sleutel en gebruik deze met de Newt-connector om automatisch sites aan te maken bij het opstarten van de eerste opstart- het is niet nodig om afzonderlijke inloggegevens in te stellen voor elke site.", "provisioningKeysBannerDescription": "Genereer een inrichtingssleutel en gebruik deze met de Newt-connector om automatisch sites te maken bij de eerste opstart - er is geen behoefte om aparte inloggegevens voor elke site in te stellen.",
"provisioningKeysBannerButtonText": "Meer informatie", "provisioningKeysBannerButtonText": "Meer informatie",
"pendingSitesBannerTitle": "Openstaande sites", "pendingSitesBannerTitle": "Openstaande sites",
"pendingSitesBannerDescription": "Sites die met elkaar verbinden met behulp van een provisioning-sleutel verschijnen hier voor beoordeling. Accepteer elke site voordat deze actief wordt en krijgt toegang tot uw bronnen.", "pendingSitesBannerDescription": "Sites die verbinding maken met een inrichtingssleutel verschijnen hier voor beoordeling.",
"pendingSitesBannerButtonText": "Meer informatie", "pendingSitesBannerButtonText": "Meer informatie",
"apiKeysSettings": "{apiKeyName} instellingen", "apiKeysSettings": "{apiKeyName} instellingen",
"userTitle": "Alle gebruikers beheren", "userTitle": "Alle gebruikers beheren",
@@ -405,6 +405,10 @@
"licenseErrorKeyActivate": "Licentiesleutel activeren mislukt", "licenseErrorKeyActivate": "Licentiesleutel activeren mislukt",
"licenseErrorKeyActivateDescription": "Er is een fout opgetreden tijdens het activeren van de licentiesleutel.", "licenseErrorKeyActivateDescription": "Er is een fout opgetreden tijdens het activeren van de licentiesleutel.",
"licenseAbout": "Over licenties", "licenseAbout": "Over licenties",
"licenseBannerTitle": "Activeer Uw Enterprise Licentie",
"licenseBannerDescription": "Ontgrendel enterprise-functies voor uw zelf-gehoste Pangolin-instantie. Koop een licentiesleutel om premium mogelijkheden te activeren, voeg deze vervolgens hieronder toe.",
"licenseBannerGetLicense": "Koop een Licentie",
"licenseBannerViewDocs": "Bekijk Documentatie",
"communityEdition": "Community editie", "communityEdition": "Community editie",
"licenseAboutDescription": "Dit geldt voor gebruikers van bedrijven en ondernemingen die Pangolin in gebruiken in een commerciële omgeving. Als u Pangolin gebruikt voor persoonlijk gebruik, kunt u dit gedeelte negeren.", "licenseAboutDescription": "Dit geldt voor gebruikers van bedrijven en ondernemingen die Pangolin in gebruiken in een commerciële omgeving. Als u Pangolin gebruikt voor persoonlijk gebruik, kunt u dit gedeelte negeren.",
"licenseKeyActivated": "Licentiesleutel geactiveerd", "licenseKeyActivated": "Licentiesleutel geactiveerd",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "Voer een geldig poortnummer in", "targetErrorInvalidPortDescription": "Voer een geldig poortnummer in",
"targetErrorNoSite": "Geen site geselecteerd", "targetErrorNoSite": "Geen site geselecteerd",
"targetErrorNoSiteDescription": "Selecteer een site voor het doel", "targetErrorNoSiteDescription": "Selecteer een site voor het doel",
"targetTargetsCleared": "Doelen gewist",
"targetTargetsClearedDescription": "Alle doelen zijn verwijderd van deze bron",
"targetCreated": "Doel aangemaakt", "targetCreated": "Doel aangemaakt",
"targetCreatedDescription": "Doel is succesvol aangemaakt", "targetCreatedDescription": "Doel is succesvol aangemaakt",
"targetErrorCreate": "Kan doel niet aanmaken", "targetErrorCreate": "Kan doel niet aanmaken",
@@ -2112,8 +2118,10 @@
"selectDomainForOrgAuthPage": "Selecteer een domein voor de authenticatiepagina van de organisatie", "selectDomainForOrgAuthPage": "Selecteer een domein voor de authenticatiepagina van de organisatie",
"domainPickerProvidedDomain": "Opgegeven domein", "domainPickerProvidedDomain": "Opgegeven domein",
"domainPickerFreeProvidedDomain": "Gratis verstrekt domein", "domainPickerFreeProvidedDomain": "Gratis verstrekt domein",
"domainPickerFreeDomainsPaidFeature": "Geleverde domeinen zijn een betaalde functie. Abonneer je om een domein bij je plan te krijgen — je hoeft er zelf geen mee te brengen.",
"domainPickerVerified": "Geverifieerd", "domainPickerVerified": "Geverifieerd",
"domainPickerUnverified": "Ongeverifieerd", "domainPickerUnverified": "Ongeverifieerd",
"domainPickerManual": "Handleiding",
"domainPickerInvalidSubdomainStructure": "Dit subdomein bevat ongeldige tekens of structuur. Het zal automatisch worden gesaneerd wanneer u opslaat.", "domainPickerInvalidSubdomainStructure": "Dit subdomein bevat ongeldige tekens of structuur. Het zal automatisch worden gesaneerd wanneer u opslaat.",
"domainPickerError": "Foutmelding", "domainPickerError": "Foutmelding",
"domainPickerErrorLoadDomains": "Fout bij het laden van organisatiedomeinen", "domainPickerErrorLoadDomains": "Fout bij het laden van organisatiedomeinen",
@@ -2346,7 +2354,7 @@
"description": "Enterprise functies, 50 gebruikers, 50 sites en prioriteit ondersteuning." "description": "Enterprise functies, 50 gebruikers, 50 sites en prioriteit ondersteuning."
} }
}, },
"personalUseOnly": "Alleen persoonlijk gebruik (gratis licentie - geen afrekenen)", "personalUseOnly": "Alleen voor persoonlijk gebruik (gratis licentie - geen afrekening)",
"buttons": { "buttons": {
"continueToCheckout": "Doorgaan naar afrekenen" "continueToCheckout": "Doorgaan naar afrekenen"
}, },
@@ -2607,6 +2615,9 @@
"machineClients": "Machine Clienten", "machineClients": "Machine Clienten",
"install": "Installeren", "install": "Installeren",
"run": "Uitvoeren", "run": "Uitvoeren",
"envFile": "Omgevingsbestand",
"serviceFile": "Servicebestand",
"enableAndStart": "Inschakelen en Starten",
"clientNameDescription": "De weergavenaam van de client die later gewijzigd kan worden.", "clientNameDescription": "De weergavenaam van de client die later gewijzigd kan worden.",
"clientAddress": "Klant adres (Geavanceerd)", "clientAddress": "Klant adres (Geavanceerd)",
"setupFailedToFetchSubnet": "Kan standaard subnet niet ophalen", "setupFailedToFetchSubnet": "Kan standaard subnet niet ophalen",
@@ -2845,10 +2856,10 @@
"httpDestAuthNoneTitle": "Geen authenticatie", "httpDestAuthNoneTitle": "Geen authenticatie",
"httpDestAuthNoneDescription": "Stuurt verzoeken zonder toestemmingskop.", "httpDestAuthNoneDescription": "Stuurt verzoeken zonder toestemmingskop.",
"httpDestAuthBearerTitle": "Betere Token", "httpDestAuthBearerTitle": "Betere Token",
"httpDestAuthBearerDescription": "Voegt een machtiging toe: Drager <token> header aan elke aanvraag.", "httpDestAuthBearerDescription": "Voegt een Authorization: Bearer '<token>' header toe aan elk verzoek.",
"httpDestAuthBearerPlaceholder": "Uw API-sleutel of -token", "httpDestAuthBearerPlaceholder": "Uw API-sleutel of -token",
"httpDestAuthBasicTitle": "Basis authenticatie", "httpDestAuthBasicTitle": "Basis authenticatie",
"httpDestAuthBasicDescription": "Voegt een Authorizatie toe: Basis <credentials> kop. Geef inloggegevens op als gebruikersnaam:wachtwoord.", "httpDestAuthBasicDescription": "Voegt een Authorization: Basic '<credentials>' header toe. Verstrek inloggegevens als gebruikersnaam:wachtwoord.",
"httpDestAuthBasicPlaceholder": "Gebruikersnaam:wachtwoord", "httpDestAuthBasicPlaceholder": "Gebruikersnaam:wachtwoord",
"httpDestAuthCustomTitle": "Aangepaste koptekst", "httpDestAuthCustomTitle": "Aangepaste koptekst",
"httpDestAuthCustomDescription": "Specificeer een aangepaste HTTP header naam en waarde voor authenticatie (bijv. X-API-Key).", "httpDestAuthCustomDescription": "Specificeer een aangepaste HTTP header naam en waarde voor authenticatie (bijv. X-API-Key).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "Klucz zaopatrzenia zaktualizowany", "provisioningKeysUpdated": "Klucz zaopatrzenia zaktualizowany",
"provisioningKeysUpdatedDescription": "Twoje zmiany zostały zapisane.", "provisioningKeysUpdatedDescription": "Twoje zmiany zostały zapisane.",
"provisioningKeysBannerTitle": "Klucze Zaopatrzenia witryny", "provisioningKeysBannerTitle": "Klucze Zaopatrzenia witryny",
"provisioningKeysBannerDescription": "Wygeneruj klucz tworzenia rezerw i użyj go z konektorem Newt do automatycznego tworzenia witryn przy pierwszym uruchomieniu nie ma potrzeby ustawiania oddzielnych poświadczeń dla każdej witryny.", "provisioningKeysBannerDescription": "Wygeneruj klucz provisioning i użyj go z konektorem Newt do automatycznego tworzenia witryn przy pierwszym uruchomieniu - nie ma potrzeby konfigurowania oddzielnych poświadczeń dla każdej witryny.",
"provisioningKeysBannerButtonText": "Dowiedz się więcej", "provisioningKeysBannerButtonText": "Dowiedz się więcej",
"pendingSitesBannerTitle": "Witryny oczekujące", "pendingSitesBannerTitle": "Witryny oczekujące",
"pendingSitesBannerDescription": "Witryny, które łączą się przy użyciu klucza zaopatrzenia, pojawiają się tutaj, aby przejrzeć. Zatwierdź każdą witrynę, zanim stanie się aktywna i uzyska dostęp do twoich zasobów.", "pendingSitesBannerDescription": "Witryny, które łączą się za pomocą klucza provisioning, pojawią się tutaj do przeglądu.",
"pendingSitesBannerButtonText": "Dowiedz się więcej", "pendingSitesBannerButtonText": "Dowiedz się więcej",
"apiKeysSettings": "Ustawienia {apiKeyName}", "apiKeysSettings": "Ustawienia {apiKeyName}",
"userTitle": "Zarządzaj wszystkimi użytkownikami", "userTitle": "Zarządzaj wszystkimi użytkownikami",
@@ -405,6 +405,10 @@
"licenseErrorKeyActivate": "Nie udało się aktywować klucza licencji", "licenseErrorKeyActivate": "Nie udało się aktywować klucza licencji",
"licenseErrorKeyActivateDescription": "Wystąpił błąd podczas aktywacji klucza licencyjnego.", "licenseErrorKeyActivateDescription": "Wystąpił błąd podczas aktywacji klucza licencyjnego.",
"licenseAbout": "O licencjonowaniu", "licenseAbout": "O licencjonowaniu",
"licenseBannerTitle": "Aktywuj swoją licencję Enterprise",
"licenseBannerDescription": "Odblokuj funkcje korporacyjne dla swojego autonomicznego wdrożenia Pangolin. Kup klucz licencyjny, aby aktywować możliwości premium, a następnie wprowadź go poniżej.",
"licenseBannerGetLicense": "Uzyskaj licencję",
"licenseBannerViewDocs": "Zobacz dokumentację",
"communityEdition": "Edycja Społecznościowa", "communityEdition": "Edycja Społecznościowa",
"licenseAboutDescription": "Dotyczy to przedsiębiorstw i przedsiębiorstw, którzy stosują Pangolin w środowisku handlowym. Jeśli używasz Pangolin do użytku osobistego, możesz zignorować tę sekcję.", "licenseAboutDescription": "Dotyczy to przedsiębiorstw i przedsiębiorstw, którzy stosują Pangolin w środowisku handlowym. Jeśli używasz Pangolin do użytku osobistego, możesz zignorować tę sekcję.",
"licenseKeyActivated": "Klucz licencyjny aktywowany", "licenseKeyActivated": "Klucz licencyjny aktywowany",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "Wprowadź prawidłowy numer portu", "targetErrorInvalidPortDescription": "Wprowadź prawidłowy numer portu",
"targetErrorNoSite": "Nie wybrano witryny", "targetErrorNoSite": "Nie wybrano witryny",
"targetErrorNoSiteDescription": "Wybierz witrynę docelową", "targetErrorNoSiteDescription": "Wybierz witrynę docelową",
"targetTargetsCleared": "Cele wyczyszczone",
"targetTargetsClearedDescription": "Wszystkie cele zostały usunięte z tego zasobu",
"targetCreated": "Cel utworzony", "targetCreated": "Cel utworzony",
"targetCreatedDescription": "Cel został utworzony pomyślnie", "targetCreatedDescription": "Cel został utworzony pomyślnie",
"targetErrorCreate": "Nie udało się utworzyć celu", "targetErrorCreate": "Nie udało się utworzyć celu",
@@ -2112,8 +2118,10 @@
"selectDomainForOrgAuthPage": "Wybierz domenę dla strony uwierzytelniania organizacji", "selectDomainForOrgAuthPage": "Wybierz domenę dla strony uwierzytelniania organizacji",
"domainPickerProvidedDomain": "Dostarczona domena", "domainPickerProvidedDomain": "Dostarczona domena",
"domainPickerFreeProvidedDomain": "Darmowa oferowana domena", "domainPickerFreeProvidedDomain": "Darmowa oferowana domena",
"domainPickerFreeDomainsPaidFeature": "Dostarczane domeny to funkcja płatna. Subskrybuj, aby uzyskać domenę w ramach swojego planu — nie ma potrzeby przynoszenia własnej.",
"domainPickerVerified": "Zweryfikowano", "domainPickerVerified": "Zweryfikowano",
"domainPickerUnverified": "Niezweryfikowane", "domainPickerUnverified": "Niezweryfikowane",
"domainPickerManual": "Podręcznik",
"domainPickerInvalidSubdomainStructure": "Ta subdomena zawiera nieprawidłowe znaki lub strukturę. Zostanie ona automatycznie oczyszczona po zapisaniu.", "domainPickerInvalidSubdomainStructure": "Ta subdomena zawiera nieprawidłowe znaki lub strukturę. Zostanie ona automatycznie oczyszczona po zapisaniu.",
"domainPickerError": "Błąd", "domainPickerError": "Błąd",
"domainPickerErrorLoadDomains": "Nie udało się załadować domen organizacji", "domainPickerErrorLoadDomains": "Nie udało się załadować domen organizacji",
@@ -2346,7 +2354,7 @@
"description": "Cechy przedsiębiorstw, 50 użytkowników, 50 obiektów i wsparcie priorytetowe." "description": "Cechy przedsiębiorstw, 50 użytkowników, 50 obiektów i wsparcie priorytetowe."
} }
}, },
"personalUseOnly": "Wyłącznie do użytku osobistego (bezpłatna licencja brak zamówień)", "personalUseOnly": "Tylko do użytku osobistego (darmowa licencja - bez płatności)",
"buttons": { "buttons": {
"continueToCheckout": "Przejdź do zamówienia" "continueToCheckout": "Przejdź do zamówienia"
}, },
@@ -2607,6 +2615,9 @@
"machineClients": "Klienci maszyn", "machineClients": "Klienci maszyn",
"install": "Zainstaluj", "install": "Zainstaluj",
"run": "Uruchom", "run": "Uruchom",
"envFile": "Plik środowiska",
"serviceFile": "Plik serwisu",
"enableAndStart": "Włącz i Uruchom",
"clientNameDescription": "Wyświetlana nazwa klienta, która może zostać zmieniona później.", "clientNameDescription": "Wyświetlana nazwa klienta, która może zostać zmieniona później.",
"clientAddress": "Adres klienta (Zaawansowany)", "clientAddress": "Adres klienta (Zaawansowany)",
"setupFailedToFetchSubnet": "Nie udało się pobrać domyślnej podsieci", "setupFailedToFetchSubnet": "Nie udało się pobrać domyślnej podsieci",
@@ -2845,10 +2856,10 @@
"httpDestAuthNoneTitle": "Brak uwierzytelniania", "httpDestAuthNoneTitle": "Brak uwierzytelniania",
"httpDestAuthNoneDescription": "Wysyła żądania bez nagłówka autoryzacji.", "httpDestAuthNoneDescription": "Wysyła żądania bez nagłówka autoryzacji.",
"httpDestAuthBearerTitle": "Token Bearer", "httpDestAuthBearerTitle": "Token Bearer",
"httpDestAuthBearerDescription": "Dodaje autoryzację: nagłówek Bearer <token> do każdego żądania.", "httpDestAuthBearerDescription": "Dodaje nagłówek Authorization: Bearer '<token>' do każdego żądania.",
"httpDestAuthBearerPlaceholder": "Twój klucz API lub token", "httpDestAuthBearerPlaceholder": "Twój klucz API lub token",
"httpDestAuthBasicTitle": "Podstawowa Autoryzacja", "httpDestAuthBasicTitle": "Podstawowa Autoryzacja",
"httpDestAuthBasicDescription": "Dodaje Autoryzacja: Nagłówek Basic <credentials> . Podaj poświadczenia jako nazwę użytkownika: hasło.", "httpDestAuthBasicDescription": "Dodaje nagłówek Authorization: Basic '<credentials>'. Podaj poświadczenia w formacie użytkownik:hasło.",
"httpDestAuthBasicPlaceholder": "Nazwa użytkownika:hasło", "httpDestAuthBasicPlaceholder": "Nazwa użytkownika:hasło",
"httpDestAuthCustomTitle": "Niestandardowy nagłówek", "httpDestAuthCustomTitle": "Niestandardowy nagłówek",
"httpDestAuthCustomDescription": "Określ niestandardową nazwę nagłówka HTTP i wartość dla uwierzytelniania (np. X-API-Key).", "httpDestAuthCustomDescription": "Określ niestandardową nazwę nagłówka HTTP i wartość dla uwierzytelniania (np. X-API-Key).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "Chave de provisionamento atualizada", "provisioningKeysUpdated": "Chave de provisionamento atualizada",
"provisioningKeysUpdatedDescription": "Suas alterações foram salvas.", "provisioningKeysUpdatedDescription": "Suas alterações foram salvas.",
"provisioningKeysBannerTitle": "Chaves de provisionamento do site", "provisioningKeysBannerTitle": "Chaves de provisionamento do site",
"provisioningKeysBannerDescription": "Gerar uma chave de provisionamento e usá-la com o conector de Newt para criar automaticamente sites na primeira inicialização — não é necessário configurar credenciais separadas para cada site.", "provisioningKeysBannerDescription": "Gere uma chave de provisionamento e use-a com o conector Newt para criar sites automaticamente na primeira inicialização - sem necessidade de configurar credenciais separadas para cada site.",
"provisioningKeysBannerButtonText": "Saiba mais", "provisioningKeysBannerButtonText": "Saiba mais",
"pendingSitesBannerTitle": "Sites pendentes", "pendingSitesBannerTitle": "Sites pendentes",
"pendingSitesBannerDescription": "Sites que conectam usando uma chave de provisionamento aparecem aqui para revisão. Aprovar cada site antes de se tornar ativo e ganhar acesso a seus recursos.", "pendingSitesBannerDescription": "Sites que se conectam usando uma chave de provisionamento aparecem aqui para revisão.",
"pendingSitesBannerButtonText": "Saiba mais", "pendingSitesBannerButtonText": "Saiba mais",
"apiKeysSettings": "Configurações de {apiKeyName}", "apiKeysSettings": "Configurações de {apiKeyName}",
"userTitle": "Gerir Todos os Utilizadores", "userTitle": "Gerir Todos os Utilizadores",
@@ -405,6 +405,10 @@
"licenseErrorKeyActivate": "Falha ao ativar a chave de licença", "licenseErrorKeyActivate": "Falha ao ativar a chave de licença",
"licenseErrorKeyActivateDescription": "Ocorreu um erro ao ativar a chave da licença.", "licenseErrorKeyActivateDescription": "Ocorreu um erro ao ativar a chave da licença.",
"licenseAbout": "Sobre Licenciamento", "licenseAbout": "Sobre Licenciamento",
"licenseBannerTitle": "Ative Sua Licença Corporativa",
"licenseBannerDescription": "Desbloqueie recursos empresariais para sua instância de Pangolin autohospedada. Compre uma chave de licença para ativar recursos premium e adicione-a abaixo.",
"licenseBannerGetLicense": "Obter Licença",
"licenseBannerViewDocs": "Ver Documentação",
"communityEdition": "Edição da Comunidade", "communityEdition": "Edição da Comunidade",
"licenseAboutDescription": "Isto destina-se aos utilizadores empresariais e empresariais que estão a usar o Pangolin num ambiente comercial. Se você estiver usando o Pangolin para uso pessoal, você pode ignorar esta seção.", "licenseAboutDescription": "Isto destina-se aos utilizadores empresariais e empresariais que estão a usar o Pangolin num ambiente comercial. Se você estiver usando o Pangolin para uso pessoal, você pode ignorar esta seção.",
"licenseKeyActivated": "Chave de licença ativada", "licenseKeyActivated": "Chave de licença ativada",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "Por favor, digite um número de porta válido", "targetErrorInvalidPortDescription": "Por favor, digite um número de porta válido",
"targetErrorNoSite": "Nenhum site selecionado", "targetErrorNoSite": "Nenhum site selecionado",
"targetErrorNoSiteDescription": "Selecione um site para o destino", "targetErrorNoSiteDescription": "Selecione um site para o destino",
"targetTargetsCleared": "Alvos limpos",
"targetTargetsClearedDescription": "Todos os alvos foram removidos deste recurso",
"targetCreated": "Destino criado", "targetCreated": "Destino criado",
"targetCreatedDescription": "O alvo foi criado com sucesso", "targetCreatedDescription": "O alvo foi criado com sucesso",
"targetErrorCreate": "Falha ao criar destino", "targetErrorCreate": "Falha ao criar destino",
@@ -2112,8 +2118,10 @@
"selectDomainForOrgAuthPage": "Selecione um domínio para a página de autenticação da organização", "selectDomainForOrgAuthPage": "Selecione um domínio para a página de autenticação da organização",
"domainPickerProvidedDomain": "Domínio fornecido", "domainPickerProvidedDomain": "Domínio fornecido",
"domainPickerFreeProvidedDomain": "Domínio fornecido grátis", "domainPickerFreeProvidedDomain": "Domínio fornecido grátis",
"domainPickerFreeDomainsPaidFeature": "Os domínios fornecidos são um recurso pago. Assine para obter um domínio incluído no seu plano — não há necessidade de trazer o seu próprio.",
"domainPickerVerified": "Verificada", "domainPickerVerified": "Verificada",
"domainPickerUnverified": "Não verificado", "domainPickerUnverified": "Não verificado",
"domainPickerManual": "Manual",
"domainPickerInvalidSubdomainStructure": "Este subdomínio contém caracteres ou estrutura inválidos. Ele será eliminado automaticamente quando você salvar.", "domainPickerInvalidSubdomainStructure": "Este subdomínio contém caracteres ou estrutura inválidos. Ele será eliminado automaticamente quando você salvar.",
"domainPickerError": "ERRO", "domainPickerError": "ERRO",
"domainPickerErrorLoadDomains": "Falha ao carregar domínios da organização", "domainPickerErrorLoadDomains": "Falha ao carregar domínios da organização",
@@ -2346,7 +2354,7 @@
"description": "Recursos de empresa, 50 usuários, 50 sites e apoio prioritário." "description": "Recursos de empresa, 50 usuários, 50 sites e apoio prioritário."
} }
}, },
"personalUseOnly": "Apenas uso pessoal (licença gratuita sem check-out)", "personalUseOnly": "Uso pessoal apenas (licença gratuita - sem checkout)",
"buttons": { "buttons": {
"continueToCheckout": "Continuar com checkout" "continueToCheckout": "Continuar com checkout"
}, },
@@ -2607,6 +2615,9 @@
"machineClients": "Clientes de máquina", "machineClients": "Clientes de máquina",
"install": "Instale", "install": "Instale",
"run": "Executar", "run": "Executar",
"envFile": "Arquivo de Ambiente",
"serviceFile": "Arquivo de Serviço",
"enableAndStart": "Ativar e Iniciar",
"clientNameDescription": "O nome de exibição do cliente que pode ser alterado mais tarde.", "clientNameDescription": "O nome de exibição do cliente que pode ser alterado mais tarde.",
"clientAddress": "Endereço do Cliente (Avançado)", "clientAddress": "Endereço do Cliente (Avançado)",
"setupFailedToFetchSubnet": "Falha ao buscar a subrede padrão", "setupFailedToFetchSubnet": "Falha ao buscar a subrede padrão",
@@ -2845,10 +2856,10 @@
"httpDestAuthNoneTitle": "Sem Autenticação", "httpDestAuthNoneTitle": "Sem Autenticação",
"httpDestAuthNoneDescription": "Envia pedidos sem um cabeçalho de autorização.", "httpDestAuthNoneDescription": "Envia pedidos sem um cabeçalho de autorização.",
"httpDestAuthBearerTitle": "Token do portador", "httpDestAuthBearerTitle": "Token do portador",
"httpDestAuthBearerDescription": "Adiciona uma autorização: Bearer <token> header a cada requisição.", "httpDestAuthBearerDescription": "Adiciona um cabeçalho Authorization: Bearer '<token>' a cada solicitação.",
"httpDestAuthBearerPlaceholder": "Sua chave de API ou token", "httpDestAuthBearerPlaceholder": "Sua chave de API ou token",
"httpDestAuthBasicTitle": "Autenticação básica", "httpDestAuthBasicTitle": "Autenticação básica",
"httpDestAuthBasicDescription": "Adiciona uma Autorização: cabeçalho <credentials> básico. Forneça credenciais como nome de usuário:senha.", "httpDestAuthBasicDescription": "Adiciona um cabeçalho Authorization: Basic '<credentials>'. Forneça as credenciais como username:password.",
"httpDestAuthBasicPlaceholder": "Usuário:password", "httpDestAuthBasicPlaceholder": "Usuário:password",
"httpDestAuthCustomTitle": "Cabeçalho personalizado", "httpDestAuthCustomTitle": "Cabeçalho personalizado",
"httpDestAuthCustomDescription": "Especifique um nome e valor de cabeçalho HTTP personalizado para autenticação (por exemplo, X-API-Key).", "httpDestAuthCustomDescription": "Especifique um nome e valor de cabeçalho HTTP personalizado para autenticação (por exemplo, X-API-Key).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "Ключ подготовки обновлен", "provisioningKeysUpdated": "Ключ подготовки обновлен",
"provisioningKeysUpdatedDescription": "Ваши изменения были сохранены.", "provisioningKeysUpdatedDescription": "Ваши изменения были сохранены.",
"provisioningKeysBannerTitle": "Ключи подготовки сайта", "provisioningKeysBannerTitle": "Ключи подготовки сайта",
"provisioningKeysBannerDescription": "Генерировать подготовительный ключ и использовать его вместе с Новым коннектором для автоматического создания сайтов при первом запуске — нет необходимости настраивать отдельные учетные данные для каждого сайта.", "provisioningKeysBannerDescription": "Создайте ключ настройки и используйте его с соединителем Newt для автоматического создания сайтов при первом запуске — нет необходимости настраивать отдельные учетные данные для каждого сайта.",
"provisioningKeysBannerButtonText": "Узнать больше", "provisioningKeysBannerButtonText": "Узнать больше",
"pendingSitesBannerTitle": "Ожидающие сайты", "pendingSitesBannerTitle": "Ожидающие сайты",
"pendingSitesBannerDescription": "Сайты, связанные с использованием ключа подготовки, появляются здесь для проверки. Одобрите каждый сайт, прежде чем он станет активным и получит доступ к вашим ресурсам.", "pendingSitesBannerDescription": "Сайты, подключающиеся с помощью ключа настройки, отображаются здесь для проверки.",
"pendingSitesBannerButtonText": "Узнать больше", "pendingSitesBannerButtonText": "Узнать больше",
"apiKeysSettings": "Настройки {apiKeyName}", "apiKeysSettings": "Настройки {apiKeyName}",
"userTitle": "Управление всеми пользователями", "userTitle": "Управление всеми пользователями",
@@ -405,6 +405,10 @@
"licenseErrorKeyActivate": "Не удалось активировать лицензионный ключ", "licenseErrorKeyActivate": "Не удалось активировать лицензионный ключ",
"licenseErrorKeyActivateDescription": "Произошла ошибка при активации лицензионного ключа.", "licenseErrorKeyActivateDescription": "Произошла ошибка при активации лицензионного ключа.",
"licenseAbout": "О лицензировании", "licenseAbout": "О лицензировании",
"licenseBannerTitle": "Активируйте вашу корпоративную лицензию",
"licenseBannerDescription": "Откройте доступ к корпоративным функциям для вашей локально размещаемой версии Pangolin. Приобретите лицензионный ключ, чтобы активировать премиум-функции, затем добавьте его ниже.",
"licenseBannerGetLicense": "Получить лицензию",
"licenseBannerViewDocs": "Посмотреть документацию",
"communityEdition": "Community Edition", "communityEdition": "Community Edition",
"licenseAboutDescription": "Это для бизнес и корпоративных пользователей, использующих Pangolin в коммерческой среде. Если вы используете Pangolin для личного использования, вы можете игнорировать этот раздел.", "licenseAboutDescription": "Это для бизнес и корпоративных пользователей, использующих Pangolin в коммерческой среде. Если вы используете Pangolin для личного использования, вы можете игнорировать этот раздел.",
"licenseKeyActivated": "Лицензионный ключ активирован", "licenseKeyActivated": "Лицензионный ключ активирован",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "Пожалуйста, введите правильный номер порта", "targetErrorInvalidPortDescription": "Пожалуйста, введите правильный номер порта",
"targetErrorNoSite": "Сайт не выбран", "targetErrorNoSite": "Сайт не выбран",
"targetErrorNoSiteDescription": "Пожалуйста, выберите сайт для цели", "targetErrorNoSiteDescription": "Пожалуйста, выберите сайт для цели",
"targetTargetsCleared": "Цели очищены",
"targetTargetsClearedDescription": "Все цели удалены из этого ресурса",
"targetCreated": "Цель создана", "targetCreated": "Цель создана",
"targetCreatedDescription": "Цель была успешно создана", "targetCreatedDescription": "Цель была успешно создана",
"targetErrorCreate": "Не удалось создать цель", "targetErrorCreate": "Не удалось создать цель",
@@ -2112,8 +2118,10 @@
"selectDomainForOrgAuthPage": "Выберите домен для страницы аутентификации организации", "selectDomainForOrgAuthPage": "Выберите домен для страницы аутентификации организации",
"domainPickerProvidedDomain": "Домен предоставлен", "domainPickerProvidedDomain": "Домен предоставлен",
"domainPickerFreeProvidedDomain": "Бесплатный домен", "domainPickerFreeProvidedDomain": "Бесплатный домен",
"domainPickerFreeDomainsPaidFeature": "Предоставленные домены являются платной функцией. Подпишитесь, чтобы получить домен, включенный в ваш план — не нужно приносить свой собственный.",
"domainPickerVerified": "Подтверждено", "domainPickerVerified": "Подтверждено",
"domainPickerUnverified": "Не подтверждено", "domainPickerUnverified": "Не подтверждено",
"domainPickerManual": "Ручной",
"domainPickerInvalidSubdomainStructure": "Этот поддомен содержит недопустимые символы или структуру. Он будет очищен автоматически при сохранении.", "domainPickerInvalidSubdomainStructure": "Этот поддомен содержит недопустимые символы или структуру. Он будет очищен автоматически при сохранении.",
"domainPickerError": "Ошибка", "domainPickerError": "Ошибка",
"domainPickerErrorLoadDomains": "Не удалось загрузить домены организации", "domainPickerErrorLoadDomains": "Не удалось загрузить домены организации",
@@ -2346,7 +2354,7 @@
"description": "Функции предприятия, 50 пользователей, 50 сайтов, а также приоритетная поддержка." "description": "Функции предприятия, 50 пользователей, 50 сайтов, а также приоритетная поддержка."
} }
}, },
"personalUseOnly": "Только для личного пользования (бесплатная лицензия без оформления)", "personalUseOnly": "Только для личного использования (бесплатная лицензия - без оформления на кассе)",
"buttons": { "buttons": {
"continueToCheckout": "Продолжить оформление заказа" "continueToCheckout": "Продолжить оформление заказа"
}, },
@@ -2607,6 +2615,9 @@
"machineClients": "Машинные клиенты", "machineClients": "Машинные клиенты",
"install": "Установить", "install": "Установить",
"run": "Запустить", "run": "Запустить",
"envFile": "Файл окружения",
"serviceFile": "Сервисный файл",
"enableAndStart": "Включить и запустить",
"clientNameDescription": "Отображаемое имя клиента, которое может быть изменено позже.", "clientNameDescription": "Отображаемое имя клиента, которое может быть изменено позже.",
"clientAddress": "Адрес клиента (Дополнительно)", "clientAddress": "Адрес клиента (Дополнительно)",
"setupFailedToFetchSubnet": "Не удалось получить подсеть по умолчанию", "setupFailedToFetchSubnet": "Не удалось получить подсеть по умолчанию",
@@ -2845,10 +2856,10 @@
"httpDestAuthNoneTitle": "Нет аутентификации", "httpDestAuthNoneTitle": "Нет аутентификации",
"httpDestAuthNoneDescription": "Отправляет запросы без заголовка авторизации.", "httpDestAuthNoneDescription": "Отправляет запросы без заголовка авторизации.",
"httpDestAuthBearerTitle": "Жетон носителя", "httpDestAuthBearerTitle": "Жетон носителя",
"httpDestAuthBearerDescription": "Добавляет заголовок Authorization: Bearer <token> к каждому запросу.", "httpDestAuthBearerDescription": "Добавляет заголовок Authorization: Bearer '<token>' к каждому запросу.",
"httpDestAuthBearerPlaceholder": "Ваш ключ API или токен", "httpDestAuthBearerPlaceholder": "Ваш ключ API или токен",
"httpDestAuthBasicTitle": "Базовая авторизация", "httpDestAuthBasicTitle": "Базовая авторизация",
"httpDestAuthBasicDescription": "Добавляет Authorization: Basic <credentials> header. Предоставьте учетные данные в качестве имени пользователя:password.", "httpDestAuthBasicDescription": "Добавляет заголовок Authorization: Basic '<credentials>'. Укажите учетные данные в формате username:password.",
"httpDestAuthBasicPlaceholder": "имя пользователя:пароль", "httpDestAuthBasicPlaceholder": "имя пользователя:пароль",
"httpDestAuthCustomTitle": "Пользовательский заголовок", "httpDestAuthCustomTitle": "Пользовательский заголовок",
"httpDestAuthCustomDescription": "Укажите пользовательское имя заголовка HTTP и значение для аутентификации (например, X-API-Key).", "httpDestAuthCustomDescription": "Укажите пользовательское имя заголовка HTTP и значение для аутентификации (например, X-API-Key).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "Tedarik anahtarı güncellendi", "provisioningKeysUpdated": "Tedarik anahtarı güncellendi",
"provisioningKeysUpdatedDescription": "Değişiklikleriniz kaydedildi.", "provisioningKeysUpdatedDescription": "Değişiklikleriniz kaydedildi.",
"provisioningKeysBannerTitle": "Site Tedarik Anahtarları", "provisioningKeysBannerTitle": "Site Tedarik Anahtarları",
"provisioningKeysBannerDescription": "Tedarik anahtarı oluşturun ve ilk başlangıçta siteleri otomatik olarak oluşturmak için Newt konektörüyle kullanın her site için ayrı kimlik bilgileri ayarlamaya gerek yoktur.", "provisioningKeysBannerDescription": "Bir sağlama anahtarı oluşturun ve ilk başlangıçta siteleri otomatik olarak oluşturmak için Newt bağlayıcısını kullanın - her site için ayrı kimlik bilgileri ayarlamaya gerek yok.",
"provisioningKeysBannerButtonText": "Daha fazla bilgi", "provisioningKeysBannerButtonText": "Daha fazla bilgi",
"pendingSitesBannerTitle": "Bekleyen Siteler", "pendingSitesBannerTitle": "Bekleyen Siteler",
"pendingSitesBannerDescription": "Tedarik anahtarı kullanarak bağlanan siteler burada incelenmek için görünür. Aktif hale gelmeden ve kaynaklarınıza erişim kazanmadan önce her siteyi onaylayın.", "pendingSitesBannerDescription": "Bir sağlama anahtarı kullanarak bağlanan siteler, inceleme için burada görünür.",
"pendingSitesBannerButtonText": "Daha fazla bilgi", "pendingSitesBannerButtonText": "Daha fazla bilgi",
"apiKeysSettings": "{apiKeyName} Ayarları", "apiKeysSettings": "{apiKeyName} Ayarları",
"userTitle": "Tüm Kullanıcıları Yönet", "userTitle": "Tüm Kullanıcıları Yönet",
@@ -405,6 +405,10 @@
"licenseErrorKeyActivate": "Lisans anahtarı etkinleştirilemedi", "licenseErrorKeyActivate": "Lisans anahtarı etkinleştirilemedi",
"licenseErrorKeyActivateDescription": "Lisans anahtarı etkinleştirilirken bir hata oluştu.", "licenseErrorKeyActivateDescription": "Lisans anahtarı etkinleştirilirken bir hata oluştu.",
"licenseAbout": "Lisans Hakkında", "licenseAbout": "Lisans Hakkında",
"licenseBannerTitle": "Kurumsal Lisansınızı Etkinleştirin",
"licenseBannerDescription": "Kendi barındırdığınız Pangolin örneğiniz için kurumsal özelliklerin kilidini açın. Premium yetenekleri etkinleştirmek için bir lisans anahtarı satın alın, ardından aşağıya ekleyin.",
"licenseBannerGetLicense": "Lisans Alın",
"licenseBannerViewDocs": "Dokümantasyonu Görüntüleyin",
"communityEdition": "Topluluk Sürümü", "communityEdition": "Topluluk Sürümü",
"licenseAboutDescription": "Bu, Pangolin'i ticari bir ortamda kullanan işletme ve kurumsal kullanıcılar içindir. Pangolin'i kişisel kullanım için kullanıyorsanız, bu bölümü görmezden gelebilirsiniz.", "licenseAboutDescription": "Bu, Pangolin'i ticari bir ortamda kullanan işletme ve kurumsal kullanıcılar içindir. Pangolin'i kişisel kullanım için kullanıyorsanız, bu bölümü görmezden gelebilirsiniz.",
"licenseKeyActivated": "Lisans anahtarı etkinleştirildi", "licenseKeyActivated": "Lisans anahtarı etkinleştirildi",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "Lütfen geçerli bir port numarası girin", "targetErrorInvalidPortDescription": "Lütfen geçerli bir port numarası girin",
"targetErrorNoSite": "Hiçbir site seçili değil", "targetErrorNoSite": "Hiçbir site seçili değil",
"targetErrorNoSiteDescription": "Lütfen hedef için bir site seçin", "targetErrorNoSiteDescription": "Lütfen hedef için bir site seçin",
"targetTargetsCleared": "Hedefler temizlendi",
"targetTargetsClearedDescription": "Bu kaynaktan tüm hedefler kaldırıldı",
"targetCreated": "Hedef oluşturuldu", "targetCreated": "Hedef oluşturuldu",
"targetCreatedDescription": "Hedef başarıyla oluşturuldu", "targetCreatedDescription": "Hedef başarıyla oluşturuldu",
"targetErrorCreate": "Hedef oluşturma başarısız oldu", "targetErrorCreate": "Hedef oluşturma başarısız oldu",
@@ -2112,8 +2118,10 @@
"selectDomainForOrgAuthPage": "Kuruluşun kimlik doğrulama sayfası için bir alan seçin", "selectDomainForOrgAuthPage": "Kuruluşun kimlik doğrulama sayfası için bir alan seçin",
"domainPickerProvidedDomain": "Sağlanan Alan Adı", "domainPickerProvidedDomain": "Sağlanan Alan Adı",
"domainPickerFreeProvidedDomain": "Ücretsiz Sağlanan Alan Adı", "domainPickerFreeProvidedDomain": "Ücretsiz Sağlanan Alan Adı",
"domainPickerFreeDomainsPaidFeature": "Sağlanan alan adları ücretli bir özelliktir. Planınıza dahil bir alan adı almak için abone olun - kendi alan adınızı getirmenize gerek yok.",
"domainPickerVerified": "Doğrulandı", "domainPickerVerified": "Doğrulandı",
"domainPickerUnverified": "Doğrulanmadı", "domainPickerUnverified": "Doğrulanmadı",
"domainPickerManual": "Manuel",
"domainPickerInvalidSubdomainStructure": "Bu alt alan adı geçersiz karakterler veya yapı içeriyor. Kaydettiğinizde otomatik olarak temizlenecektir.", "domainPickerInvalidSubdomainStructure": "Bu alt alan adı geçersiz karakterler veya yapı içeriyor. Kaydettiğinizde otomatik olarak temizlenecektir.",
"domainPickerError": "Hata", "domainPickerError": "Hata",
"domainPickerErrorLoadDomains": "Organizasyon alan adları yüklenemedi", "domainPickerErrorLoadDomains": "Organizasyon alan adları yüklenemedi",
@@ -2346,7 +2354,7 @@
"description": "Kurumsal özellikler, 50 kullanıcı, 50 site ve öncelikli destek." "description": "Kurumsal özellikler, 50 kullanıcı, 50 site ve öncelikli destek."
} }
}, },
"personalUseOnly": "Yalnızca kişisel kullanım (ücretsiz lisans ödeme yapılmaz)", "personalUseOnly": "Kişisel kullanım için (ücretsiz lisans - ödeme yok)",
"buttons": { "buttons": {
"continueToCheckout": "Ödemeye Devam Et" "continueToCheckout": "Ödemeye Devam Et"
}, },
@@ -2607,6 +2615,9 @@
"machineClients": "Makine İstemcileri", "machineClients": "Makine İstemcileri",
"install": "Yükle", "install": "Yükle",
"run": "Çalıştır", "run": "Çalıştır",
"envFile": "Ortam Dosyası",
"serviceFile": "Servis Dosyası",
"enableAndStart": "Etkinleştir ve Başlat",
"clientNameDescription": "Daha sonra değiştirilebilecek istemcinin görünen adı.", "clientNameDescription": "Daha sonra değiştirilebilecek istemcinin görünen adı.",
"clientAddress": "İstemci Adresi (Gelişmiş)", "clientAddress": "İstemci Adresi (Gelişmiş)",
"setupFailedToFetchSubnet": "Varsayılan alt ağ alınamadı", "setupFailedToFetchSubnet": "Varsayılan alt ağ alınamadı",
@@ -2845,10 +2856,10 @@
"httpDestAuthNoneTitle": "Kimlik Doğrulama Yok", "httpDestAuthNoneTitle": "Kimlik Doğrulama Yok",
"httpDestAuthNoneDescription": "Yetkilendirme başlığı olmadan istekler gönderir.", "httpDestAuthNoneDescription": "Yetkilendirme başlığı olmadan istekler gönderir.",
"httpDestAuthBearerTitle": "Taşıyıcı Jetonu", "httpDestAuthBearerTitle": "Taşıyıcı Jetonu",
"httpDestAuthBearerDescription": "Her isteğe bir Yetkilendirme: Taşıyıcı <token> başlığı ekler.", "httpDestAuthBearerDescription": "Her isteğe bir Yetkilendirme: Taşıyıcı '<token>' üst bilgisi ekler.",
"httpDestAuthBearerPlaceholder": "API anahtarınız veya jetonunuz", "httpDestAuthBearerPlaceholder": "API anahtarınız veya jetonunuz",
"httpDestAuthBasicTitle": "Temel Kimlik Doğrulama", "httpDestAuthBasicTitle": "Temel Kimlik Doğrulama",
"httpDestAuthBasicDescription": "Authorization: Temel <belirtecikler> başlığı ekler. Yetkilendirmeleri kullanıcı adı:şifre olarak sağlayın.", "httpDestAuthBasicDescription": "Bir Yetkilendirme: Temel '<credentials>' üst bilgisi ekler. Kimlik bilgilerini kullanıcı adı:şifre olarak sağlayın.",
"httpDestAuthBasicPlaceholder": "kullanıcı adı:şifre", "httpDestAuthBasicPlaceholder": "kullanıcı adı:şifre",
"httpDestAuthCustomTitle": "Özel Başlık", "httpDestAuthCustomTitle": "Özel Başlık",
"httpDestAuthCustomDescription": "Kimlik doğrulama için özel bir HTTP başlık adı ve değer belirtin (örn. X-API-Key).", "httpDestAuthCustomDescription": "Kimlik doğrulama için özel bir HTTP başlık adı ve değer belirtin (örn. X-API-Key).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "置备密钥已更新", "provisioningKeysUpdated": "置备密钥已更新",
"provisioningKeysUpdatedDescription": "您的更改已保存。", "provisioningKeysUpdatedDescription": "您的更改已保存。",
"provisioningKeysBannerTitle": "站点置备密钥", "provisioningKeysBannerTitle": "站点置备密钥",
"provisioningKeysBannerDescription": "生成一个预配键并使用它来在首次启动时自动创建站点——无需为每个站点设置单独的凭。", "provisioningKeysBannerDescription": "生成一个供应密钥,并将其与 Newt 连接器一起使用,以在首次启动时自动创建站点 - 无需为每个站点设置单独的凭。",
"provisioningKeysBannerButtonText": "了解更多", "provisioningKeysBannerButtonText": "了解更多",
"pendingSitesBannerTitle": "待定站点", "pendingSitesBannerTitle": "待定站点",
"pendingSitesBannerDescription": "使用预配键连接的站点会出现在这里供审核。在站点开始运行之前批准并获取对您资源的访问权限。", "pendingSitesBannerDescription": "使用供应密钥连接的站点将在此显示以供审核。",
"pendingSitesBannerButtonText": "了解更多", "pendingSitesBannerButtonText": "了解更多",
"apiKeysSettings": "{apiKeyName} 设置", "apiKeysSettings": "{apiKeyName} 设置",
"userTitle": "管理所有用户", "userTitle": "管理所有用户",
@@ -405,6 +405,10 @@
"licenseErrorKeyActivate": "激活许可证密钥失败", "licenseErrorKeyActivate": "激活许可证密钥失败",
"licenseErrorKeyActivateDescription": "激活许可证密钥时出错。", "licenseErrorKeyActivateDescription": "激活许可证密钥时出错。",
"licenseAbout": "关于许可协议", "licenseAbout": "关于许可协议",
"licenseBannerTitle": "启用您的企业许可证",
"licenseBannerDescription": "为您自行托管的Pangolin实例解锁企业功能。购买许可证密钥以激活高级功能然后在下方添加。",
"licenseBannerGetLicense": "获取许可证",
"licenseBannerViewDocs": "查看文档",
"communityEdition": "社区版", "communityEdition": "社区版",
"licenseAboutDescription": "这是针对商业环境中使用Pangolin的商业和企业用户。 如果您正在使用 Pangolin 供个人使用,您可以忽略此部分。", "licenseAboutDescription": "这是针对商业环境中使用Pangolin的商业和企业用户。 如果您正在使用 Pangolin 供个人使用,您可以忽略此部分。",
"licenseKeyActivated": "授权密钥已激活", "licenseKeyActivated": "授权密钥已激活",
@@ -624,6 +628,8 @@
"targetErrorInvalidPortDescription": "请输入有效的端口号", "targetErrorInvalidPortDescription": "请输入有效的端口号",
"targetErrorNoSite": "没有选择站点", "targetErrorNoSite": "没有选择站点",
"targetErrorNoSiteDescription": "请选择目标站点", "targetErrorNoSiteDescription": "请选择目标站点",
"targetTargetsCleared": "目标已清除",
"targetTargetsClearedDescription": "所有目标已从此资源中移除",
"targetCreated": "目标已创建", "targetCreated": "目标已创建",
"targetCreatedDescription": "目标已成功创建", "targetCreatedDescription": "目标已成功创建",
"targetErrorCreate": "创建目标失败", "targetErrorCreate": "创建目标失败",
@@ -2112,8 +2118,10 @@
"selectDomainForOrgAuthPage": "选择组织认证页面的域", "selectDomainForOrgAuthPage": "选择组织认证页面的域",
"domainPickerProvidedDomain": "提供的域", "domainPickerProvidedDomain": "提供的域",
"domainPickerFreeProvidedDomain": "免费提供的域", "domainPickerFreeProvidedDomain": "免费提供的域",
"domainPickerFreeDomainsPaidFeature": "提供的域名是付费功能。订阅即可将域名包含在您的计划中—无需自带域名。",
"domainPickerVerified": "已验证", "domainPickerVerified": "已验证",
"domainPickerUnverified": "未验证", "domainPickerUnverified": "未验证",
"domainPickerManual": "手动",
"domainPickerInvalidSubdomainStructure": "此子域包含无效的字符或结构。当您保存时,它将被自动清除。", "domainPickerInvalidSubdomainStructure": "此子域包含无效的字符或结构。当您保存时,它将被自动清除。",
"domainPickerError": "错误", "domainPickerError": "错误",
"domainPickerErrorLoadDomains": "加载组织域名失败", "domainPickerErrorLoadDomains": "加载组织域名失败",
@@ -2346,7 +2354,7 @@
"description": "企业特征、50个用户、50个站点和优先支持。" "description": "企业特征、50个用户、50个站点和优先支持。"
} }
}, },
"personalUseOnly": "仅个人使用 (免费许可证-无签出)", "personalUseOnly": "仅个人使用免费许可 - 无需结账)",
"buttons": { "buttons": {
"continueToCheckout": "继续签出" "continueToCheckout": "继续签出"
}, },
@@ -2607,6 +2615,9 @@
"machineClients": "机器客户端", "machineClients": "机器客户端",
"install": "安装", "install": "安装",
"run": "运行", "run": "运行",
"envFile": "环境文件",
"serviceFile": "服务文件",
"enableAndStart": "启用并启动",
"clientNameDescription": "可以稍后更改的客户端的显示名称。", "clientNameDescription": "可以稍后更改的客户端的显示名称。",
"clientAddress": "客户端地址 (高级)", "clientAddress": "客户端地址 (高级)",
"setupFailedToFetchSubnet": "获取默认子网失败", "setupFailedToFetchSubnet": "获取默认子网失败",
@@ -2845,10 +2856,10 @@
"httpDestAuthNoneTitle": "无身份验证", "httpDestAuthNoneTitle": "无身份验证",
"httpDestAuthNoneDescription": "在没有授权头的情况下发送请求。", "httpDestAuthNoneDescription": "在没有授权头的情况下发送请求。",
"httpDestAuthBearerTitle": "持有者令牌", "httpDestAuthBearerTitle": "持有者令牌",
"httpDestAuthBearerDescription": "添加授权:每个请求的标题为 <token>。", "httpDestAuthBearerDescription": "在每个请求中添加授权Bearer “<token>” 头。",
"httpDestAuthBearerPlaceholder": "您的 API 密钥或令牌", "httpDestAuthBearerPlaceholder": "您的 API 密钥或令牌",
"httpDestAuthBasicTitle": "基本认证", "httpDestAuthBasicTitle": "基本认证",
"httpDestAuthBasicDescription": "添加授权:基本 <credentials> 头。提供用户名:密码凭据。", "httpDestAuthBasicDescription": "添加一个Authorization: Basic \"<凭据>\" 标头。 以用户名:密码形式提供凭据。",
"httpDestAuthBasicPlaceholder": "用户名:密码", "httpDestAuthBasicPlaceholder": "用户名:密码",
"httpDestAuthCustomTitle": "自定义标题", "httpDestAuthCustomTitle": "自定义标题",
"httpDestAuthCustomDescription": "指定自定义 HTTP 头名称和身份验证值 (例如X-API 键)。", "httpDestAuthCustomDescription": "指定自定义 HTTP 头名称和身份验证值 (例如X-API 键)。",

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

After

Width:  |  Height:  |  Size: 765 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

After

Width:  |  Height:  |  Size: 765 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 KiB

After

Width:  |  Height:  |  Size: 243 KiB

View File

@@ -57,7 +57,9 @@ export const orgs = pgTable("orgs", {
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull() .notNull()
.default(0), .default(0),
settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year settingsLogRetentionDaysConnection: integer(
"settingsLogRetentionDaysConnection"
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull() .notNull()
.default(0), .default(0),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
@@ -101,7 +103,9 @@ export const sites = pgTable("sites", {
lastHolePunch: bigint("lastHolePunch", { mode: "number" }), lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"), listenPort: integer("listenPort"),
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
status: varchar("status").$type<"pending" | "approved">().default("approved") status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
}); });
export const resources = pgTable("resources", { export const resources = pgTable("resources", {
@@ -222,16 +226,23 @@ export const exitNodes = pgTable("exitNodes", {
export const siteResources = pgTable("siteResources", { export const siteResources = pgTable("siteResources", {
// this is for the clients // this is for the clients
siteResourceId: serial("siteResourceId").primaryKey(), siteResourceId: serial("siteResourceId").primaryKey(),
siteId: integer("siteId")
.notNull()
.references(() => sites.siteId, { onDelete: "cascade" }),
orgId: varchar("orgId") orgId: varchar("orgId")
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
networkId: integer("networkId").references(() => networks.networkId, {
onDelete: "set null"
}),
defaultNetworkId: integer("defaultNetworkId").references(
() => networks.networkId,
{
onDelete: "restrict"
}
),
niceId: varchar("niceId").notNull(), niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" ssl: boolean("ssl").notNull().default(false),
protocol: varchar("protocol"), // only for port mode mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
@@ -244,7 +255,38 @@ export const siteResources = pgTable("siteResources", {
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
authDaemonMode: varchar("authDaemonMode", { length: 32 }) authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote">() .$type<"site" | "remote">()
.default("site") .default("site"),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
}),
subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain")
});
export const networks = pgTable("networks", {
networkId: serial("networkId").primaryKey(),
niceId: text("niceId"),
name: text("name"),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const siteNetworks = pgTable("siteNetworks", {
siteId: integer("siteId")
.notNull()
.references(() => sites.siteId, {
onDelete: "cascade"
}),
networkId: integer("networkId")
.notNull()
.references(() => networks.networkId, { onDelete: "cascade" })
}); });
export const clientSiteResources = pgTable("clientSiteResources", { export const clientSiteResources = pgTable("clientSiteResources", {
@@ -994,6 +1036,7 @@ export const requestAuditLog = pgTable(
actor: text("actor"), actor: text("actor"),
actorId: text("actorId"), actorId: text("actorId"),
resourceId: integer("resourceId"), resourceId: integer("resourceId"),
siteResourceId: integer("siteResourceId"),
ip: text("ip"), ip: text("ip"),
location: text("location"), location: text("location"),
userAgent: text("userAgent"), userAgent: text("userAgent"),
@@ -1106,3 +1149,4 @@ export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
export type RoundTripMessageTracker = InferSelectModel< export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker typeof roundTripMessageTracker
>; >;
export type Network = InferSelectModel<typeof networks>;

View File

@@ -54,7 +54,9 @@ export const orgs = sqliteTable("orgs", {
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull() .notNull()
.default(0), .default(0),
settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year settingsLogRetentionDaysConnection: integer(
"settingsLogRetentionDaysConnection"
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull() .notNull()
.default(0), .default(0),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
@@ -92,6 +94,9 @@ export const sites = sqliteTable("sites", {
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null" onDelete: "set null"
}), }),
networkId: integer("networkId").references(() => networks.networkId, {
onDelete: "set null"
}),
name: text("name").notNull(), name: text("name").notNull(),
pubKey: text("pubKey"), pubKey: text("pubKey"),
subnet: text("subnet"), subnet: text("subnet"),
@@ -250,16 +255,21 @@ export const siteResources = sqliteTable("siteResources", {
siteResourceId: integer("siteResourceId").primaryKey({ siteResourceId: integer("siteResourceId").primaryKey({
autoIncrement: true autoIncrement: true
}), }),
siteId: integer("siteId")
.notNull()
.references(() => sites.siteId, { onDelete: "cascade" }),
orgId: text("orgId") orgId: text("orgId")
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
networkId: integer("networkId").references(() => networks.networkId, {
onDelete: "set null"
}),
defaultNetworkId: integer("defaultNetworkId").references(
() => networks.networkId,
{ onDelete: "restrict" }
),
niceId: text("niceId").notNull(), niceId: text("niceId").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
protocol: text("protocol"), // only for port mode mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode
destination: text("destination").notNull(), // ip, cidr, hostname destination: text("destination").notNull(), // ip, cidr, hostname
@@ -274,7 +284,36 @@ export const siteResources = sqliteTable("siteResources", {
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
authDaemonMode: text("authDaemonMode") authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote">() .$type<"site" | "remote">()
.default("site") .default("site"),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "set null"
}),
subdomain: text("subdomain"),
fullDomain: text("fullDomain"),
});
export const networks = sqliteTable("networks", {
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
niceId: text("niceId"),
name: text("name"),
scope: text("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" })
});
export const siteNetworks = sqliteTable("siteNetworks", {
siteId: integer("siteId")
.notNull()
.references(() => sites.siteId, {
onDelete: "cascade"
}),
networkId: integer("networkId")
.notNull()
.references(() => networks.networkId, { onDelete: "cascade" })
}); });
export const clientSiteResources = sqliteTable("clientSiteResources", { export const clientSiteResources = sqliteTable("clientSiteResources", {
@@ -1096,6 +1135,7 @@ export const requestAuditLog = sqliteTable(
actor: text("actor"), actor: text("actor"),
actorId: text("actorId"), actorId: text("actorId"),
resourceId: integer("resourceId"), resourceId: integer("resourceId"),
siteResourceId: integer("siteResourceId"),
ip: text("ip"), ip: text("ip"),
location: text("location"), location: text("location"),
userAgent: text("userAgent"), userAgent: text("userAgent"),
@@ -1195,6 +1235,7 @@ export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>; export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>; export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
export type SiteResource = InferSelectModel<typeof siteResources>; export type SiteResource = InferSelectModel<typeof siteResources>;
export type Network = InferSelectModel<typeof networks>;
export type OrgDomains = InferSelectModel<typeof orgDomains>; export type OrgDomains = InferSelectModel<typeof orgDomains>;
export type SetupToken = InferSelectModel<typeof setupTokens>; export type SetupToken = InferSelectModel<typeof setupTokens>;
export type HostMeta = InferSelectModel<typeof hostMeta>; export type HostMeta = InferSelectModel<typeof hostMeta>;

View File

@@ -22,6 +22,7 @@ import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
import { initCleanup } from "#dynamic/cleanup"; import { initCleanup } from "#dynamic/cleanup";
import license from "#dynamic/license/license"; import license from "#dynamic/license/license";
import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
import { fetchServerIp } from "@server/lib/serverIpService"; import { fetchServerIp } from "@server/lib/serverIpService";
async function startServers() { async function startServers() {
@@ -39,6 +40,7 @@ async function startServers() {
initTelemetryClient(); initTelemetryClient();
initLogCleanupInterval(); initLogCleanupInterval();
initAcmeCertSync();
// Start all servers // Start all servers
const apiServer = createApiServer(); const apiServer = createApiServer();

View File

@@ -0,0 +1,3 @@
export function initAcmeCertSync(): void {
// stub
}

View File

@@ -19,7 +19,9 @@ export enum TierFeature {
SshPam = "sshPam", SshPam = "sshPam",
FullRbac = "fullRbac", FullRbac = "fullRbac",
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
SIEM = "siem" // handle downgrade by disabling SIEM integrations SIEM = "siem", // handle downgrade by disabling SIEM integrations
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
DomainNamespaces = "domainNamespaces" // handle downgrade by removing custom domain namespaces
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -56,5 +58,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"], [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"], [TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
[TierFeature.SIEM]: ["enterprise"] [TierFeature.SIEM]: ["enterprise"],
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"]
}; };

View File

@@ -121,8 +121,8 @@ export async function applyBlueprint({
for (const result of clientResourcesResults) { for (const result of clientResourcesResults) {
if ( if (
result.oldSiteResource && result.oldSiteResource &&
result.oldSiteResource.siteId != JSON.stringify(result.newSites?.sort()) !==
result.newSiteResource.siteId JSON.stringify(result.oldSites?.sort())
) { ) {
// query existing associations // query existing associations
const existingRoleIds = await trx const existingRoleIds = await trx
@@ -222,38 +222,46 @@ export async function applyBlueprint({
trx trx
); );
} else { } else {
const [newSite] = await trx let good = true;
.select() for (const newSite of result.newSites) {
.from(sites) const [site] = await trx
.innerJoin(newts, eq(sites.siteId, newts.siteId)) .select()
.where( .from(sites)
and( .innerJoin(newts, eq(sites.siteId, newts.siteId))
eq(sites.siteId, result.newSiteResource.siteId), .where(
eq(sites.orgId, orgId), and(
eq(sites.type, "newt"), eq(sites.siteId, newSite.siteId),
isNotNull(sites.pubKey) eq(sites.orgId, orgId),
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
)
) )
) .limit(1);
.limit(1);
if (!site) {
logger.debug(
`No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
);
good = false;
break;
}
if (!newSite) {
logger.debug( logger.debug(
`No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update` `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
); );
continue;
} }
logger.debug( if (!good) {
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}` continue;
); }
await handleMessagingForUpdatedSiteResource( await handleMessagingForUpdatedSiteResource(
result.oldSiteResource, result.oldSiteResource,
result.newSiteResource, result.newSiteResource,
{ result.newSites.map((site) => ({
siteId: newSite.sites.siteId, siteId: site.siteId,
orgId: newSite.sites.orgId orgId: result.newSiteResource.orgId
}, })),
trx trx
); );
} }

View File

@@ -1,24 +1,104 @@
import { import {
clients, clients,
clientSiteResources, clientSiteResources,
domains,
orgDomains,
roles, roles,
roleSiteResources, roleSiteResources,
Site,
SiteResource, SiteResource,
siteNetworks,
siteResources, siteResources,
Transaction, Transaction,
userOrgs, userOrgs,
users, users,
userSiteResources userSiteResources,
networks
} from "@server/db"; } from "@server/db";
import { sites } from "@server/db"; import { sites } from "@server/db";
import { eq, and, ne, inArray, or } from "drizzle-orm"; import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
import { Config } from "./types"; import { Config } from "./types";
import logger from "@server/logger"; import logger from "@server/logger";
import { getNextAvailableAliasAddress } from "../ip"; import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
async function getDomainForSiteResource(
siteResourceId: number | undefined,
fullDomain: string,
orgId: string,
trx: Transaction
): Promise<{ subdomain: string | null; domainId: string }> {
const [fullDomainExists] = await trx
.select({ siteResourceId: siteResources.siteResourceId })
.from(siteResources)
.where(
and(
eq(siteResources.fullDomain, fullDomain),
eq(siteResources.orgId, orgId),
siteResourceId
? ne(siteResources.siteResourceId, siteResourceId)
: isNotNull(siteResources.siteResourceId)
)
)
.limit(1);
if (fullDomainExists) {
throw new Error(
`Site resource already exists with domain: ${fullDomain} in org ${orgId}`
);
}
const possibleDomains = await trx
.select()
.from(domains)
.innerJoin(orgDomains, eq(domains.domainId, orgDomains.domainId))
.where(and(eq(orgDomains.orgId, orgId), eq(domains.verified, true)))
.execute();
if (possibleDomains.length === 0) {
throw new Error(
`Domain not found for full-domain: ${fullDomain} in org ${orgId}`
);
}
const validDomains = possibleDomains.filter((domain) => {
if (domain.domains.type == "ns" || domain.domains.type == "wildcard") {
return (
fullDomain === domain.domains.baseDomain ||
fullDomain.endsWith(`.${domain.domains.baseDomain}`)
);
} else if (domain.domains.type == "cname") {
return fullDomain === domain.domains.baseDomain;
}
});
if (validDomains.length === 0) {
throw new Error(
`Domain not found for full-domain: ${fullDomain} in org ${orgId}`
);
}
const domainSelection = validDomains[0].domains;
const baseDomain = domainSelection.baseDomain;
let subdomain: string | null = null;
if (fullDomain !== baseDomain) {
subdomain = fullDomain.replace(`.${baseDomain}`, "");
}
await createCertificate(domainSelection.domainId, fullDomain, trx);
return {
subdomain,
domainId: domainSelection.domainId
};
}
export type ClientResourcesResults = { export type ClientResourcesResults = {
newSiteResource: SiteResource; newSiteResource: SiteResource;
oldSiteResource?: SiteResource; oldSiteResource?: SiteResource;
newSites: { siteId: number }[];
oldSites: { siteId: number }[];
}[]; }[];
export async function updateClientResources( export async function updateClientResources(
@@ -43,53 +123,104 @@ export async function updateClientResources(
) )
.limit(1); .limit(1);
const resourceSiteId = resourceData.site; const existingSiteIds = existingResource?.networkId
let site; ? await trx
.select({ siteId: sites.siteId })
.from(siteNetworks)
.where(eq(siteNetworks.networkId, existingResource.networkId))
: [];
if (resourceSiteId) { let allSites: { siteId: number }[] = [];
// Look up site by niceId if (resourceData.site) {
[site] = await trx let siteSingle;
.select({ siteId: sites.siteId }) const resourceSiteId = resourceData.site;
.from(sites)
.where( if (resourceSiteId) {
and( // Look up site by niceId
eq(sites.niceId, resourceSiteId), [siteSingle] = await trx
eq(sites.orgId, orgId) .select({ siteId: sites.siteId })
.from(sites)
.where(
and(
eq(sites.niceId, resourceSiteId),
eq(sites.orgId, orgId)
)
) )
) .limit(1);
.limit(1); } else if (siteId) {
} else if (siteId) { // Use the provided siteId directly, but verify it belongs to the org
// Use the provided siteId directly, but verify it belongs to the org [siteSingle] = await trx
[site] = await trx .select({ siteId: sites.siteId })
.select({ siteId: sites.siteId }) .from(sites)
.from(sites) .where(
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
.limit(1); )
} else { .limit(1);
throw new Error(`Target site is required`); } else {
throw new Error(`Target site is required`);
}
if (!siteSingle) {
throw new Error(
`Site not found: ${resourceSiteId} in org ${orgId}`
);
}
allSites.push(siteSingle);
} }
if (!site) { if (resourceData.sites) {
throw new Error( for (const siteNiceId of resourceData.sites) {
`Site not found: ${resourceSiteId} in org ${orgId}` const [site] = await trx
); .select({ siteId: sites.siteId })
.from(sites)
.where(
and(
eq(sites.niceId, siteNiceId),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!site) {
throw new Error(
`Site not found: ${siteId} in org ${orgId}`
);
}
allSites.push(site);
}
} }
if (existingResource) { if (existingResource) {
let domainInfo:
| { subdomain: string | null; domainId: string }
| undefined;
if (resourceData["full-domain"] && resourceData.mode === "http") {
domainInfo = await getDomainForSiteResource(
existingResource.siteResourceId,
resourceData["full-domain"],
orgId,
trx
);
}
// Update existing resource // Update existing resource
const [updatedResource] = await trx const [updatedResource] = await trx
.update(siteResources) .update(siteResources)
.set({ .set({
name: resourceData.name || resourceNiceId, name: resourceData.name || resourceNiceId,
siteId: site.siteId,
mode: resourceData.mode, mode: resourceData.mode,
ssl: resourceData.ssl,
scheme: resourceData.scheme,
destination: resourceData.destination, destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
enabled: true, // hardcoded for now enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true, // enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null, alias: resourceData.alias || null,
disableIcmp: resourceData["disable-icmp"], disableIcmp: resourceData["disable-icmp"],
tcpPortRangeString: resourceData["tcp-ports"], tcpPortRangeString: resourceData["tcp-ports"],
udpPortRangeString: resourceData["udp-ports"] udpPortRangeString: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null
}) })
.where( .where(
eq( eq(
@@ -100,7 +231,21 @@ export async function updateClientResources(
.returning(); .returning();
const siteResourceId = existingResource.siteResourceId; const siteResourceId = existingResource.siteResourceId;
const orgId = existingResource.orgId;
if (updatedResource.networkId) {
await trx
.delete(siteNetworks)
.where(
eq(siteNetworks.networkId, updatedResource.networkId)
);
for (const site of allSites) {
await trx.insert(siteNetworks).values({
siteId: site.siteId,
networkId: updatedResource.networkId
});
}
}
await trx await trx
.delete(clientSiteResources) .delete(clientSiteResources)
@@ -204,37 +349,72 @@ export async function updateClientResources(
results.push({ results.push({
newSiteResource: updatedResource, newSiteResource: updatedResource,
oldSiteResource: existingResource oldSiteResource: existingResource,
newSites: allSites,
oldSites: existingSiteIds
}); });
} else { } else {
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
if (resourceData.mode == "host") { if (resourceData.mode === "host" || resourceData.mode === "http") {
// we can only have an alias on a host
aliasAddress = await getNextAvailableAliasAddress(orgId); aliasAddress = await getNextAvailableAliasAddress(orgId);
} }
let domainInfo:
| { subdomain: string | null; domainId: string }
| undefined;
if (resourceData["full-domain"] && resourceData.mode === "http") {
domainInfo = await getDomainForSiteResource(
undefined,
resourceData["full-domain"],
orgId,
trx
);
}
const [network] = await trx
.insert(networks)
.values({
scope: "resource",
orgId: orgId
})
.returning();
// Create new resource // Create new resource
const [newResource] = await trx const [newResource] = await trx
.insert(siteResources) .insert(siteResources)
.values({ .values({
orgId: orgId, orgId: orgId,
siteId: site.siteId,
niceId: resourceNiceId, niceId: resourceNiceId,
networkId: network.networkId,
defaultNetworkId: network.networkId,
name: resourceData.name || resourceNiceId, name: resourceData.name || resourceNiceId,
mode: resourceData.mode, mode: resourceData.mode,
ssl: resourceData.ssl,
scheme: resourceData.scheme,
destination: resourceData.destination, destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
enabled: true, // hardcoded for now enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true, // enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null, alias: resourceData.alias || null,
aliasAddress: aliasAddress, aliasAddress: aliasAddress,
disableIcmp: resourceData["disable-icmp"], disableIcmp: resourceData["disable-icmp"],
tcpPortRangeString: resourceData["tcp-ports"], tcpPortRangeString: resourceData["tcp-ports"],
udpPortRangeString: resourceData["udp-ports"] udpPortRangeString: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null
}) })
.returning(); .returning();
const siteResourceId = newResource.siteResourceId; const siteResourceId = newResource.siteResourceId;
for (const site of allSites) {
await trx.insert(siteNetworks).values({
siteId: site.siteId,
networkId: network.networkId
});
}
const [adminRole] = await trx const [adminRole] = await trx
.select() .select()
.from(roles) .from(roles)
@@ -324,7 +504,11 @@ export async function updateClientResources(
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}` `Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
); );
results.push({ newSiteResource: newResource }); results.push({
newSiteResource: newResource,
newSites: allSites,
oldSites: existingSiteIds
});
} }
} }

View File

@@ -1100,7 +1100,7 @@ function checkIfTargetChanged(
return false; return false;
} }
async function getDomain( export async function getDomain(
resourceId: number | undefined, resourceId: number | undefined,
fullDomain: string, fullDomain: string,
orgId: string, orgId: string,

View File

@@ -164,6 +164,7 @@ export const ResourceSchema = z
name: z.string().optional(), name: z.string().optional(),
protocol: z.enum(["http", "tcp", "udp"]).optional(), protocol: z.enum(["http", "tcp", "udp"]).optional(),
ssl: z.boolean().optional(), ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional(),
"full-domain": z.string().optional(), "full-domain": z.string().optional(),
"proxy-port": z.int().min(1).max(65535).optional(), "proxy-port": z.int().min(1).max(65535).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
@@ -325,16 +326,20 @@ export function isTargetsOnlyResource(resource: any): boolean {
export const ClientResourceSchema = z export const ClientResourceSchema = z
.object({ .object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr"]), mode: z.enum(["host", "cidr", "http"]),
site: z.string(), site: z.string(), // DEPRECATED IN FAVOR OF sites
sites: z.array(z.string()).optional().default([]),
// protocol: z.enum(["tcp", "udp"]).optional(), // protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
// destinationPort: z.int().positive().optional(), "destination-port": z.int().positive().optional(),
destination: z.string().min(1), destination: z.string().min(1),
// enabled: z.boolean().default(true), // enabled: z.boolean().default(true),
"tcp-ports": portRangeStringSchema.optional().default("*"), "tcp-ports": portRangeStringSchema.optional().default("*"),
"udp-ports": portRangeStringSchema.optional().default("*"), "udp-ports": portRangeStringSchema.optional().default("*"),
"disable-icmp": z.boolean().optional().default(false), "disable-icmp": z.boolean().optional().default(false),
"full-domain": z.string().optional(),
ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional().nullable(),
alias: z alias: z
.string() .string()
.regex( .regex(
@@ -477,6 +482,39 @@ export const ConfigSchema = z
}); });
} }
// Enforce the full-domain uniqueness across client-resources in the same stack
const clientFullDomainMap = new Map<string, string[]>();
Object.entries(config["client-resources"]).forEach(
([resourceKey, resource]) => {
const fullDomain = resource["full-domain"];
if (fullDomain) {
if (!clientFullDomainMap.has(fullDomain)) {
clientFullDomainMap.set(fullDomain, []);
}
clientFullDomainMap.get(fullDomain)!.push(resourceKey);
}
}
);
const clientFullDomainDuplicates = Array.from(
clientFullDomainMap.entries()
)
.filter(([_, resourceKeys]) => resourceKeys.length > 1)
.map(
([fullDomain, resourceKeys]) =>
`'${fullDomain}' used by resources: ${resourceKeys.join(", ")}`
)
.join("; ");
if (clientFullDomainDuplicates.length !== 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["client-resources"],
message: `Duplicate 'full-domain' values found: ${clientFullDomainDuplicates}`
});
}
// Enforce proxy-port uniqueness within proxy-resources per protocol // Enforce proxy-port uniqueness within proxy-resources per protocol
const protocolPortMap = new Map<string, string[]>(); const protocolPortMap = new Map<string, string[]>();

View File

@@ -1,39 +0,0 @@
import crypto from "crypto";
export function encryptData(data: string, key: Buffer): string {
const algorithm = "aes-256-gcm";
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(data, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
// Combine IV, auth tag, and encrypted data
return iv.toString("hex") + ":" + authTag.toString("hex") + ":" + encrypted;
}
// Helper function to decrypt data (you'll need this to read certificates)
export function decryptData(encryptedData: string, key: Buffer): string {
const algorithm = "aes-256-gcm";
const parts = encryptedData.split(":");
if (parts.length !== 3) {
throw new Error("Invalid encrypted data format");
}
const iv = Buffer.from(parts[0], "hex");
const authTag = Buffer.from(parts[1], "hex");
const encrypted = parts[2];
const decipher = crypto.createDecipheriv(algorithm, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
// openssl rand -hex 32 > config/encryption.key

View File

@@ -5,6 +5,7 @@ import config from "@server/lib/config";
import z from "zod"; import z from "zod";
import logger from "@server/logger"; import logger from "@server/logger";
import semver from "semver"; import semver from "semver";
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
interface IPRange { interface IPRange {
start: bigint; start: bigint;
@@ -477,9 +478,9 @@ export type Alias = { alias: string | null; aliasAddress: string | null };
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
return allSiteResources return allSiteResources
.filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host") .filter((sr) => sr.aliasAddress && ((sr.alias && sr.mode == "host") || (sr.fullDomain && sr.mode == "http")))
.map((sr) => ({ .map((sr) => ({
alias: sr.alias, alias: sr.alias || sr.fullDomain,
aliasAddress: sr.aliasAddress aliasAddress: sr.aliasAddress
})); }));
} }
@@ -582,16 +583,26 @@ export type SubnetProxyTargetV2 = {
protocol: "tcp" | "udp"; protocol: "tcp" | "udp";
}[]; }[];
resourceId?: number; resourceId?: number;
protocol?: "http" | "https"; // if set, this target only applies to the specified protocol
httpTargets?: HTTPTarget[];
tlsCert?: string;
tlsKey?: string;
}; };
export function generateSubnetProxyTargetV2( export type HTTPTarget = {
destAddr: string; // must be an IP or hostname
destPort: number;
scheme: "http" | "https";
};
export async function generateSubnetProxyTargetV2(
siteResource: SiteResource, siteResource: SiteResource,
clients: { clients: {
clientId: number; clientId: number;
pubKey: string | null; pubKey: string | null;
subnet: string | null; subnet: string | null;
}[] }[]
): SubnetProxyTargetV2 | undefined { ): Promise<SubnetProxyTargetV2[] | undefined> {
if (clients.length === 0) { if (clients.length === 0) {
logger.debug( logger.debug(
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
@@ -599,7 +610,7 @@ export function generateSubnetProxyTargetV2(
return; return;
} }
let target: SubnetProxyTargetV2 | null = null; let targets: SubnetProxyTargetV2[] = [];
const portRange = [ const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"), ...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
@@ -614,52 +625,115 @@ export function generateSubnetProxyTargetV2(
if (ipSchema.safeParse(destination).success) { if (ipSchema.safeParse(destination).success) {
destination = `${destination}/32`; destination = `${destination}/32`;
target = { targets.push({
sourcePrefixes: [], sourcePrefixes: [],
destPrefix: destination, destPrefix: destination,
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId, resourceId: siteResource.siteResourceId
}; });
} }
if (siteResource.alias && siteResource.aliasAddress) { if (siteResource.alias && siteResource.aliasAddress) {
// also push a match for the alias address // also push a match for the alias address
target = { targets.push({
sourcePrefixes: [], sourcePrefixes: [],
destPrefix: `${siteResource.aliasAddress}/32`, destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination, rewriteTo: destination,
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId, resourceId: siteResource.siteResourceId
}; });
} }
} else if (siteResource.mode == "cidr") { } else if (siteResource.mode == "cidr") {
target = { targets.push({
sourcePrefixes: [], sourcePrefixes: [],
destPrefix: siteResource.destination, destPrefix: siteResource.destination,
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId
});
} else if (siteResource.mode == "http") {
let destination = siteResource.destination;
// check if this is a valid ip
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
if (ipSchema.safeParse(destination).success) {
destination = `${destination}/32`;
}
if (
!siteResource.aliasAddress ||
!siteResource.destinationPort ||
!siteResource.scheme ||
!siteResource.fullDomain
) {
logger.debug(
`Site resource ${siteResource.siteResourceId} is in HTTP mode but is missing alias or alias address or destinationPort or scheme, skipping alias target generation.`
);
return;
}
// also push a match for the alias address
let tlsCert: string | undefined;
let tlsKey: string | undefined;
if (siteResource.ssl && siteResource.fullDomain) {
try {
const certs = await getValidCertificatesForDomains(
new Set([siteResource.fullDomain]),
true
);
if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) {
tlsCert = certs[0].certFile;
tlsKey = certs[0].keyFile;
} else {
logger.warn(
`No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.fullDomain}`
);
}
} catch (err) {
logger.error(
`Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.fullDomain}: ${err}`
);
}
}
targets.push({
sourcePrefixes: [],
destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination,
portRange,
disableIcmp,
resourceId: siteResource.siteResourceId, resourceId: siteResource.siteResourceId,
}; protocol: siteResource.ssl ? "https" : "http",
httpTargets: [
{
destAddr: siteResource.destination,
destPort: siteResource.destinationPort,
scheme: siteResource.scheme
}
],
...(tlsCert && tlsKey ? { tlsCert, tlsKey } : {})
});
} }
if (!target) { if (targets.length == 0) {
return; return;
} }
for (const clientSite of clients) { for (const target of targets) {
if (!clientSite.subnet) { for (const clientSite of clients) {
logger.debug( if (!clientSite.subnet) {
`Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.` logger.debug(
); `Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.`
continue; );
continue;
}
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
// add client prefix to source prefixes
target.sourcePrefixes.push(clientPrefix);
} }
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
// add client prefix to source prefixes
target.sourcePrefixes.push(clientPrefix);
} }
// print a nice representation of the targets // print a nice representation of the targets
@@ -667,36 +741,34 @@ export function generateSubnetProxyTargetV2(
// `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}` // `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}`
// ); // );
return target; return targets;
} }
/** /**
* Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1) * Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1)
* by expanding each source prefix into its own target entry. * by expanding each source prefix into its own target entry.
* @param targetV2 - The v2 target to convert * @param targetV2 - The v2 target to convert
* @returns Array of v1 SubnetProxyTarget objects * @returns Array of v1 SubnetProxyTarget objects
*/ */
export function convertSubnetProxyTargetsV2ToV1( export function convertSubnetProxyTargetsV2ToV1(
targetsV2: SubnetProxyTargetV2[] targetsV2: SubnetProxyTargetV2[]
): SubnetProxyTarget[] { ): SubnetProxyTarget[] {
return targetsV2.flatMap((targetV2) => return targetsV2.flatMap((targetV2) =>
targetV2.sourcePrefixes.map((sourcePrefix) => ({ targetV2.sourcePrefixes.map((sourcePrefix) => ({
sourcePrefix, sourcePrefix,
destPrefix: targetV2.destPrefix, destPrefix: targetV2.destPrefix,
...(targetV2.disableIcmp !== undefined && { ...(targetV2.disableIcmp !== undefined && {
disableIcmp: targetV2.disableIcmp disableIcmp: targetV2.disableIcmp
}), }),
...(targetV2.rewriteTo !== undefined && { ...(targetV2.rewriteTo !== undefined && {
rewriteTo: targetV2.rewriteTo rewriteTo: targetV2.rewriteTo
}), }),
...(targetV2.portRange !== undefined && { ...(targetV2.portRange !== undefined && {
portRange: targetV2.portRange portRange: targetV2.portRange
}) })
})) }))
); );
} }
// Custom schema for validating port range strings // Custom schema for validating port range strings
// Format: "80,443,8000-9000" or "*" for all ports, or empty string // Format: "80,443,8000-9000" or "*" for all ports, or empty string

View File

@@ -11,17 +11,16 @@ import {
roleSiteResources, roleSiteResources,
Site, Site,
SiteResource, SiteResource,
siteNetworks,
siteResources, siteResources,
sites, sites,
Transaction, Transaction,
userOrgRoles, userOrgRoles,
userOrgs,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm"; import { and, eq, inArray, ne } from "drizzle-orm";
import { import {
addPeer as newtAddPeer,
deletePeer as newtDeletePeer deletePeer as newtDeletePeer
} from "@server/routers/newt/peers"; } from "@server/routers/newt/peers";
import { import {
@@ -35,7 +34,6 @@ import {
generateRemoteSubnets, generateRemoteSubnets,
generateSubnetProxyTargetV2, generateSubnetProxyTargetV2,
parseEndpoint, parseEndpoint,
formatEndpoint
} from "@server/lib/ip"; } from "@server/lib/ip";
import { import {
addPeerData, addPeerData,
@@ -48,15 +46,27 @@ export async function getClientSiteResourceAccess(
siteResource: SiteResource, siteResource: SiteResource,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
) { ) {
// get the site // get all sites associated with this siteResource via its network
const [site] = await trx const sitesList = siteResource.networkId
.select() ? await trx
.from(sites) .select()
.where(eq(sites.siteId, siteResource.siteId)) .from(sites)
.limit(1); .innerJoin(
siteNetworks,
eq(siteNetworks.siteId, sites.siteId)
)
.where(eq(siteNetworks.networkId, siteResource.networkId))
.then((rows) => rows.map((row) => row.sites))
: [];
if (!site) { logger.debug(
throw new Error(`Site with ID ${siteResource.siteId} not found`); `rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]`
);
if (sitesList.length === 0) {
logger.warn(
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
);
} }
const roleIds = await trx const roleIds = await trx
@@ -136,8 +146,12 @@ export async function getClientSiteResourceAccess(
const mergedAllClients = Array.from(allClientsMap.values()); const mergedAllClients = Array.from(allClientsMap.values());
const mergedAllClientIds = mergedAllClients.map((c) => c.clientId); const mergedAllClientIds = mergedAllClients.map((c) => c.clientId);
logger.debug(
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} mergedClientCount=${mergedAllClientIds.length} clientIds=[${mergedAllClientIds.join(", ")}] (userBased=${newAllClients.length} direct=${directClients.length})`
);
return { return {
site, sitesList,
mergedAllClients, mergedAllClients,
mergedAllClientIds mergedAllClientIds
}; };
@@ -153,40 +167,59 @@ export async function rebuildClientAssociationsFromSiteResource(
subnet: string | null; subnet: string | null;
}[]; }[];
}> { }> {
const siteId = siteResource.siteId; logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
);
const { site, mergedAllClients, mergedAllClientIds } = const { sitesList, mergedAllClients, mergedAllClientIds } =
await getClientSiteResourceAccess(siteResource, trx); await getClientSiteResourceAccess(siteResource, trx);
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] access resolved siteResourceId=${siteResource.siteResourceId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}] mergedClientCount=${mergedAllClients.length} clientIds=[${mergedAllClientIds.join(", ")}]`
);
/////////// process the client-siteResource associations /////////// /////////// process the client-siteResource associations ///////////
// get all of the clients associated with other resources on this site // get all of the clients associated with other resources in the same network,
const allUpdatedClientsFromOtherResourcesOnThisSite = await trx // joined through siteNetworks so we know which siteId each client belongs to
.select({ const allUpdatedClientsFromOtherResourcesOnThisSite = siteResource.networkId
clientId: clientSiteResourcesAssociationsCache.clientId ? await trx
}) .select({
.from(clientSiteResourcesAssociationsCache) clientId: clientSiteResourcesAssociationsCache.clientId,
.innerJoin( siteId: siteNetworks.siteId
siteResources, })
eq( .from(clientSiteResourcesAssociationsCache)
clientSiteResourcesAssociationsCache.siteResourceId, .innerJoin(
siteResources.siteResourceId siteResources,
) eq(
) clientSiteResourcesAssociationsCache.siteResourceId,
.where( siteResources.siteResourceId
and( )
eq(siteResources.siteId, siteId), )
ne(siteResources.siteResourceId, siteResource.siteResourceId) .innerJoin(
) siteNetworks,
); eq(siteNetworks.networkId, siteResources.networkId)
)
.where(
and(
eq(siteResources.networkId, siteResource.networkId),
ne(
siteResources.siteResourceId,
siteResource.siteResourceId
)
)
)
: [];
const allClientIdsFromOtherResourcesOnThisSite = Array.from( // Build a per-site map so the loop below can check by siteId rather than
new Set( // across the entire network.
allUpdatedClientsFromOtherResourcesOnThisSite.map( const clientsFromOtherResourcesBySite = new Map<number, Set<number>>();
(row) => row.clientId for (const row of allUpdatedClientsFromOtherResourcesOnThisSite) {
) if (!clientsFromOtherResourcesBySite.has(row.siteId)) {
) clientsFromOtherResourcesBySite.set(row.siteId, new Set());
); }
clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId);
}
const existingClientSiteResources = await trx const existingClientSiteResources = await trx
.select({ .select({
@@ -204,6 +237,10 @@ export async function rebuildClientAssociationsFromSiteResource(
(row) => row.clientId (row) => row.clientId
); );
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} existingResourceClientIds=[${existingClientSiteResourceIds.join(", ")}]`
);
// Get full client details for existing resource clients (needed for sending delete messages) // Get full client details for existing resource clients (needed for sending delete messages)
const existingResourceClients = const existingResourceClients =
existingClientSiteResourceIds.length > 0 existingClientSiteResourceIds.length > 0
@@ -223,6 +260,10 @@ export async function rebuildClientAssociationsFromSiteResource(
(clientId) => !existingClientSiteResourceIds.includes(clientId) (clientId) => !existingClientSiteResourceIds.includes(clientId)
); );
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} resourceClients toAdd=[${clientSiteResourcesToAdd.join(", ")}]`
);
const clientSiteResourcesToInsert = clientSiteResourcesToAdd.map( const clientSiteResourcesToInsert = clientSiteResourcesToAdd.map(
(clientId) => ({ (clientId) => ({
clientId, clientId,
@@ -231,17 +272,34 @@ export async function rebuildClientAssociationsFromSiteResource(
); );
if (clientSiteResourcesToInsert.length > 0) { if (clientSiteResourcesToInsert.length > 0) {
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserting ${clientSiteResourcesToInsert.length} clientSiteResource association(s)`
);
await trx await trx
.insert(clientSiteResourcesAssociationsCache) .insert(clientSiteResourcesAssociationsCache)
.values(clientSiteResourcesToInsert) .values(clientSiteResourcesToInsert)
.returning(); .returning();
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
);
} else {
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} no clientSiteResource associations to insert`
);
} }
const clientSiteResourcesToRemove = existingClientSiteResourceIds.filter( const clientSiteResourcesToRemove = existingClientSiteResourceIds.filter(
(clientId) => !mergedAllClientIds.includes(clientId) (clientId) => !mergedAllClientIds.includes(clientId)
); );
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} resourceClients toRemove=[${clientSiteResourcesToRemove.join(", ")}]`
);
if (clientSiteResourcesToRemove.length > 0) { if (clientSiteResourcesToRemove.length > 0) {
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} deleting ${clientSiteResourcesToRemove.length} clientSiteResource association(s)`
);
await trx await trx
.delete(clientSiteResourcesAssociationsCache) .delete(clientSiteResourcesAssociationsCache)
.where( .where(
@@ -260,82 +318,127 @@ export async function rebuildClientAssociationsFromSiteResource(
/////////// process the client-site associations /////////// /////////// process the client-site associations ///////////
const existingClientSites = await trx logger.debug(
.select({ `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesList.length} site(s)`
clientId: clientSitesAssociationsCache.clientId
})
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, siteResource.siteId));
const existingClientSiteIds = existingClientSites.map(
(row) => row.clientId
); );
// Get full client details for existing clients (needed for sending delete messages) for (const site of sitesList) {
const existingClients = await trx const siteId = site.siteId;
.select({
clientId: clients.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clients)
.where(inArray(clients.clientId, existingClientSiteIds));
const clientSitesToAdd = mergedAllClientIds.filter( logger.debug(
(clientId) => `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
!existingClientSiteIds.includes(clientId) && );
!allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
);
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ const existingClientSites = await trx
clientId, .select({
siteId clientId: clientSitesAssociationsCache.clientId
})); })
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, siteId));
if (clientSitesToInsert.length > 0) { const existingClientSiteIds = existingClientSites.map(
await trx (row) => row.clientId
.insert(clientSitesAssociationsCache) );
.values(clientSitesToInsert)
.returning();
}
// Now remove any client-site associations that should no longer exist logger.debug(
const clientSitesToRemove = existingClientSiteIds.filter( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]`
(clientId) => );
!mergedAllClientIds.includes(clientId) &&
!allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
);
if (clientSitesToRemove.length > 0) { // Get full client details for existing clients (needed for sending delete messages)
await trx const existingClients =
.delete(clientSitesAssociationsCache) existingClientSiteIds.length > 0
.where( ? await trx
and( .select({
eq(clientSitesAssociationsCache.siteId, siteId), clientId: clients.clientId,
inArray( pubKey: clients.pubKey,
clientSitesAssociationsCache.clientId, subnet: clients.subnet
clientSitesToRemove })
) .from(clients)
) .where(inArray(clients.clientId, existingClientSiteIds))
: [];
const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
);
const clientSitesToAdd = mergedAllClientIds.filter(
(clientId) =>
!existingClientSiteIds.includes(clientId) &&
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
);
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
clientId,
siteId
}));
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]`
);
if (clientSitesToInsert.length > 0) {
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)`
); );
await trx
.insert(clientSitesAssociationsCache)
.values(clientSitesToInsert)
.returning();
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
);
} else {
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert`
);
}
// Now remove any client-site associations that should no longer exist
const clientSitesToRemove = existingClientSiteIds.filter(
(clientId) =>
!mergedAllClientIds.includes(clientId) &&
!otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
);
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]`
);
if (clientSitesToRemove.length > 0) {
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)`
);
await trx
.delete(clientSitesAssociationsCache)
.where(
and(
eq(clientSitesAssociationsCache.siteId, siteId),
inArray(
clientSitesAssociationsCache.clientId,
clientSitesToRemove
)
)
);
}
// Now handle the messages to add/remove peers on both the newt and olm sides
await handleMessagesForSiteClients(
site,
siteId,
mergedAllClients,
existingClients,
clientSitesToAdd,
clientSitesToRemove,
trx
);
} }
/////////// send the messages ///////////
// Now handle the messages to add/remove peers on both the newt and olm sides
await handleMessagesForSiteClients(
site,
siteId,
mergedAllClients,
existingClients,
clientSitesToAdd,
clientSitesToRemove,
trx
);
// Handle subnet proxy target updates for the resource associations // Handle subnet proxy target updates for the resource associations
await handleSubnetProxyTargetUpdates( await handleSubnetProxyTargetUpdates(
siteResource, siteResource,
sitesList,
mergedAllClients, mergedAllClients,
existingResourceClients, existingResourceClients,
clientSiteResourcesToAdd, clientSiteResourcesToAdd,
@@ -624,6 +727,7 @@ export async function updateClientSiteDestinations(
async function handleSubnetProxyTargetUpdates( async function handleSubnetProxyTargetUpdates(
siteResource: SiteResource, siteResource: SiteResource,
sitesList: Site[],
allClients: { allClients: {
clientId: number; clientId: number;
pubKey: string | null; pubKey: string | null;
@@ -638,125 +742,138 @@ async function handleSubnetProxyTargetUpdates(
clientSiteResourcesToRemove: number[], clientSiteResourcesToRemove: number[],
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
// Get the newt for this site const proxyJobs: Promise<any>[] = [];
const [newt] = await trx const olmJobs: Promise<any>[] = [];
.select()
.from(newts)
.where(eq(newts.siteId, siteResource.siteId))
.limit(1);
if (!newt) { for (const siteData of sitesList) {
logger.warn( const siteId = siteData.siteId;
`Newt not found for site ${siteResource.siteId}, skipping subnet proxy target updates`
);
return;
}
const proxyJobs = []; // Get the newt for this site
const olmJobs = []; const [newt] = await trx
// Generate targets for added associations .select()
if (clientSiteResourcesToAdd.length > 0) { .from(newts)
const addedClients = allClients.filter((client) => .where(eq(newts.siteId, siteId))
clientSiteResourcesToAdd.includes(client.clientId) .limit(1);
);
if (addedClients.length > 0) { if (!newt) {
const targetToAdd = generateSubnetProxyTargetV2( logger.warn(
siteResource, `Newt not found for site ${siteId}, skipping subnet proxy target updates`
addedClients
); );
continue;
if (targetToAdd) {
proxyJobs.push(
addSubnetProxyTargets(
newt.newtId,
[targetToAdd],
newt.version
)
);
}
for (const client of addedClients) {
olmJobs.push(
addPeerData(
client.clientId,
siteResource.siteId,
generateRemoteSubnets([siteResource]),
generateAliasConfig([siteResource])
)
);
}
} }
}
// here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here // Generate targets for added associations
if (clientSiteResourcesToAdd.length > 0) {
// Generate targets for removed associations const addedClients = allClients.filter((client) =>
if (clientSiteResourcesToRemove.length > 0) { clientSiteResourcesToAdd.includes(client.clientId)
const removedClients = existingClients.filter((client) =>
clientSiteResourcesToRemove.includes(client.clientId)
);
if (removedClients.length > 0) {
const targetToRemove = generateSubnetProxyTargetV2(
siteResource,
removedClients
); );
if (targetToRemove) { if (addedClients.length > 0) {
proxyJobs.push( const targetsToAdd = await generateSubnetProxyTargetV2(
removeSubnetProxyTargets( siteResource,
newt.newtId, addedClients
[targetToRemove],
newt.version
)
); );
}
for (const client of removedClients) { if (targetsToAdd) {
// Check if this client still has access to another resource on this site with the same destination proxyJobs.push(
const destinationStillInUse = await trx addSubnetProxyTargets(
.select() newt.newtId,
.from(siteResources) targetsToAdd,
.innerJoin( newt.version
clientSiteResourcesAssociationsCache,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
eq(siteResources.siteId, siteResource.siteId),
eq(
siteResources.destination,
siteResource.destination
),
ne(
siteResources.siteResourceId,
siteResource.siteResourceId
)
) )
); );
}
// Only remove remote subnet if no other resource uses the same destination for (const client of addedClients) {
const remoteSubnetsToRemove = olmJobs.push(
destinationStillInUse.length > 0 addPeerData(
? [] client.clientId,
: generateRemoteSubnets([siteResource]); siteId,
generateRemoteSubnets([siteResource]),
generateAliasConfig([siteResource])
)
);
}
}
}
olmJobs.push( // here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here
removePeerData(
client.clientId, // Generate targets for removed associations
siteResource.siteId, if (clientSiteResourcesToRemove.length > 0) {
remoteSubnetsToRemove, const removedClients = existingClients.filter((client) =>
generateAliasConfig([siteResource]) clientSiteResourcesToRemove.includes(client.clientId)
) );
if (removedClients.length > 0) {
const targetsToRemove = await generateSubnetProxyTargetV2(
siteResource,
removedClients
); );
if (targetsToRemove) {
proxyJobs.push(
removeSubnetProxyTargets(
newt.newtId,
targetsToRemove,
newt.version
)
);
}
for (const client of removedClients) {
// Check if this client still has access to another resource
// on this specific site with the same destination. We scope
// by siteId (via siteNetworks) rather than networkId because
// removePeerData operates per-site — a resource on a different
// site sharing the same network should not block removal here.
const destinationStillInUse = await trx
.select()
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where(
and(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
eq(siteNetworks.siteId, siteId),
eq(
siteResources.destination,
siteResource.destination
),
ne(
siteResources.siteResourceId,
siteResource.siteResourceId
)
)
);
// Only remove remote subnet if no other resource uses the same destination
const remoteSubnetsToRemove =
destinationStillInUse.length > 0
? []
: generateRemoteSubnets([siteResource]);
olmJobs.push(
removePeerData(
client.clientId,
siteId,
remoteSubnetsToRemove,
generateAliasConfig([siteResource])
)
);
}
} }
} }
} }
@@ -863,10 +980,25 @@ export async function rebuildClientAssociationsFromClient(
) )
: []; : [];
// Group by siteId for site-level associations // Group by siteId for site-level associations — look up via siteNetworks since
const newSiteIds = Array.from( // siteResources no longer carries a direct siteId column.
new Set(newSiteResources.map((sr) => sr.siteId)) const networkIds = Array.from(
new Set(
newSiteResources
.map((sr) => sr.networkId)
.filter((id): id is number => id !== null)
)
); );
const newSiteIds =
networkIds.length > 0
? await trx
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(inArray(siteNetworks.networkId, networkIds))
.then((rows) =>
Array.from(new Set(rows.map((r) => r.siteId)))
)
: [];
/////////// Process client-siteResource associations /////////// /////////// Process client-siteResource associations ///////////
@@ -1139,13 +1271,45 @@ async function handleMessagesForClientResources(
resourcesToAdd.includes(r.siteResourceId) resourcesToAdd.includes(r.siteResourceId)
); );
// Build (resource, siteId) pairs by looking up siteNetworks for each resource's networkId
const addedNetworkIds = Array.from(
new Set(
addedResources
.map((r) => r.networkId)
.filter((id): id is number => id !== null)
)
);
const addedSiteNetworkRows =
addedNetworkIds.length > 0
? await trx
.select({
networkId: siteNetworks.networkId,
siteId: siteNetworks.siteId
})
.from(siteNetworks)
.where(inArray(siteNetworks.networkId, addedNetworkIds))
: [];
const addedNetworkToSites = new Map<number, number[]>();
for (const row of addedSiteNetworkRows) {
if (!addedNetworkToSites.has(row.networkId)) {
addedNetworkToSites.set(row.networkId, []);
}
addedNetworkToSites.get(row.networkId)!.push(row.siteId);
}
// Group by site for proxy updates // Group by site for proxy updates
const addedBySite = new Map<number, SiteResource[]>(); const addedBySite = new Map<number, SiteResource[]>();
for (const resource of addedResources) { for (const resource of addedResources) {
if (!addedBySite.has(resource.siteId)) { const siteIds =
addedBySite.set(resource.siteId, []); resource.networkId != null
? (addedNetworkToSites.get(resource.networkId) ?? [])
: [];
for (const siteId of siteIds) {
if (!addedBySite.has(siteId)) {
addedBySite.set(siteId, []);
}
addedBySite.get(siteId)!.push(resource);
} }
addedBySite.get(resource.siteId)!.push(resource);
} }
// Add subnet proxy targets for each site // Add subnet proxy targets for each site
@@ -1164,7 +1328,7 @@ async function handleMessagesForClientResources(
} }
for (const resource of resources) { for (const resource of resources) {
const target = generateSubnetProxyTargetV2(resource, [ const targets = await generateSubnetProxyTargetV2(resource, [
{ {
clientId: client.clientId, clientId: client.clientId,
pubKey: client.pubKey, pubKey: client.pubKey,
@@ -1172,11 +1336,11 @@ async function handleMessagesForClientResources(
} }
]); ]);
if (target) { if (targets) {
proxyJobs.push( proxyJobs.push(
addSubnetProxyTargets( addSubnetProxyTargets(
newt.newtId, newt.newtId,
[target], targets,
newt.version newt.version
) )
); );
@@ -1187,7 +1351,7 @@ async function handleMessagesForClientResources(
olmJobs.push( olmJobs.push(
addPeerData( addPeerData(
client.clientId, client.clientId,
resource.siteId, siteId,
generateRemoteSubnets([resource]), generateRemoteSubnets([resource]),
generateAliasConfig([resource]) generateAliasConfig([resource])
) )
@@ -1199,7 +1363,7 @@ async function handleMessagesForClientResources(
error.message.includes("not found") error.message.includes("not found")
) { ) {
logger.debug( logger.debug(
`Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal` `Olm data not found for client ${client.clientId} and site ${siteId}, skipping addition`
); );
} else { } else {
throw error; throw error;
@@ -1216,13 +1380,45 @@ async function handleMessagesForClientResources(
.from(siteResources) .from(siteResources)
.where(inArray(siteResources.siteResourceId, resourcesToRemove)); .where(inArray(siteResources.siteResourceId, resourcesToRemove));
// Build (resource, siteId) pairs via siteNetworks
const removedNetworkIds = Array.from(
new Set(
removedResources
.map((r) => r.networkId)
.filter((id): id is number => id !== null)
)
);
const removedSiteNetworkRows =
removedNetworkIds.length > 0
? await trx
.select({
networkId: siteNetworks.networkId,
siteId: siteNetworks.siteId
})
.from(siteNetworks)
.where(inArray(siteNetworks.networkId, removedNetworkIds))
: [];
const removedNetworkToSites = new Map<number, number[]>();
for (const row of removedSiteNetworkRows) {
if (!removedNetworkToSites.has(row.networkId)) {
removedNetworkToSites.set(row.networkId, []);
}
removedNetworkToSites.get(row.networkId)!.push(row.siteId);
}
// Group by site for proxy updates // Group by site for proxy updates
const removedBySite = new Map<number, SiteResource[]>(); const removedBySite = new Map<number, SiteResource[]>();
for (const resource of removedResources) { for (const resource of removedResources) {
if (!removedBySite.has(resource.siteId)) { const siteIds =
removedBySite.set(resource.siteId, []); resource.networkId != null
? (removedNetworkToSites.get(resource.networkId) ?? [])
: [];
for (const siteId of siteIds) {
if (!removedBySite.has(siteId)) {
removedBySite.set(siteId, []);
}
removedBySite.get(siteId)!.push(resource);
} }
removedBySite.get(resource.siteId)!.push(resource);
} }
// Remove subnet proxy targets for each site // Remove subnet proxy targets for each site
@@ -1241,7 +1437,7 @@ async function handleMessagesForClientResources(
} }
for (const resource of resources) { for (const resource of resources) {
const target = generateSubnetProxyTargetV2(resource, [ const targets = await generateSubnetProxyTargetV2(resource, [
{ {
clientId: client.clientId, clientId: client.clientId,
pubKey: client.pubKey, pubKey: client.pubKey,
@@ -1249,18 +1445,22 @@ async function handleMessagesForClientResources(
} }
]); ]);
if (target) { if (targets) {
proxyJobs.push( proxyJobs.push(
removeSubnetProxyTargets( removeSubnetProxyTargets(
newt.newtId, newt.newtId,
[target], targets,
newt.version newt.version
) )
); );
} }
try { try {
// Check if this client still has access to another resource on this site with the same destination // Check if this client still has access to another resource
// on this specific site with the same destination. We scope
// by siteId (via siteNetworks) rather than networkId because
// removePeerData operates per-site — a resource on a different
// site sharing the same network should not block removal here.
const destinationStillInUse = await trx const destinationStillInUse = await trx
.select() .select()
.from(siteResources) .from(siteResources)
@@ -1271,13 +1471,17 @@ async function handleMessagesForClientResources(
siteResources.siteResourceId siteResources.siteResourceId
) )
) )
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where( .where(
and( and(
eq( eq(
clientSiteResourcesAssociationsCache.clientId, clientSiteResourcesAssociationsCache.clientId,
client.clientId client.clientId
), ),
eq(siteResources.siteId, resource.siteId), eq(siteNetworks.siteId, siteId),
eq( eq(
siteResources.destination, siteResources.destination,
resource.destination resource.destination
@@ -1299,7 +1503,7 @@ async function handleMessagesForClientResources(
olmJobs.push( olmJobs.push(
removePeerData( removePeerData(
client.clientId, client.clientId,
resource.siteId, siteId,
remoteSubnetsToRemove, remoteSubnetsToRemove,
generateAliasConfig([resource]) generateAliasConfig([resource])
) )
@@ -1311,7 +1515,7 @@ async function handleMessagesForClientResources(
error.message.includes("not found") error.message.includes("not found")
) { ) {
logger.debug( logger.debug(
`Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal` `Olm data not found for client ${client.clientId} and site ${siteId}, skipping removal`
); );
} else { } else {
throw error; throw error;

View File

@@ -479,10 +479,7 @@ export async function getTraefikConfig(
// TODO: HOW TO HANDLE ^^^^^^ BETTER // TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = targets.some( const anySitesOnline = targets.some(
(target) => (target) => target.site.online
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
); );
return ( return (
@@ -495,7 +492,7 @@ export async function getTraefikConfig(
if (target.health == "unhealthy") { if (target.health == "unhealthy") {
return false; return false;
} }
// If any sites are online, exclude offline sites // If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) { if (anySitesOnline && !target.site.online) {
return false; return false;
@@ -610,10 +607,7 @@ export async function getTraefikConfig(
servers: (() => { servers: (() => {
// Check if any sites are online // Check if any sites are online
const anySitesOnline = targets.some( const anySitesOnline = targets.some(
(target) => (target) => target.site.online
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
); );
return targets return targets
@@ -621,7 +615,7 @@ export async function getTraefikConfig(
if (!target.enabled) { if (!target.enabled) {
return false; return false;
} }
// If any sites are online, exclude offline sites // If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) { if (anySitesOnline && !target.site.online) {
return false; return false;

View File

@@ -0,0 +1,478 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 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 fs from "fs";
import crypto from "crypto";
import {
certificates,
clients,
clientSiteResourcesAssociationsCache,
db,
domains,
newts,
siteNetworks,
SiteResource,
siteResources
} from "@server/db";
import { and, eq } from "drizzle-orm";
import { encrypt, decrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import privateConfig from "#private/lib/config";
import config from "@server/lib/config";
import {
generateSubnetProxyTargetV2,
SubnetProxyTargetV2
} from "@server/lib/ip";
import { updateTargets } from "@server/routers/client/targets";
import cache from "#private/lib/cache";
import { build } from "@server/build";
interface AcmeCert {
domain: { main: string; sans?: string[] };
certificate: string;
key: string;
Store: string;
}
interface AcmeJson {
[resolver: string]: {
Certificates: AcmeCert[];
};
}
async function pushCertUpdateToAffectedNewts(
domain: string,
domainId: string | null,
oldCertPem: string | null,
oldKeyPem: string | null
): Promise<void> {
// Find all SSL-enabled HTTP site resources that use this cert's domain
let affectedResources: SiteResource[] = [];
if (domainId) {
affectedResources = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.domainId, domainId),
eq(siteResources.ssl, true)
)
);
} else {
// Fallback: match by exact fullDomain when no domainId is available
affectedResources = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.fullDomain, domain),
eq(siteResources.ssl, true)
)
);
}
if (affectedResources.length === 0) {
logger.debug(
`acmeCertSync: no affected site resources for cert domain "${domain}"`
);
return;
}
logger.info(
`acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"`
);
for (const resource of affectedResources) {
try {
// Get all sites for this resource via siteNetworks
const resourceSiteRows = resource.networkId
? await db
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(eq(siteNetworks.networkId, resource.networkId))
: [];
if (resourceSiteRows.length === 0) {
logger.debug(
`acmeCertSync: no sites for resource ${resource.siteResourceId}, skipping`
);
continue;
}
// Get all clients with access to this resource
const resourceClients = await db
.select({
clientId: clients.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clients)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clients.clientId,
clientSiteResourcesAssociationsCache.clientId
)
)
.where(
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
resource.siteResourceId
)
);
if (resourceClients.length === 0) {
logger.debug(
`acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping`
);
continue;
}
// Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data
if (resource.fullDomain) {
await cache.del(`cert:${resource.fullDomain}`);
}
// Generate target once — same cert applies to all sites for this resource
const newTargets = await generateSubnetProxyTargetV2(
resource,
resourceClients
);
if (!newTargets) {
logger.debug(
`acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping`
);
continue;
}
// Construct the old targets — same routing shape but with the previous cert/key.
// The newt only uses destPrefix/sourcePrefixes for removal, but we keep the
// semantics correct so the update message accurately reflects what changed.
const oldTargets: SubnetProxyTargetV2[] = newTargets.map((t) => ({
...t,
tlsCert: oldCertPem ?? undefined,
tlsKey: oldKeyPem ?? undefined
}));
// Push update to each site's newt
for (const { siteId } of resourceSiteRows) {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
if (!newt) {
logger.debug(
`acmeCertSync: no newt found for site ${siteId}, skipping resource ${resource.siteResourceId}`
);
continue;
}
await updateTargets(
newt.newtId,
{ oldTargets: oldTargets, newTargets: newTargets },
newt.version
);
logger.info(
`acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}`
);
}
} catch (err) {
logger.error(
`acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}`
);
}
}
}
async function findDomainId(certDomain: string): Promise<string | null> {
// Strip wildcard prefix before lookup (*.example.com -> example.com)
const lookupDomain = certDomain.startsWith("*.")
? certDomain.slice(2)
: certDomain;
// 1. Exact baseDomain match (any domain type)
const exactMatch = await db
.select({ domainId: domains.domainId })
.from(domains)
.where(eq(domains.baseDomain, lookupDomain))
.limit(1);
if (exactMatch.length > 0) {
return exactMatch[0].domainId;
}
// 2. Walk up the domain hierarchy looking for a wildcard-type domain whose
// baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com"
// matches a wildcard domain with baseDomain "example.com".
const parts = lookupDomain.split(".");
for (let i = 1; i < parts.length; i++) {
const candidate = parts.slice(i).join(".");
if (!candidate) continue;
const wildcardMatch = await db
.select({ domainId: domains.domainId })
.from(domains)
.where(
and(
eq(domains.baseDomain, candidate),
eq(domains.type, "wildcard")
)
)
.limit(1);
if (wildcardMatch.length > 0) {
return wildcardMatch[0].domainId;
}
}
return null;
}
function extractFirstCert(pemBundle: string): string | null {
const match = pemBundle.match(
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
);
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;
}
let acmeJson: AcmeJson;
try {
acmeJson = JSON.parse(raw);
} catch (err) {
logger.debug(`acmeCertSync: could not parse acme.json: ${err}`);
return;
}
const resolverData = acmeJson[resolver];
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
logger.debug(
`acmeCertSync: no certificates found for resolver "${resolver}"`
);
return;
}
for (const cert of resolverData.Certificates) {
const domain = cert.domain?.main;
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"
);
const keyPem = Buffer.from(cert.key, "base64").toString("utf8");
if (!certPem.trim() || !keyPem.trim()) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode`
);
continue;
}
// Check if cert already exists in DB
const existing = await db
.select()
.from(certificates)
.where(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!
);
if (storedCertPem === certPem) {
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;
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) {
// Decryption failure means we should proceed with the update
logger.debug(
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
);
}
}
// Parse cert expiry from the first cert in the PEM bundle
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}`
);
}
}
const wildcard = domain.startsWith("*.");
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 cert domain "${domain}"`
);
} else {
logger.debug(
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
);
}
if (existing.length > 0) {
await db
.update(certificates)
.set({
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
updatedAt: now,
wildcard,
...(domainId !== null && { domainId })
})
.where(eq(certificates.domain, domain));
logger.info(
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await pushCertUpdateToAffectedNewts(
domain,
domainId,
oldCertPem,
oldKeyPem
);
} else {
await db.insert(certificates).values({
domain,
domainId,
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
createdAt: now,
updatedAt: now,
wildcard
});
logger.info(
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
// For a brand-new cert, push to any SSL resources that were waiting for it
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
}
}
}
export function initAcmeCertSync(): void {
if (build == "saas") {
logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`);
return;
}
const privateConfigData = privateConfig.getRawPrivateConfig();
if (!privateConfigData.flags?.enable_acme_cert_sync) {
logger.debug(
`acmeCertSync: ACME cert sync is disabled by config flag, skipping`
);
return;
}
if (privateConfigData.flags.use_pangolin_dns) {
logger.debug(
`acmeCertSync: ACME cert sync requires use_pangolin_dns flag to be disabled, skipping`
);
return;
}
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;
logger.info(
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
);
// Run immediately on init, then on the configured interval
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
logger.error(`acmeCertSync: error during initial sync: ${err}`);
});
setInterval(() => {
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
logger.error(`acmeCertSync: error during sync: ${err}`);
});
}, intervalMs);
}

View File

@@ -11,23 +11,15 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import config from "./config"; import privateConfig from "./config";
import config from "@server/lib/config";
import { certificates, db } from "@server/db"; import { certificates, db } from "@server/db";
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
import { decryptData } from "@server/lib/encryption"; import { decrypt } from "@server/lib/crypto";
import logger from "@server/logger"; import logger from "@server/logger";
import cache from "#private/lib/cache"; import cache from "#private/lib/cache";
let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
if (encryptionKey) {
return; // already loaded
}
encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key;
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
}
// Define the return type for clarity and type safety // Define the return type for clarity and type safety
export type CertificateResult = { export type CertificateResult = {
@@ -45,7 +37,7 @@ export async function getValidCertificatesForDomains(
domains: Set<string>, domains: Set<string>,
useCache: boolean = true useCache: boolean = true
): Promise<Array<CertificateResult>> { ): Promise<Array<CertificateResult>> {
loadEncryptData(); // Ensure encryption key is loaded
const finalResults: CertificateResult[] = []; const finalResults: CertificateResult[] = [];
const domainsToQuery = new Set<string>(); const domainsToQuery = new Set<string>();
@@ -68,7 +60,7 @@ export async function getValidCertificatesForDomains(
// 2. If all domains were resolved from the cache, return early // 2. If all domains were resolved from the cache, return early
if (domainsToQuery.size === 0) { if (domainsToQuery.size === 0) {
const decryptedResults = decryptFinalResults(finalResults); const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
return decryptedResults; return decryptedResults;
} }
@@ -173,22 +165,23 @@ export async function getValidCertificatesForDomains(
} }
} }
const decryptedResults = decryptFinalResults(finalResults); const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
return decryptedResults; return decryptedResults;
} }
function decryptFinalResults( function decryptFinalResults(
finalResults: CertificateResult[] finalResults: CertificateResult[],
secret: string
): CertificateResult[] { ): CertificateResult[] {
const validCertsDecrypted = finalResults.map((cert) => { const validCertsDecrypted = finalResults.map((cert) => {
// Decrypt and save certificate file // Decrypt and save certificate file
const decryptedCert = decryptData( const decryptedCert = decrypt(
cert.certFile!, // is not null from query cert.certFile!, // is not null from query
encryptionKey secret
); );
// Decrypt and save key file // Decrypt and save key file
const decryptedKey = decryptData(cert.keyFile!, encryptionKey); const decryptedKey = decrypt(cert.keyFile!, secret);
// Return only the certificate data without org information // Return only the certificate data without org information
return { return {

View File

@@ -23,6 +23,8 @@ import {
} from "@server/db"; } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { and, eq, gt, desc, max, sql } from "drizzle-orm"; import { and, eq, gt, desc, max, sql } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { import {
LogType, LogType,
LOG_TYPES, LOG_TYPES,
@@ -127,7 +129,7 @@ export class LogStreamingManager {
start(): void { start(): void {
if (this.isRunning) return; if (this.isRunning) return;
this.isRunning = true; this.isRunning = true;
logger.info("LogStreamingManager: started"); logger.debug("LogStreamingManager: started");
this.schedulePoll(POLL_INTERVAL_MS); this.schedulePoll(POLL_INTERVAL_MS);
} }
@@ -272,19 +274,20 @@ export class LogStreamingManager {
return; return;
} }
// Parse config skip destination if config is unparseable // Decrypt and parse config skip destination if either step fails
let config: HttpConfig; let configFromDb: HttpConfig;
try { try {
config = JSON.parse(dest.config) as HttpConfig; const decryptedConfig = decrypt(dest.config, config.getRawConfig().server.secret!);
configFromDb = JSON.parse(decryptedConfig) as HttpConfig;
} catch (err) { } catch (err) {
logger.error( logger.error(
`LogStreamingManager: destination ${dest.destinationId} has invalid JSON config`, `LogStreamingManager: destination ${dest.destinationId} has invalid or undecryptable config`,
err err
); );
return; return;
} }
const provider = this.createProvider(dest.type, config); const provider = this.createProvider(dest.type, configFromDb);
if (!provider) { if (!provider) {
logger.warn( logger.warn(
`LogStreamingManager: unsupported destination type "${dest.type}" ` + `LogStreamingManager: unsupported destination type "${dest.type}" ` +
@@ -770,4 +773,4 @@ export class LogStreamingManager {
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }

View File

@@ -34,10 +34,6 @@ export const privateConfigSchema = z.object({
}), }),
server: z server: z
.object({ .object({
encryption_key: z
.string()
.optional()
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
reo_client_id: z reo_client_id: z
.string() .string()
.optional() .optional()
@@ -95,10 +91,21 @@ export const privateConfigSchema = z.object({
.object({ .object({
enable_redis: z.boolean().optional().default(false), enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false), use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional() use_org_only_idp: z.boolean().optional(),
enable_acme_cert_sync: z.boolean().optional().default(true)
}) })
.optional() .optional()
.prefault({}), .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 branding: z
.object({ .object({
app_name: z.string().optional(), app_name: z.string().optional(),

View File

@@ -33,7 +33,7 @@ import {
} from "drizzle-orm"; } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { orgs, resources, sites, Target, targets } from "@server/db"; import { orgs, resources, sites, siteNetworks, siteResources, Target, targets } from "@server/db";
import { import {
sanitize, sanitize,
encodePath, encodePath,
@@ -267,6 +267,35 @@ 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 validCerts: CertificateResult[] = []; let validCerts: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
// create a list of all domains to get certs for // create a list of all domains to get certs for
@@ -276,6 +305,12 @@ export async function getTraefikConfig(
domains.add(resource.fullDomain); domains.add(resource.fullDomain);
} }
} }
// Include siteResource aliases so pangolin-dns also fetches certs for them
for (const sr of siteResourcesWithFullDomain) {
if (sr.fullDomain) {
domains.add(sr.fullDomain);
}
}
// get the valid certs for these domains // get the valid certs for these domains
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
@@ -671,10 +706,7 @@ export async function getTraefikConfig(
// TODO: HOW TO HANDLE ^^^^^^ BETTER // TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = targets.some( const anySitesOnline = targets.some(
(target) => (target) => target.site.online
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
); );
return ( return (
@@ -802,10 +834,7 @@ export async function getTraefikConfig(
servers: (() => { servers: (() => {
// Check if any sites are online // Check if any sites are online
const anySitesOnline = targets.some( const anySitesOnline = targets.some(
(target) => (target) => target.site.online
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
); );
return targets return targets
@@ -873,6 +902,139 @@ export async function getTraefikConfig(
} }
} }
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
// Traefik generates TLS certificates for those domains even when no
// matching resource exists yet.
if (siteResourcesWithFullDomain.length > 0) {
// Build a set of domains already covered by normal resources
const existingFullDomains = new Set<string>();
for (const resource of resourcesMap.values()) {
if (resource.fullDomain) {
existingFullDomains.add(resource.fullDomain);
}
}
for (const sr of siteResourcesWithFullDomain) {
if (!sr.fullDomain) continue;
// Skip if this alias is already handled by a resource router
if (existingFullDomains.has(sr.fullDomain)) continue;
const fullDomain = sr.fullDomain;
const srKey = `site-resource-cert-${sr.siteResourceId}`;
const siteResourceServiceName = `${srKey}-service`;
const siteResourceRouterName = `${srKey}-router`;
const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`;
const maintenancePort = config.getRawConfig().server.next_port;
const maintenanceHost =
config.getRawConfig().server.internal_hostname;
if (!config_output.http.routers) {
config_output.http.routers = {};
}
if (!config_output.http.services) {
config_output.http.services = {};
}
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
// Service pointing at the internal maintenance/Next.js page
config_output.http.services[siteResourceServiceName] = {
loadBalancer: {
servers: [
{
url: `http://${maintenanceHost}:${maintenancePort}`
}
],
passHostHeader: true
}
};
// Middleware that rewrites any path to /maintenance-screen
config_output.http.middlewares[
siteResourceRewriteMiddlewareName
] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: "/private-maintenance-screen"
}
};
// HTTP -> HTTPS redirect so the ACME challenge can be served
config_output.http.routers[
`${siteResourceRouterName}-redirect`
] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`)`,
priority: 100
};
// Determine TLS / cert-resolver configuration
let tls: any = {};
if (
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
) {
const domainParts = fullDomain.split(".");
const wildCard =
domainParts.length <= 2
? `*.${domainParts.join(".")}`
: `*.${domainParts.slice(1).join(".")}`;
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
tls = {
certResolver: globalDefaultResolver,
...(globalDefaultPreferWildcard
? { domains: [{ main: wildCard }] }
: {})
};
} else {
// pangolin-dns: only add route if we already have a valid cert
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for siteResource alias: ${fullDomain}`
);
continue;
}
}
// HTTPS router — presence of this entry triggers cert generation
config_output.http.routers[siteResourceRouterName] = {
entryPoints: [
config.getRawConfig().traefik.https_entrypoint
],
service: siteResourceServiceName,
middlewares: [siteResourceRewriteMiddlewareName],
rule: `Host(\`${fullDomain}\`)`,
priority: 100,
tls
};
// Assets bypass router — lets Next.js static files load without rewrite
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
entryPoints: [
config.getRawConfig().traefik.https_entrypoint
],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
priority: 101,
tls
};
}
}
if (generateLoginPageRouters) { if (generateLoginPageRouters) {
const exitNodeLoginPages = await db const exitNodeLoginPages = await db
.select({ .select({

View File

@@ -22,11 +22,15 @@ import { OpenAPITags, registry } from "@server/openApi";
import { db, domainNamespaces, resources } from "@server/db"; import { db, domainNamespaces, resources } from "@server/db";
import { inArray } from "drizzle-orm"; import { inArray } from "drizzle-orm";
import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types"; import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types";
import { build } from "@server/build";
import { isSubscribed } from "#private/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const paramsSchema = z.strictObject({}); const paramsSchema = z.strictObject({});
const querySchema = z.strictObject({ const querySchema = z.strictObject({
subdomain: z.string() subdomain: z.string(),
// orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
}); });
registry.registerPath({ registry.registerPath({
@@ -58,6 +62,23 @@ export async function checkDomainNamespaceAvailability(
} }
const { subdomain } = parsedQuery.data; const { subdomain } = parsedQuery.data;
// if (
// build == "saas" &&
// !isSubscribed(orgId!, tierMatrix.domainNamespaces)
// ) {
// // return not available
// return response<CheckDomainAvailabilityResponse>(res, {
// data: {
// available: false,
// options: []
// },
// success: true,
// error: false,
// message: "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.",
// status: HttpCode.OK
// });
// }
const namespaces = await db.select().from(domainNamespaces); const namespaces = await db.select().from(domainNamespaces);
let possibleDomains = namespaces.map((ns) => { let possibleDomains = namespaces.map((ns) => {
const desired = `${subdomain}.${ns.domainNamespaceId}`; const desired = `${subdomain}.${ns.domainNamespaceId}`;

View File

@@ -22,6 +22,9 @@ import { eq, sql } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { isSubscribed } from "#private/lib/isSubscribed";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const paramsSchema = z.strictObject({}); const paramsSchema = z.strictObject({});
@@ -37,7 +40,8 @@ const querySchema = z.strictObject({
.optional() .optional()
.default("0") .default("0")
.transform(Number) .transform(Number)
.pipe(z.int().nonnegative()) .pipe(z.int().nonnegative()),
// orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
}); });
async function query(limit: number, offset: number) { async function query(limit: number, offset: number) {
@@ -99,6 +103,26 @@ export async function listDomainNamespaces(
); );
} }
// if (
// build == "saas" &&
// !isSubscribed(orgId!, tierMatrix.domainNamespaces)
// ) {
// return response<ListDomainNamespacesResponse>(res, {
// data: {
// domainNamespaces: [],
// pagination: {
// total: 0,
// limit,
// offset
// }
// },
// success: true,
// error: false,
// message: "No namespaces found. Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.",
// status: HttpCode.OK
// });
// }
const domainNamespacesList = await query(limit, offset); const domainNamespacesList = await query(limit, offset);
const [{ count }] = await db const [{ count }] = await db

View File

@@ -22,6 +22,8 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -87,7 +89,10 @@ export async function createEventStreamingDestination(
); );
} }
const { type, config, enabled } = parsedBody.data; const { type, config: configToSet, enabled } = parsedBody.data;
const key = config.getRawConfig().server.secret!;
const encryptedConfig = encrypt(configToSet, key);
const now = Date.now(); const now = Date.now();
@@ -96,7 +101,7 @@ export async function createEventStreamingDestination(
.values({ .values({
orgId, orgId,
type, type,
config, config: encryptedConfig,
enabled, enabled,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,

View File

@@ -22,6 +22,8 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -121,9 +123,22 @@ export async function listEventStreamingDestinations(
.from(eventStreamingDestinations) .from(eventStreamingDestinations)
.where(eq(eventStreamingDestinations.orgId, orgId)); .where(eq(eventStreamingDestinations.orgId, orgId));
const key = config.getRawConfig().server.secret!;
const decryptedList = list.map((dest) => {
try {
return { ...dest, config: decrypt(dest.config, key) };
} catch (err) {
logger.error(
`listEventStreamingDestinations: failed to decrypt config for destination ${dest.destinationId}`,
err
);
return { ...dest, config: "" };
}
});
return response<ListEventStreamingDestinationsResponse>(res, { return response<ListEventStreamingDestinationsResponse>(res, {
data: { data: {
destinations: list, destinations: decryptedList,
pagination: { pagination: {
total: count, total: count,
limit, limit,

View File

@@ -22,7 +22,8 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -110,14 +111,17 @@ export async function updateEventStreamingDestination(
); );
} }
const { type, config, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs } = parsedBody.data; const { type, config: configToUpdate, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs } = parsedBody.data;
const updateData: Record<string, unknown> = { const updateData: Record<string, unknown> = {
updatedAt: Date.now() updatedAt: Date.now()
}; };
if (type !== undefined) updateData.type = type; if (type !== undefined) updateData.type = type;
if (config !== undefined) updateData.config = config; if (configToUpdate !== undefined) {
const key = config.getRawConfig().server.secret!;
updateData.config = encrypt(configToUpdate, key);
}
if (enabled !== undefined) updateData.enabled = enabled; if (enabled !== undefined) updateData.enabled = enabled;
if (sendAccessLogs !== undefined) updateData.sendAccessLogs = sendAccessLogs; if (sendAccessLogs !== undefined) updateData.sendAccessLogs = sendAccessLogs;
if (sendActionLogs !== undefined) updateData.sendActionLogs = sendActionLogs; if (sendActionLogs !== undefined) updateData.sendActionLogs = sendActionLogs;

View File

@@ -24,14 +24,8 @@ import {
User, User,
certificates, certificates,
exitNodeOrgs, exitNodeOrgs,
RemoteExitNode,
olms,
newts,
clients,
sites,
domains, domains,
orgDomains, orgDomains,
targets,
loginPage, loginPage,
loginPageOrg, loginPageOrg,
LoginPage, LoginPage,
@@ -70,12 +64,9 @@ import {
updateAndGenerateEndpointDestinations, updateAndGenerateEndpointDestinations,
updateSiteBandwidth updateSiteBandwidth
} from "@server/routers/gerbil"; } from "@server/routers/gerbil";
import * as gerbil from "@server/routers/gerbil";
import logger from "@server/logger"; import logger from "@server/logger";
import { decryptData } from "@server/lib/encryption"; import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config"; import config from "@server/lib/config";
import privateConfig from "#private/lib/config";
import * as fs from "fs";
import { exchangeSession } from "@server/routers/badger"; import { exchangeSession } from "@server/routers/badger";
import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { validateResourceSessionToken } from "@server/auth/sessions/resource";
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
@@ -298,25 +289,11 @@ hybridRouter.get(
} }
); );
let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
if (encryptionKey) {
return; // already loaded
}
encryptionKeyHex =
privateConfig.getRawPrivateConfig().server.encryption_key;
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
}
// Get valid certificates for given domains (supports wildcard certs) // Get valid certificates for given domains (supports wildcard certs)
hybridRouter.get( hybridRouter.get(
"/certificates/domains", "/certificates/domains",
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
loadEncryptData(); // Ensure encryption key is loaded
const parsed = getCertificatesByDomainsQuerySchema.safeParse( const parsed = getCertificatesByDomainsQuerySchema.safeParse(
req.query req.query
); );
@@ -447,13 +424,13 @@ hybridRouter.get(
const result = filtered.map((cert) => { const result = filtered.map((cert) => {
// Decrypt and save certificate file // Decrypt and save certificate file
const decryptedCert = decryptData( const decryptedCert = decrypt(
cert.certFile!, // is not null from query cert.certFile!, // is not null from query
encryptionKey config.getRawConfig().server.secret!
); );
// Decrypt and save key file // Decrypt and save key file
const decryptedKey = decryptData(cert.keyFile!, encryptionKey); const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!);
// Return only the certificate data without org information // Return only the certificate data without org information
return { return {
@@ -833,9 +810,12 @@ hybridRouter.get(
) )
); );
logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows); logger.debug(
`User ${userId} has roles in org ${orgId}:`,
userOrgRoleRows
);
return response<{ roleId: number, roleName: string }[]>(res, { return response<{ roleId: number; roleName: string }[]>(res, {
data: userOrgRoleRows, data: userOrgRoleRows,
success: true, success: true,
error: false, error: false,

View File

@@ -92,9 +92,14 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
return; return;
} }
// Look up the org for this site // Look up the org for this site and check retention settings
const [site] = await db const [site] = await db
.select({ orgId: sites.orgId, orgSubnet: orgs.subnet }) .select({
orgId: sites.orgId,
orgSubnet: orgs.subnet,
settingsLogRetentionDaysConnection:
orgs.settingsLogRetentionDaysConnection
})
.from(sites) .from(sites)
.innerJoin(orgs, eq(sites.orgId, orgs.orgId)) .innerJoin(orgs, eq(sites.orgId, orgs.orgId))
.where(eq(sites.siteId, newt.siteId)); .where(eq(sites.siteId, newt.siteId));
@@ -108,6 +113,13 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
const orgId = site.orgId; const orgId = site.orgId;
if (site.settingsLogRetentionDaysConnection === 0) {
logger.debug(
`Connection log retention is disabled for org ${orgId}, skipping`
);
return;
}
// Extract the CIDR suffix (e.g. "/16") from the org subnet so we can // Extract the CIDR suffix (e.g. "/16") from the org subnet so we can
// reconstruct the exact subnet string stored on each client record. // reconstruct the exact subnet string stored on each client record.
const cidrSuffix = site.orgSubnet?.includes("/") const cidrSuffix = site.orgSubnet?.includes("/")

View File

@@ -0,0 +1,238 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 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 { db } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { inflate } from "zlib";
import { promisify } from "util";
import { logRequestAudit } from "@server/routers/badger/logRequestAudit";
import { getCountryCodeForIp } from "@server/lib/geoip";
export async function flushRequestLogToDb(): Promise<void> {
return;
}
const zlibInflate = promisify(inflate);
interface HTTPRequestLogData {
requestId: string;
resourceId: number; // siteResourceId
timestamp: string; // ISO 8601
method: string;
scheme: string; // "http" or "https"
host: string;
path: string;
rawQuery?: string;
userAgent?: string;
sourceAddr: string; // ip:port
tls: boolean;
}
/**
* Decompress a base64-encoded zlib-compressed string into parsed JSON.
*/
async function decompressRequestLog(
compressed: string
): Promise<HTTPRequestLogData[]> {
const compressedBuffer = Buffer.from(compressed, "base64");
const decompressed = await zlibInflate(compressedBuffer);
const jsonString = decompressed.toString("utf-8");
const parsed = JSON.parse(jsonString);
if (!Array.isArray(parsed)) {
throw new Error("Decompressed request log data is not an array");
}
return parsed;
}
export const handleRequestLogMessage: MessageHandler = async (context) => {
const { message, client } = context;
const newt = client as Newt;
if (!newt) {
logger.warn("Request log received but no newt client in context");
return;
}
if (!newt.siteId) {
logger.warn("Request log received but newt has no siteId");
return;
}
if (!message.data?.compressed) {
logger.warn("Request log message missing compressed data");
return;
}
// Look up the org for this site and check retention settings
const [site] = await db
.select({
orgId: sites.orgId,
orgSubnet: orgs.subnet,
settingsLogRetentionDaysRequest:
orgs.settingsLogRetentionDaysRequest
})
.from(sites)
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
.where(eq(sites.siteId, newt.siteId));
if (!site) {
logger.warn(
`Request log received but site ${newt.siteId} not found in database`
);
return;
}
const orgId = site.orgId;
if (site.settingsLogRetentionDaysRequest === 0) {
logger.debug(
`Request log retention is disabled for org ${orgId}, skipping`
);
return;
}
let entries: HTTPRequestLogData[];
try {
entries = await decompressRequestLog(message.data.compressed);
} catch (error) {
logger.error("Failed to decompress request log data:", error);
return;
}
if (entries.length === 0) {
return;
}
logger.debug(`Request log entries: ${JSON.stringify(entries)}`);
// Build a map from sourceIp → external endpoint string by joining clients
// with clientSitesAssociationsCache. The endpoint is the real-world IP:port
// of the client device and is used for GeoIP lookup.
const ipToEndpoint = new Map<string, string>();
const cidrSuffix = site.orgSubnet?.includes("/")
? site.orgSubnet.substring(site.orgSubnet.indexOf("/"))
: null;
if (cidrSuffix) {
const uniqueSourceAddrs = new Set<string>();
for (const entry of entries) {
if (entry.sourceAddr) {
uniqueSourceAddrs.add(entry.sourceAddr);
}
}
if (uniqueSourceAddrs.size > 0) {
const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => {
const ip = addr.includes(":") ? addr.split(":")[0] : addr;
return `${ip}${cidrSuffix}`;
});
const matchedClients = await db
.select({
subnet: clients.subnet,
endpoint: clientSitesAssociationsCache.endpoint
})
.from(clients)
.innerJoin(
clientSitesAssociationsCache,
and(
eq(
clientSitesAssociationsCache.clientId,
clients.clientId
),
eq(clientSitesAssociationsCache.siteId, newt.siteId)
)
)
.where(
and(
eq(clients.orgId, orgId),
inArray(clients.subnet, subnetQueries)
)
);
for (const c of matchedClients) {
if (c.endpoint) {
const ip = c.subnet.split("/")[0];
ipToEndpoint.set(ip, c.endpoint);
}
}
}
}
for (const entry of entries) {
if (
!entry.requestId ||
!entry.resourceId ||
!entry.method ||
!entry.scheme ||
!entry.host ||
!entry.path ||
!entry.sourceAddr
) {
logger.debug(
`Skipping request log entry with missing required fields: ${JSON.stringify(entry)}`
);
continue;
}
const originalRequestURL =
entry.scheme +
"://" +
entry.host +
entry.path +
(entry.rawQuery ? "?" + entry.rawQuery : "");
// Resolve the client's external endpoint for GeoIP lookup.
// sourceAddr is the WireGuard IP (possibly ip:port), so strip the port.
const sourceIp = entry.sourceAddr.includes(":")
? entry.sourceAddr.split(":")[0]
: entry.sourceAddr;
const endpoint = ipToEndpoint.get(sourceIp);
let location: string | undefined;
if (endpoint) {
const endpointIp = endpoint.includes(":")
? endpoint.split(":")[0]
: endpoint;
location = await getCountryCodeForIp(endpointIp);
}
await logRequestAudit(
{
action: true,
reason: 108,
siteResourceId: entry.resourceId,
orgId,
location
},
{
path: entry.path,
originalRequestURL,
scheme: entry.scheme,
host: entry.host,
method: entry.method,
tls: entry.tls,
requestIp: entry.sourceAddr
}
);
}
logger.debug(
`Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})`
);
};

View File

@@ -12,3 +12,4 @@
*/ */
export * from "./handleConnectionLogMessage"; export * from "./handleConnectionLogMessage";
export * from "./handleRequestLogMessage";

View File

@@ -21,7 +21,7 @@ import {
roles, roles,
roundTripMessageTracker, roundTripMessageTracker,
siteResources, siteResources,
sites, siteNetworks,
userOrgs userOrgs
} from "@server/db"; } from "@server/db";
import { logAccessAudit } from "#private/lib/logAccessAudit"; import { logAccessAudit } from "#private/lib/logAccessAudit";
@@ -63,10 +63,12 @@ const bodySchema = z
export type SignSshKeyResponse = { export type SignSshKeyResponse = {
certificate: string; certificate: string;
messageIds: number[];
messageId: number; messageId: number;
sshUsername: string; sshUsername: string;
sshHost: string; sshHost: string;
resourceId: number; resourceId: number;
siteIds: number[];
siteId: number; siteId: number;
keyId: string; keyId: string;
validPrincipals: string[]; validPrincipals: string[];
@@ -260,10 +262,7 @@ export async function signSshKey(
.update(userOrgs) .update(userOrgs)
.set({ pamUsername: usernameToUse }) .set({ pamUsername: usernameToUse })
.where( .where(
and( and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
eq(userOrgs.orgId, orgId),
eq(userOrgs.userId, userId)
)
); );
} else { } else {
usernameToUse = userOrg.pamUsername; usernameToUse = userOrg.pamUsername;
@@ -395,21 +394,12 @@ export async function signSshKey(
homedir = roleRows[0].sshCreateHomeDir ?? null; homedir = roleRows[0].sshCreateHomeDir ?? null;
} }
// get the site const sites = await db
const [newt] = await db .select({ siteId: siteNetworks.siteId })
.select() .from(siteNetworks)
.from(newts) .where(eq(siteNetworks.networkId, resource.networkId!));
.where(eq(newts.siteId, resource.siteId))
.limit(1);
if (!newt) { const siteIds = sites.map((site) => site.siteId);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Site associated with resource not found"
)
);
}
// Sign the public key // Sign the public key
const now = BigInt(Math.floor(Date.now() / 1000)); const now = BigInt(Math.floor(Date.now() / 1000));
@@ -423,43 +413,64 @@ export async function signSshKey(
validBefore: now + validFor validBefore: now + validFor
}); });
const [message] = await db const messageIds: number[] = [];
.insert(roundTripMessageTracker) for (const siteId of siteIds) {
.values({ // get the site
wsClientId: newt.newtId, const [newt] = await db
messageType: `newt/pam/connection`, .select()
sentAt: Math.floor(Date.now() / 1000) .from(newts)
}) .where(eq(newts.siteId, siteId))
.returning(); .limit(1);
if (!message) { if (!newt) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create message tracker entry" "Site associated with resource not found"
) )
); );
}
await sendToClient(newt.newtId, {
type: `newt/pam/connection`,
data: {
messageId: message.messageId,
orgId: orgId,
agentPort: resource.authDaemonPort ?? 22123,
externalAuthDaemon: resource.authDaemonMode === "remote",
agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse,
niceId: resource.niceId,
metadata: {
sudoMode: sudoMode,
sudoCommands: parsedSudoCommands,
homedir: homedir,
groups: parsedGroups
}
} }
});
const [message] = await db
.insert(roundTripMessageTracker)
.values({
wsClientId: newt.newtId,
messageType: `newt/pam/connection`,
sentAt: Math.floor(Date.now() / 1000)
})
.returning();
if (!message) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create message tracker entry"
)
);
}
messageIds.push(message.messageId);
await sendToClient(newt.newtId, {
type: `newt/pam/connection`,
data: {
messageId: message.messageId,
orgId: orgId,
agentPort: resource.authDaemonPort ?? 22123,
externalAuthDaemon: resource.authDaemonMode === "remote",
agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse,
niceId: resource.niceId,
metadata: {
sudoMode: sudoMode,
sudoCommands: parsedSudoCommands,
homedir: homedir,
groups: parsedGroups
}
}
});
}
const expiresIn = Number(validFor); // seconds const expiresIn = Number(validFor); // seconds
@@ -480,7 +491,7 @@ export async function signSshKey(
metadata: JSON.stringify({ metadata: JSON.stringify({
resourceId: resource.siteResourceId, resourceId: resource.siteResourceId,
resource: resource.name, resource: resource.name,
siteId: resource.siteId, siteIds: siteIds
}) })
}); });
@@ -494,7 +505,7 @@ export async function signSshKey(
: undefined, : undefined,
metadata: { metadata: {
resourceName: resource.name, resourceName: resource.name,
siteId: resource.siteId, siteId: siteIds[0],
sshUsername: usernameToUse, sshUsername: usernameToUse,
sshHost: sshHost sshHost: sshHost
}, },
@@ -505,11 +516,13 @@ export async function signSshKey(
return response<SignSshKeyResponse>(res, { return response<SignSshKeyResponse>(res, {
data: { data: {
certificate: cert.certificate, certificate: cert.certificate,
messageId: message.messageId, messageIds: messageIds,
messageId: messageIds[0], // just pick the first one for backward compatibility
sshUsername: usernameToUse, sshUsername: usernameToUse,
sshHost: sshHost, sshHost: sshHost,
resourceId: resource.siteResourceId, resourceId: resource.siteResourceId,
siteId: resource.siteId, siteIds: siteIds,
siteId: siteIds[0], // just pick the first one for backward compatibility
keyId: cert.keyId, keyId: cert.keyId,
validPrincipals: cert.validPrincipals, validPrincipals: cert.validPrincipals,
validAfter: cert.validAfter.toISOString(), validAfter: cert.validAfter.toISOString(),

View File

@@ -18,12 +18,13 @@ import {
} from "#private/routers/remoteExitNode"; } from "#private/routers/remoteExitNode";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { build } from "@server/build"; import { build } from "@server/build";
import { handleConnectionLogMessage } from "#private/routers/newt"; import { handleConnectionLogMessage, handleRequestLogMessage } from "#private/routers/newt";
export const messageHandlers: Record<string, MessageHandler> = { export const messageHandlers: Record<string, MessageHandler> = {
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage, "remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
"remoteExitNode/ping": handleRemoteExitNodePingMessage, "remoteExitNode/ping": handleRemoteExitNodePingMessage,
"newt/access-log": handleConnectionLogMessage, "newt/access-log": handleConnectionLogMessage,
"newt/request-log": handleRequestLogMessage,
}; };
if (build != "saas") { if (build != "saas") {

View File

@@ -1,8 +1,8 @@
import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db"; import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm"; import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi"; import { OpenAPITags } from "@server/openApi";
import { z } from "zod"; import { z } from "zod";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -92,7 +92,10 @@ function getWhere(data: Q) {
lt(requestAuditLog.timestamp, data.timeEnd), lt(requestAuditLog.timestamp, data.timeEnd),
eq(requestAuditLog.orgId, data.orgId), eq(requestAuditLog.orgId, data.orgId),
data.resourceId data.resourceId
? eq(requestAuditLog.resourceId, data.resourceId) ? or(
eq(requestAuditLog.resourceId, data.resourceId),
eq(requestAuditLog.siteResourceId, data.resourceId)
)
: undefined, : undefined,
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined, data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
data.method ? eq(requestAuditLog.method, data.method) : undefined, data.method ? eq(requestAuditLog.method, data.method) : undefined,
@@ -110,15 +113,16 @@ export function queryRequest(data: Q) {
return primaryLogsDb return primaryLogsDb
.select({ .select({
id: requestAuditLog.id, id: requestAuditLog.id,
timestamp: requestAuditLog.timestamp, timestamp: requestAuditLog.timestamp,
orgId: requestAuditLog.orgId, orgId: requestAuditLog.orgId,
action: requestAuditLog.action, action: requestAuditLog.action,
reason: requestAuditLog.reason, reason: requestAuditLog.reason,
actorType: requestAuditLog.actorType, actorType: requestAuditLog.actorType,
actor: requestAuditLog.actor, actor: requestAuditLog.actor,
actorId: requestAuditLog.actorId, actorId: requestAuditLog.actorId,
resourceId: requestAuditLog.resourceId, resourceId: requestAuditLog.resourceId,
ip: requestAuditLog.ip, siteResourceId: requestAuditLog.siteResourceId,
ip: requestAuditLog.ip,
location: requestAuditLog.location, location: requestAuditLog.location,
userAgent: requestAuditLog.userAgent, userAgent: requestAuditLog.userAgent,
metadata: requestAuditLog.metadata, metadata: requestAuditLog.metadata,
@@ -137,37 +141,73 @@ export function queryRequest(data: Q) {
} }
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) { async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) {
// If logs database is the same as main database, we can do a join
// Otherwise, we need to fetch resource details separately
const resourceIds = logs const resourceIds = logs
.map(log => log.resourceId) .map(log => log.resourceId)
.filter((id): id is number => id !== null && id !== undefined); .filter((id): id is number => id !== null && id !== undefined);
if (resourceIds.length === 0) { const siteResourceIds = logs
.filter(log => log.resourceId == null && log.siteResourceId != null)
.map(log => log.siteResourceId)
.filter((id): id is number => id !== null && id !== undefined);
if (resourceIds.length === 0 && siteResourceIds.length === 0) {
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null })); return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
} }
// Fetch resource details from main database const resourceMap = new Map<number, { name: string | null; niceId: string | null }>();
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name,
niceId: resources.niceId
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
// Create a map for quick lookup if (resourceIds.length > 0) {
const resourceMap = new Map( const resourceDetails = await primaryDb
resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }]) .select({
); resourceId: resources.resourceId,
name: resources.name,
niceId: resources.niceId
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
for (const r of resourceDetails) {
resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId });
}
}
const siteResourceMap = new Map<number, { name: string | null; niceId: string | null }>();
if (siteResourceIds.length > 0) {
const siteResourceDetails = await primaryDb
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name,
niceId: siteResources.niceId
})
.from(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds));
for (const r of siteResourceDetails) {
siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId });
}
}
// Enrich logs with resource details // Enrich logs with resource details
return logs.map(log => ({ return logs.map(log => {
...log, if (log.resourceId != null) {
resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null, const details = resourceMap.get(log.resourceId);
resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null return {
})); ...log,
resourceName: details?.name ?? null,
resourceNiceId: details?.niceId ?? null
};
} else if (log.siteResourceId != null) {
const details = siteResourceMap.get(log.siteResourceId);
return {
...log,
resourceId: log.siteResourceId,
resourceName: details?.name ?? null,
resourceNiceId: details?.niceId ?? null
};
}
return { ...log, resourceName: null, resourceNiceId: null };
});
} }
export function countRequestQuery(data: Q) { export function countRequestQuery(data: Q) {
@@ -211,7 +251,8 @@ async function queryUniqueFilterAttributes(
uniqueLocations, uniqueLocations,
uniqueHosts, uniqueHosts,
uniquePaths, uniquePaths,
uniqueResources uniqueResources,
uniqueSiteResources
] = await Promise.all([ ] = await Promise.all([
primaryLogsDb primaryLogsDb
.selectDistinct({ actor: requestAuditLog.actor }) .selectDistinct({ actor: requestAuditLog.actor })
@@ -239,6 +280,13 @@ async function queryUniqueFilterAttributes(
}) })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions) .where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
primaryLogsDb
.selectDistinct({
id: requestAuditLog.siteResourceId
})
.from(requestAuditLog)
.where(and(baseConditions, isNull(requestAuditLog.resourceId)))
.limit(DISTINCT_LIMIT + 1) .limit(DISTINCT_LIMIT + 1)
]); ]);
@@ -259,6 +307,10 @@ async function queryUniqueFilterAttributes(
.map(row => row.id) .map(row => row.id)
.filter((id): id is number => id !== null); .filter((id): id is number => id !== null);
const siteResourceIds = uniqueSiteResources
.map(row => row.id)
.filter((id): id is number => id !== null);
let resourcesWithNames: Array<{ id: number; name: string | null }> = []; let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
if (resourceIds.length > 0) { if (resourceIds.length > 0) {
@@ -270,10 +322,31 @@ async function queryUniqueFilterAttributes(
.from(resources) .from(resources)
.where(inArray(resources.resourceId, resourceIds)); .where(inArray(resources.resourceId, resourceIds));
resourcesWithNames = resourceDetails.map(r => ({ resourcesWithNames = [
id: r.resourceId, ...resourcesWithNames,
name: r.name ...resourceDetails.map(r => ({
})); id: r.resourceId,
name: r.name
}))
];
}
if (siteResourceIds.length > 0) {
const siteResourceDetails = await primaryDb
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name
})
.from(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds));
resourcesWithNames = [
...resourcesWithNames,
...siteResourceDetails.map(r => ({
id: r.siteResourceId,
name: r.name
}))
];
} }
return { return {

View File

@@ -28,6 +28,7 @@ export type QueryRequestAuditLogResponse = {
actor: string | null; actor: string | null;
actorId: string | null; actorId: string | null;
resourceId: number | null; resourceId: number | null;
siteResourceId: number | null;
resourceNiceId: string | null; resourceNiceId: string | null;
resourceName: string | null; resourceName: string | null;
ip: string | null; ip: string | null;

View File

@@ -18,6 +18,7 @@ Reasons:
105 - Valid Password 105 - Valid Password
106 - Valid email 106 - Valid email
107 - Valid SSO 107 - Valid SSO
108 - Connected Client
201 - Resource Not Found 201 - Resource Not Found
202 - Resource Blocked 202 - Resource Blocked
@@ -38,6 +39,7 @@ const auditLogBuffer: Array<{
metadata: any; metadata: any;
action: boolean; action: boolean;
resourceId?: number; resourceId?: number;
siteResourceId?: number;
reason: number; reason: number;
location?: string; location?: string;
originalRequestURL: string; originalRequestURL: string;
@@ -186,6 +188,7 @@ export async function logRequestAudit(
action: boolean; action: boolean;
reason: number; reason: number;
resourceId?: number; resourceId?: number;
siteResourceId?: number;
orgId?: string; orgId?: string;
location?: string; location?: string;
user?: { username: string; userId: string }; user?: { username: string; userId: string };
@@ -262,6 +265,7 @@ export async function logRequestAudit(
metadata: sanitizeString(metadata), metadata: sanitizeString(metadata),
action: data.action, action: data.action,
resourceId: data.resourceId, resourceId: data.resourceId,
siteResourceId: data.siteResourceId,
reason: data.reason, reason: data.reason,
location: sanitizeString(data.location), location: sanitizeString(data.location),
originalRequestURL: sanitizeString(body.originalRequestURL) ?? "", originalRequestURL: sanitizeString(body.originalRequestURL) ?? "",

View File

@@ -440,6 +440,12 @@ authenticated.get(
resource.getUserResources resource.getUserResources
); );
authenticated.get(
"/org/:orgId/user-resource-aliases",
verifyOrgAccess,
resource.listUserResourceAliases
);
authenticated.get( authenticated.get(
"/org/:orgId/domains", "/org/:orgId/domains",
verifyOrgAccess, verifyOrgAccess,

View File

@@ -171,9 +171,8 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
} }
// PostgreSQL: batch UPDATE … FROM (VALUES …) — single round-trip per chunk. // PostgreSQL: batch UPDATE … FROM (VALUES …) — single round-trip per chunk.
const valuesList = chunk.map( const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) =>
([publicKey, { bytesIn, bytesOut }]) => sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)`
sql`(${publicKey}, ${bytesIn}, ${bytesOut})`
); );
const valuesClause = sql.join(valuesList, sql`, `); const valuesClause = sql.join(valuesList, sql`, `);
return dbQueryRows<{ orgId: string; pubKey: string }>(sql` return dbQueryRows<{ orgId: string; pubKey: string }>(sql`

View File

@@ -4,8 +4,10 @@ import {
clientSitesAssociationsCache, clientSitesAssociationsCache,
db, db,
ExitNode, ExitNode,
networks,
resources, resources,
Site, Site,
siteNetworks,
siteResources, siteResources,
targetHealthCheck, targetHealthCheck,
targets targets
@@ -137,11 +139,14 @@ export async function buildClientConfigurationForNewtClient(
// Filter out any null values from peers that didn't have an olm // Filter out any null values from peers that didn't have an olm
const validPeers = peers.filter((peer) => peer !== null); const validPeers = peers.filter((peer) => peer !== null);
// Get all enabled site resources for this site // Get all enabled site resources for this site by joining through siteNetworks and networks
const allSiteResources = await db const allSiteResources = await db
.select() .select()
.from(siteResources) .from(siteResources)
.where(eq(siteResources.siteId, siteId)); .innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where(eq(siteNetworks.siteId, siteId))
.then((rows) => rows.map((r) => r.siteResources));
const targetsToSend: SubnetProxyTargetV2[] = []; const targetsToSend: SubnetProxyTargetV2[] = [];
@@ -168,13 +173,13 @@ export async function buildClientConfigurationForNewtClient(
) )
); );
const resourceTarget = generateSubnetProxyTargetV2( const resourceTargets = await generateSubnetProxyTargetV2(
resource, resource,
resourceClients resourceClients
); );
if (resourceTarget) { if (resourceTargets) {
targetsToSend.push(resourceTarget); targetsToSend.push(...resourceTargets);
} }
} }

View File

@@ -10,7 +10,7 @@ import { convertTargetsIfNessicary } from "../client/targets";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config"; import config from "@server/lib/config";
export const handleGetConfigMessage: MessageHandler = async (context) => { export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
const newt = client as Newt; const newt = client as Newt;
@@ -56,7 +56,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) { if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
logger.warn( logger.warn(
`Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?` `Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the site reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
); );
return; return;
} }
@@ -113,7 +113,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
exitNode exitNode
); );
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format
return { return {
message: { message: {

View File

@@ -0,0 +1,9 @@
import { MessageHandler } from "@server/routers/ws";
export async function flushRequestLogToDb(): Promise<void> {
return;
}
export const handleRequestLogMessage: MessageHandler = async (context) => {
return;
};

View File

@@ -2,11 +2,12 @@ export * from "./createNewt";
export * from "./getNewtToken"; export * from "./getNewtToken";
export * from "./handleNewtRegisterMessage"; export * from "./handleNewtRegisterMessage";
export * from "./handleReceiveBandwidthMessage"; export * from "./handleReceiveBandwidthMessage";
export * from "./handleGetConfigMessage"; export * from "./handleNewtGetConfigMessage";
export * from "./handleSocketMessages"; export * from "./handleSocketMessages";
export * from "./handleNewtPingRequestMessage"; export * from "./handleNewtPingRequestMessage";
export * from "./handleApplyBlueprintMessage"; export * from "./handleApplyBlueprintMessage";
export * from "./handleNewtPingMessage"; export * from "./handleNewtPingMessage";
export * from "./handleNewtDisconnectingMessage"; export * from "./handleNewtDisconnectingMessage";
export * from "./handleConnectionLogMessage"; export * from "./handleConnectionLogMessage";
export * from "./handleRequestLogMessage";
export * from "./registerNewt"; export * from "./registerNewt";

View File

@@ -1,6 +1,6 @@
import { db } from "@server/db"; import { db } from "@server/db";
import { sites, clients, olms } from "@server/db"; import { sites, clients, olms } from "@server/db";
import { eq, inArray } from "drizzle-orm"; import { inArray } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
/** /**
@@ -21,7 +21,7 @@ import logger from "@server/logger";
*/ */
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
const MAX_RETRIES = 2; const MAX_RETRIES = 5;
const BASE_DELAY_MS = 50; const BASE_DELAY_MS = 50;
// ── Site (newt) pings ────────────────────────────────────────────────── // ── Site (newt) pings ──────────────────────────────────────────────────
@@ -36,6 +36,14 @@ const pendingOlmArchiveResets: Set<string> = new Set();
let flushTimer: NodeJS.Timeout | null = null; let flushTimer: NodeJS.Timeout | null = null;
/**
* Guard that prevents two flush cycles from running concurrently.
* setInterval does not await async callbacks, so without this a slow flush
* (e.g. due to DB latency) would overlap with the next scheduled cycle and
* the two concurrent bulk UPDATEs would deadlock each other.
*/
let isFlushing = false;
// ── Public API ───────────────────────────────────────────────────────── // ── Public API ─────────────────────────────────────────────────────────
/** /**
@@ -72,6 +80,12 @@ export function recordClientPing(
/** /**
* Flush all accumulated site pings to the database. * Flush all accumulated site pings to the database.
*
* Each batch of up to BATCH_SIZE rows is written with a **single** UPDATE
* statement. We use the maximum timestamp across the batch so that `lastPing`
* reflects the most recent ping seen for any site in the group. This avoids
* the multi-statement transaction that previously created additional
* row-lock ordering hazards.
*/ */
async function flushSitePingsToDb(): Promise<void> { async function flushSitePingsToDb(): Promise<void> {
if (pendingSitePings.size === 0) { if (pendingSitePings.size === 0) {
@@ -83,55 +97,35 @@ async function flushSitePingsToDb(): Promise<void> {
const pingsToFlush = new Map(pendingSitePings); const pingsToFlush = new Map(pendingSitePings);
pendingSitePings.clear(); pendingSitePings.clear();
// Sort by siteId for consistent lock ordering (prevents deadlocks) const entries = Array.from(pingsToFlush.entries());
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
([a], [b]) => a - b
);
const BATCH_SIZE = 50; const BATCH_SIZE = 50;
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) { for (let i = 0; i < entries.length; i += BATCH_SIZE) {
const batch = sortedEntries.slice(i, i + BATCH_SIZE); const batch = entries.slice(i, i + BATCH_SIZE);
// Use the latest timestamp in the batch so that `lastPing` always
// moves forward. Using a single timestamp for the whole batch means
// we only ever need one UPDATE statement (no transaction).
const maxTimestamp = Math.max(...batch.map(([, ts]) => ts));
const siteIds = batch.map(([id]) => id);
try { try {
await withRetry(async () => { await withRetry(async () => {
// Group by timestamp for efficient bulk updates await db
const byTimestamp = new Map<number, number[]>(); .update(sites)
for (const [siteId, timestamp] of batch) { .set({
const group = byTimestamp.get(timestamp) || []; online: true,
group.push(siteId); lastPing: maxTimestamp
byTimestamp.set(timestamp, group); })
} .where(inArray(sites.siteId, siteIds));
if (byTimestamp.size === 1) {
const [timestamp, siteIds] = Array.from(
byTimestamp.entries()
)[0];
await db
.update(sites)
.set({
online: true,
lastPing: timestamp
})
.where(inArray(sites.siteId, siteIds));
} else {
await db.transaction(async (tx) => {
for (const [timestamp, siteIds] of byTimestamp) {
await tx
.update(sites)
.set({
online: true,
lastPing: timestamp
})
.where(inArray(sites.siteId, siteIds));
}
});
}
}, "flushSitePingsToDb"); }, "flushSitePingsToDb");
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`, `Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`,
{ error } { error }
); );
// Re-queue only if the preserved timestamp is newer than any
// update that may have landed since we snapshotted.
for (const [siteId, timestamp] of batch) { for (const [siteId, timestamp] of batch) {
const existing = pendingSitePings.get(siteId); const existing = pendingSitePings.get(siteId);
if (!existing || existing < timestamp) { if (!existing || existing < timestamp) {
@@ -144,6 +138,8 @@ async function flushSitePingsToDb(): Promise<void> {
/** /**
* Flush all accumulated client (OLM) pings to the database. * Flush all accumulated client (OLM) pings to the database.
*
* Same single-UPDATE-per-batch approach as `flushSitePingsToDb`.
*/ */
async function flushClientPingsToDb(): Promise<void> { async function flushClientPingsToDb(): Promise<void> {
if (pendingClientPings.size === 0 && pendingOlmArchiveResets.size === 0) { if (pendingClientPings.size === 0 && pendingOlmArchiveResets.size === 0) {
@@ -159,51 +155,25 @@ async function flushClientPingsToDb(): Promise<void> {
// ── Flush client pings ───────────────────────────────────────────── // ── Flush client pings ─────────────────────────────────────────────
if (pingsToFlush.size > 0) { if (pingsToFlush.size > 0) {
const sortedEntries = Array.from(pingsToFlush.entries()).sort( const entries = Array.from(pingsToFlush.entries());
([a], [b]) => a - b
);
const BATCH_SIZE = 50; const BATCH_SIZE = 50;
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) { for (let i = 0; i < entries.length; i += BATCH_SIZE) {
const batch = sortedEntries.slice(i, i + BATCH_SIZE); const batch = entries.slice(i, i + BATCH_SIZE);
const maxTimestamp = Math.max(...batch.map(([, ts]) => ts));
const clientIds = batch.map(([id]) => id);
try { try {
await withRetry(async () => { await withRetry(async () => {
const byTimestamp = new Map<number, number[]>(); await db
for (const [clientId, timestamp] of batch) { .update(clients)
const group = byTimestamp.get(timestamp) || []; .set({
group.push(clientId); lastPing: maxTimestamp,
byTimestamp.set(timestamp, group); online: true,
} archived: false
})
if (byTimestamp.size === 1) { .where(inArray(clients.clientId, clientIds));
const [timestamp, clientIds] = Array.from(
byTimestamp.entries()
)[0];
await db
.update(clients)
.set({
lastPing: timestamp,
online: true,
archived: false
})
.where(inArray(clients.clientId, clientIds));
} else {
await db.transaction(async (tx) => {
for (const [timestamp, clientIds] of byTimestamp) {
await tx
.update(clients)
.set({
lastPing: timestamp,
online: true,
archived: false
})
.where(
inArray(clients.clientId, clientIds)
);
}
});
}
}, "flushClientPingsToDb"); }, "flushClientPingsToDb");
} catch (error) { } catch (error) {
logger.error( logger.error(
@@ -260,7 +230,12 @@ export async function flushPingsToDb(): Promise<void> {
/** /**
* Simple retry wrapper with exponential backoff for transient errors * Simple retry wrapper with exponential backoff for transient errors
* (connection timeouts, unexpected disconnects). * (deadlocks, connection timeouts, unexpected disconnects).
*
* PostgreSQL deadlocks (40P01) are always safe to retry: the database
* guarantees exactly one winner per deadlock pair, so the loser just needs
* to try again. MAX_RETRIES is intentionally higher than typical connection
* retry budgets to give deadlock victims enough chances to succeed.
*/ */
async function withRetry<T>( async function withRetry<T>(
operation: () => Promise<T>, operation: () => Promise<T>,
@@ -277,7 +252,8 @@ async function withRetry<T>(
const jitter = Math.random() * baseDelay; const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter; const delay = baseDelay + jitter;
logger.warn( logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms` `Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
); );
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
continue; continue;
@@ -288,14 +264,14 @@ async function withRetry<T>(
} }
/** /**
* Detect transient connection errors that are safe to retry. * Detect transient errors that are safe to retry.
*/ */
function isTransientError(error: any): boolean { function isTransientError(error: any): boolean {
if (!error) return false; if (!error) return false;
const message = (error.message || "").toLowerCase(); const message = (error.message || "").toLowerCase();
const causeMessage = (error.cause?.message || "").toLowerCase(); const causeMessage = (error.cause?.message || "").toLowerCase();
const code = error.code || ""; const code = error.code || error.cause?.code || "";
// Connection timeout / terminated // Connection timeout / terminated
if ( if (
@@ -308,12 +284,17 @@ function isTransientError(error: any): boolean {
return true; return true;
} }
// PostgreSQL deadlock // PostgreSQL deadlock detected — always safe to retry (one winner guaranteed)
if (code === "40P01" || message.includes("deadlock")) { if (code === "40P01" || message.includes("deadlock")) {
return true; return true;
} }
// ECONNRESET, ECONNREFUSED, EPIPE // PostgreSQL serialization failure
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if ( if (
code === "ECONNRESET" || code === "ECONNRESET" ||
code === "ECONNREFUSED" || code === "ECONNREFUSED" ||
@@ -337,12 +318,26 @@ export function startPingAccumulator(): void {
} }
flushTimer = setInterval(async () => { flushTimer = setInterval(async () => {
// Skip this tick if the previous flush is still in progress.
// setInterval does not await async callbacks, so without this guard
// two flush cycles can run concurrently and deadlock each other on
// overlapping bulk UPDATE statements.
if (isFlushing) {
logger.debug(
"Ping accumulator: previous flush still in progress, skipping cycle"
);
return;
}
isFlushing = true;
try { try {
await flushPingsToDb(); await flushPingsToDb();
} catch (error) { } catch (error) {
logger.error("Unhandled error in ping accumulator flush", { logger.error("Unhandled error in ping accumulator flush", {
error error
}); });
} finally {
isFlushing = false;
} }
}, FLUSH_INTERVAL_MS); }, FLUSH_INTERVAL_MS);
@@ -364,7 +359,22 @@ export async function stopPingAccumulator(): Promise<void> {
flushTimer = null; flushTimer = null;
} }
// Final flush to persist any remaining pings // Final flush to persist any remaining pings.
// Wait for any in-progress flush to finish first so we don't race.
if (isFlushing) {
logger.debug(
"Ping accumulator: waiting for in-progress flush before stopping…"
);
await new Promise<void>((resolve) => {
const poll = setInterval(() => {
if (!isFlushing) {
clearInterval(poll);
resolve();
}
}, 50);
});
}
try { try {
await flushPingsToDb(); await flushPingsToDb();
} catch (error) { } catch (error) {
@@ -379,4 +389,4 @@ export async function stopPingAccumulator(): Promise<void> {
*/ */
export function getPendingPingCount(): number { export function getPendingPingCount(): number {
return pendingSitePings.size + pendingClientPings.size; return pendingSitePings.size + pendingClientPings.size;
} }

View File

@@ -27,7 +27,7 @@ import { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { FeatureId } from "@server/lib/billing";
import { INSPECT_MAX_BYTES } from "buffer"; import { INSPECT_MAX_BYTES } from "buffer";
import { v } from "@faker-js/faker/dist/airline-Dz1uGqgJ"; import { getNextAvailableClientSubnet } from "@server/lib/ip";
const bodySchema = z.object({ const bodySchema = z.object({
provisioningKey: z.string().nonempty(), provisioningKey: z.string().nonempty(),
@@ -152,6 +152,11 @@ export async function registerNewt(
createHttpError(HttpCode.NOT_FOUND, "Organization not found") createHttpError(HttpCode.NOT_FOUND, "Organization not found")
); );
} }
if (!org.subnet) {
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Organization subnet not found")
);
}
// SaaS billing check // SaaS billing check
if (build == "saas") { if (build == "saas") {
@@ -190,6 +195,20 @@ export async function registerNewt(
let newSiteId: number | undefined; let newSiteId: number | undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const newClientAddress = await getNextAvailableClientSubnet(orgId);
if (!newClientAddress) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"No available subnet found"
)
);
}
let clientAddress = newClientAddress.split("/")[0];
clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org
// Create the site (type "newt", name = niceId) // Create the site (type "newt", name = niceId)
const [newSite] = await trx const [newSite] = await trx
.insert(sites) .insert(sites)
@@ -197,6 +216,7 @@ export async function registerNewt(
orgId, orgId,
name: name || niceId, name: name || niceId,
niceId, niceId,
address: clientAddress,
type: "newt", type: "newt",
dockerSocketEnabled: true, dockerSocketEnabled: true,
status: keyRecord.approveNewSites ? "approved" : "pending", status: keyRecord.approveNewSites ? "approved" : "pending",

View File

@@ -4,6 +4,8 @@ import {
clientSitesAssociationsCache, clientSitesAssociationsCache,
db, db,
exitNodes, exitNodes,
networks,
siteNetworks,
siteResources, siteResources,
sites sites
} from "@server/db"; } from "@server/db";
@@ -59,9 +61,17 @@ export async function buildSiteConfigurationForOlmClient(
clientSiteResourcesAssociationsCache.siteResourceId clientSiteResourcesAssociationsCache.siteResourceId
) )
) )
.innerJoin(
networks,
eq(siteResources.networkId, networks.networkId)
)
.innerJoin(
siteNetworks,
eq(networks.networkId, siteNetworks.networkId)
)
.where( .where(
and( and(
eq(siteResources.siteId, site.siteId), eq(siteNetworks.siteId, site.siteId),
eq( eq(
clientSiteResourcesAssociationsCache.clientId, clientSiteResourcesAssociationsCache.clientId,
client.clientId client.clientId
@@ -69,6 +79,7 @@ export async function buildSiteConfigurationForOlmClient(
) )
); );
if (jitMode) { if (jitMode) {
// Add site configuration to the array // Add site configuration to the array
siteConfigurations.push({ siteConfigurations.push({

View File

@@ -17,7 +17,6 @@ import { getUserDeviceName } from "@server/db/names";
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
import { OlmErrorCodes, sendOlmError } from "./error"; import { OlmErrorCodes, sendOlmError } from "./error";
import { handleFingerprintInsertion } from "./fingerprintingUtils"; import { handleFingerprintInsertion } from "./fingerprintingUtils";
import { Alias } from "@server/lib/ip";
import { build } from "@server/build"; import { build } from "@server/build";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config"; import config from "@server/lib/config";

View File

@@ -4,10 +4,12 @@ import {
db, db,
exitNodes, exitNodes,
Site, Site,
siteResources siteNetworks,
siteResources,
sites
} from "@server/db"; } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { clients, Olm, sites } from "@server/db"; import { clients, Olm } from "@server/db";
import { and, eq, or } from "drizzle-orm"; import { and, eq, or } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { initPeerAddHandshake } from "./peers"; import { initPeerAddHandshake } from "./peers";
@@ -44,20 +46,31 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
const { siteId, resourceId, chainId } = message.data; const { siteId, resourceId, chainId } = message.data;
let site: Site | null = null; const sendCancel = async () => {
await sendToClient(
olm.olmId,
{
type: "olm/wg/peer/chain/cancel",
data: { chainId }
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
};
let sitesToProcess: Site[] = [];
if (siteId) { if (siteId) {
// get the site
const [siteRes] = await db const [siteRes] = await db
.select() .select()
.from(sites) .from(sites)
.where(eq(sites.siteId, siteId)) .where(eq(sites.siteId, siteId))
.limit(1); .limit(1);
if (siteRes) { if (siteRes) {
site = siteRes; sitesToProcess = [siteRes];
} }
} } else if (resourceId) {
if (resourceId && !site) {
const resources = await db const resources = await db
.select() .select()
.from(siteResources) .from(siteResources)
@@ -72,27 +85,17 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
); );
if (!resources || resources.length === 0) { if (!resources || resources.length === 0) {
logger.error(`handleOlmServerPeerAddMessage: Resource not found`); logger.error(
// cancel the request from the olm side to not keep doing this `handleOlmServerInitAddPeerHandshake: Resource not found`
await sendToClient( );
olm.olmId, await sendCancel();
{
type: "olm/wg/peer/chain/cancel",
data: {
chainId
}
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
return; return;
} }
if (resources.length > 1) { if (resources.length > 1) {
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches // error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
logger.error( logger.error(
`handleOlmServerPeerAddMessage: Multiple resources found matching the criteria` `handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
); );
return; return;
} }
@@ -117,125 +120,120 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
if (currentResourceAssociationCaches.length === 0) { if (currentResourceAssociationCaches.length === 0) {
logger.error( logger.error(
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}` `handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
); );
// cancel the request from the olm side to not keep doing this await sendCancel();
await sendToClient(
olm.olmId,
{
type: "olm/wg/peer/chain/cancel",
data: {
chainId
}
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
return; return;
} }
const siteIdFromResource = resource.siteId; if (!resource.networkId) {
// get the site
const [siteRes] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteIdFromResource));
if (!siteRes) {
logger.error( logger.error(
`handleOlmServerPeerAddMessage: Site with ID ${site} not found` `handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
); );
await sendCancel();
return; return;
} }
site = siteRes; // Get all sites associated with this resource's network via siteNetworks
const siteRows = await db
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(eq(siteNetworks.networkId, resource.networkId));
if (!siteRows || siteRows.length === 0) {
logger.error(
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
);
await sendCancel();
return;
}
// Fetch full site objects for all network members
const foundSites = await Promise.all(
siteRows.map(async ({ siteId: sid }) => {
const [s] = await db
.select()
.from(sites)
.where(eq(sites.siteId, sid))
.limit(1);
return s ?? null;
})
);
sitesToProcess = foundSites.filter((s): s is Site => s !== null);
} }
if (!site) { if (sitesToProcess.length === 0) {
logger.error(`handleOlmServerPeerAddMessage: Site not found`); logger.error(
`handleOlmServerInitAddPeerHandshake: No sites to process`
);
await sendCancel();
return; return;
} }
// check if the client can access this site using the cache let handshakeInitiated = false;
const currentSiteAssociationCaches = await db
.select()
.from(clientSitesAssociationsCache)
.where(
and(
eq(clientSitesAssociationsCache.clientId, client.clientId),
eq(clientSitesAssociationsCache.siteId, site.siteId)
)
);
if (currentSiteAssociationCaches.length === 0) { for (const site of sitesToProcess) {
logger.error( // Check if the client can access this site using the cache
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}` const currentSiteAssociationCaches = await db
); .select()
// cancel the request from the olm side to not keep doing this .from(clientSitesAssociationsCache)
await sendToClient( .where(
olm.olmId, and(
eq(clientSitesAssociationsCache.clientId, client.clientId),
eq(clientSitesAssociationsCache.siteId, site.siteId)
)
);
if (currentSiteAssociationCaches.length === 0) {
logger.warn(
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to site ${site.siteId}, skipping`
);
continue;
}
if (!site.exitNodeId) {
logger.error(
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
);
continue;
}
const [exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
if (!exitNode) {
logger.error(
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
);
continue;
}
// Trigger the peer add handshake — if the peer was already added this will be a no-op
await initPeerAddHandshake(
client.clientId,
{ {
type: "olm/wg/peer/chain/cancel", siteId: site.siteId,
data: { exitNode: {
chainId publicKey: exitNode.publicKey,
endpoint: exitNode.endpoint
} }
}, },
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
return;
}
if (!site.exitNodeId) {
logger.error(
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
);
// cancel the request from the olm side to not keep doing this
await sendToClient(
olm.olmId, olm.olmId,
{ chainId
type: "olm/wg/peer/chain/cancel",
data: {
chainId
}
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
return;
}
// get the exit node from the side
const [exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
if (!exitNode) {
logger.error(
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
); );
return;
handshakeInitiated = true;
} }
// also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch if (!handshakeInitiated) {
// if it has already been added this will be a no-op logger.error(
await initPeerAddHandshake( `handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
// this will kick off the add peer process for the client );
client.clientId, await sendCancel();
{ }
siteId: site.siteId,
exitNode: {
publicKey: exitNode.publicKey,
endpoint: exitNode.endpoint
}
},
olm.olmId,
chainId
);
return; return;
}; };

View File

@@ -1,43 +1,25 @@
import { import {
Client,
clientSiteResourcesAssociationsCache, clientSiteResourcesAssociationsCache,
db, db,
ExitNode, networks,
Org, siteNetworks,
orgs,
roleClients,
roles,
siteResources, siteResources,
Transaction,
userClients,
userOrgs,
users
} from "@server/db"; } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { import {
clients, clients,
clientSitesAssociationsCache, clientSitesAssociationsCache,
exitNodes,
Olm, Olm,
olms,
sites sites
} from "@server/db"; } from "@server/db";
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers";
import logger from "@server/logger"; import logger from "@server/logger";
import { listExitNodes } from "#dynamic/lib/exitNodes";
import { import {
generateAliasConfig, generateAliasConfig,
getNextAvailableClientSubnet
} from "@server/lib/ip"; } from "@server/lib/ip";
import { generateRemoteSubnets } from "@server/lib/ip"; import { generateRemoteSubnets } from "@server/lib/ip";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { validateSessionToken } from "@server/auth/sessions/app";
import config from "@server/lib/config";
import { import {
addPeer as newtAddPeer, addPeer as newtAddPeer,
deletePeer as newtDeletePeer
} from "@server/routers/newt/peers"; } from "@server/routers/newt/peers";
export const handleOlmServerPeerAddMessage: MessageHandler = async ( export const handleOlmServerPeerAddMessage: MessageHandler = async (
@@ -153,13 +135,21 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
clientSiteResourcesAssociationsCache.siteResourceId clientSiteResourcesAssociationsCache.siteResourceId
) )
) )
.where( .innerJoin(
networks,
eq(siteResources.networkId, networks.networkId)
)
.innerJoin(
siteNetworks,
and( and(
eq(siteResources.siteId, site.siteId), eq(networks.networkId, siteNetworks.networkId),
eq( eq(siteNetworks.siteId, site.siteId)
clientSiteResourcesAssociationsCache.clientId, )
client.clientId )
) .where(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
) )
); );

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, loginPage } from "@server/db"; import { db, domainNamespaces, loginPage } from "@server/db";
import { import {
domains, domains,
orgDomains, orgDomains,
@@ -24,6 +24,8 @@ import { build } from "@server/build";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { getUniqueResourceName } from "@server/db/names"; import { getUniqueResourceName } from "@server/db/names";
import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const createResourceParamsSchema = z.strictObject({ const createResourceParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -112,7 +114,10 @@ export async function createResource(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { if (
req.user &&
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );
@@ -193,6 +198,29 @@ async function createHttpResource(
const subdomain = parsedBody.data.subdomain; const subdomain = parsedBody.data.subdomain;
const stickySession = parsedBody.data.stickySession; const stickySession = parsedBody.data.stickySession;
if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) {
// grandfather in existing users
const lastAllowedDate = new Date("2026-04-13");
const userCreatedDate = new Date(req.user?.dateCreated || new Date());
if (userCreatedDate > lastAllowedDate) {
// check if this domain id is a namespace domain and if so, reject
const domain = await db
.select()
.from(domainNamespaces)
.where(eq(domainNamespaces.domainId, domainId))
.limit(1);
if (domain.length > 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
)
);
}
}
}
// Validate domain and construct full domain // Validate domain and construct full domain
const domainResult = await validateAndConstructDomain( const domainResult = await validateAndConstructDomain(
domainId, domainId,

View File

@@ -142,9 +142,10 @@ export async function getUserResources(
let siteResourcesData: Array<{ let siteResourcesData: Array<{
siteResourceId: number; siteResourceId: number;
name: string; name: string;
niceId: string;
destination: string; destination: string;
mode: string; mode: string;
protocol: string | null; scheme: string | null;
enabled: boolean; enabled: boolean;
alias: string | null; alias: string | null;
aliasAddress: string | null; aliasAddress: string | null;
@@ -154,9 +155,10 @@ export async function getUserResources(
.select({ .select({
siteResourceId: siteResources.siteResourceId, siteResourceId: siteResources.siteResourceId,
name: siteResources.name, name: siteResources.name,
niceId: siteResources.niceId,
destination: siteResources.destination, destination: siteResources.destination,
mode: siteResources.mode, mode: siteResources.mode,
protocol: siteResources.protocol, scheme: siteResources.scheme,
enabled: siteResources.enabled, enabled: siteResources.enabled,
alias: siteResources.alias, alias: siteResources.alias,
aliasAddress: siteResources.aliasAddress aliasAddress: siteResources.aliasAddress
@@ -240,7 +242,7 @@ export async function getUserResources(
name: siteResource.name, name: siteResource.name,
destination: siteResource.destination, destination: siteResource.destination,
mode: siteResource.mode, mode: siteResource.mode,
protocol: siteResource.protocol, protocol: siteResource.scheme,
enabled: siteResource.enabled, enabled: siteResource.enabled,
alias: siteResource.alias, alias: siteResource.alias,
aliasAddress: siteResource.aliasAddress, aliasAddress: siteResource.aliasAddress,
@@ -249,7 +251,7 @@ export async function getUserResources(
}); });
return response(res, { return response(res, {
data: { data: {
resources: resourcesWithAuth, resources: resourcesWithAuth,
siteResources: siteResourcesFormatted siteResources: siteResourcesFormatted
}, },
@@ -289,7 +291,7 @@ export type GetUserResourcesResponse = {
enabled: boolean; enabled: boolean;
alias: string | null; alias: string | null;
aliasAddress: string | null; aliasAddress: string | null;
type: 'site'; type: "site";
}>; }>;
}; };
}; };

View File

@@ -22,6 +22,7 @@ export * from "./deleteResourceRule";
export * from "./listResourceRules"; export * from "./listResourceRules";
export * from "./updateResourceRule"; export * from "./updateResourceRule";
export * from "./getUserResources"; export * from "./getUserResources";
export * from "./listUserResourceAliases";
export * from "./setResourceHeaderAuth"; export * from "./setResourceHeaderAuth";
export * from "./addEmailToResourceWhitelist"; export * from "./addEmailToResourceWhitelist";
export * from "./removeEmailFromResourceWhitelist"; export * from "./removeEmailFromResourceWhitelist";

View File

@@ -6,6 +6,7 @@ import {
resourcePincode, resourcePincode,
resources, resources,
roleResources, roleResources,
sites,
targetHealthCheck, targetHealthCheck,
targets, targets,
userResources userResources
@@ -138,6 +139,7 @@ export type ResourceWithTargets = {
port: number; port: number;
enabled: boolean; enabled: boolean;
healthStatus: "healthy" | "unhealthy" | "unknown" | null; healthStatus: "healthy" | "unhealthy" | "unknown" | null;
siteName: string | null;
}>; }>;
}; };
@@ -446,14 +448,16 @@ export async function listResources(
port: targets.port, port: targets.port,
enabled: targets.enabled, enabled: targets.enabled,
healthStatus: targetHealthCheck.hcHealth, healthStatus: targetHealthCheck.hcHealth,
hcEnabled: targetHealthCheck.hcEnabled hcEnabled: targetHealthCheck.hcEnabled,
siteName: sites.name
}) })
.from(targets) .from(targets)
.where(inArray(targets.resourceId, resourceIdList)) .where(inArray(targets.resourceId, resourceIdList))
.leftJoin( .leftJoin(
targetHealthCheck, targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId) eq(targetHealthCheck.targetId, targets.targetId)
); )
.leftJoin(sites, eq(targets.siteId, sites.siteId));
// avoids TS issues with reduce/never[] // avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>(); const map = new Map<number, ResourceWithTargets>();

View File

@@ -0,0 +1,262 @@
import { Request, Response, NextFunction } from "express";
import {
db,
siteResources,
userSiteResources,
roleSiteResources,
userOrgRoles,
userOrgs
} from "@server/db";
import { and, eq, inArray, asc, isNotNull, ne } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import logger from "@server/logger";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
import type { PaginatedResponse } from "@server/types/Pagination";
import { OpenAPITags, registry } from "@server/openApi";
import { localCache } from "#dynamic/lib/cache";
const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60;
function userResourceAliasesCacheKey(
orgId: string,
userId: string,
page: number,
pageSize: number
) {
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}`;
}
const listUserResourceAliasesParamsSchema = z.strictObject({
orgId: z.string()
});
const listUserResourceAliasesQuerySchema = z.object({
pageSize: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>()
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
})
});
export type ListUserResourceAliasesResponse = PaginatedResponse<{
aliases: string[];
}>;
// registry.registerPath({
// method: "get",
// path: "/org/{orgId}/user-resource-aliases",
// description:
// "List private (host-mode) site resource aliases the authenticated user can access in the organization, paginated.",
// tags: [OpenAPITags.PrivateResource],
// request: {
// params: z.object({
// orgId: z.string()
// }),
// query: listUserResourceAliasesQuerySchema
// },
// responses: {}
// });
export async function listUserResourceAliases(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listUserResourceAliasesQuerySchema.safeParse(
req.query
);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedQuery.error)
)
);
}
const { page, pageSize } = parsedQuery.data;
const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
const userId = req.user?.userId;
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
const [userOrg] = await db
.select()
.from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1);
if (!userOrg) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User not in organization")
);
}
const cacheKey = userResourceAliasesCacheKey(
orgId,
userId,
page,
pageSize
);
const cachedData: ListUserResourceAliasesResponse | undefined =
localCache.get(cacheKey);
if (cachedData) {
return response<ListUserResourceAliasesResponse>(res, {
data: cachedData,
success: true,
error: false,
message: "User resource aliases retrieved successfully",
status: HttpCode.OK
});
}
const userRoleIds = await db
.select({ roleId: userOrgRoles.roleId })
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
)
.then((rows) => rows.map((r) => r.roleId));
const directSiteResourcesQuery = db
.select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources)
.where(eq(userSiteResources.userId, userId));
const roleSiteResourcesQuery =
userRoleIds.length > 0
? db
.select({
siteResourceId: roleSiteResources.siteResourceId
})
.from(roleSiteResources)
.where(inArray(roleSiteResources.roleId, userRoleIds))
: Promise.resolve([]);
const [directSiteResourceResults, roleSiteResourceResults] =
await Promise.all([
directSiteResourcesQuery,
roleSiteResourcesQuery
]);
const accessibleSiteResourceIds = [
...directSiteResourceResults.map((r) => r.siteResourceId),
...roleSiteResourceResults.map((r) => r.siteResourceId)
];
if (accessibleSiteResourceIds.length === 0) {
const data: ListUserResourceAliasesResponse = {
aliases: [],
pagination: {
total: 0,
pageSize,
page
}
};
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
return response<ListUserResourceAliasesResponse>(res, {
data,
success: true,
error: false,
message: "User resource aliases retrieved successfully",
status: HttpCode.OK
});
}
const whereClause = and(
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
eq(siteResources.mode, "host"),
isNotNull(siteResources.alias),
ne(siteResources.alias, ""),
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
);
const baseSelect = () =>
db
.select({ alias: siteResources.alias })
.from(siteResources)
.where(whereClause);
const countQuery = db.$count(baseSelect().as("filtered_aliases"));
const [rows, totalCount] = await Promise.all([
baseSelect()
.orderBy(asc(siteResources.alias))
.limit(pageSize)
.offset(pageSize * (page - 1)),
countQuery
]);
const aliases = rows.map((r) => r.alias as string);
const data: ListUserResourceAliasesResponse = {
aliases,
pagination: {
total: totalCount,
pageSize,
page
}
};
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
return response<ListUserResourceAliasesResponse>(res, {
data,
success: true,
error: false,
message: "User resource aliases retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, loginPage } from "@server/db"; import { db, domainNamespaces, loginPage } from "@server/db";
import { import {
domains, domains,
Org, Org,
@@ -25,6 +25,7 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { build } from "@server/build"; import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
const updateResourceParamsSchema = z.strictObject({ const updateResourceParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive()) resourceId: z.string().transform(Number).pipe(z.int().positive())
@@ -120,7 +121,9 @@ const updateHttpResourceBodySchema = z
if (data.headers) { if (data.headers) {
// HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230) // HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230)
const validHeaderValue = /^[\t\x20-\x7E]*$/; const validHeaderValue = /^[\t\x20-\x7E]*$/;
return data.headers.every((h) => validHeaderValue.test(h.value)); return data.headers.every((h) =>
validHeaderValue.test(h.value)
);
} }
return true; return true;
}, },
@@ -318,6 +321,34 @@ async function updateHttpResource(
if (updateData.domainId) { if (updateData.domainId) {
const domainId = updateData.domainId; const domainId = updateData.domainId;
if (
build == "saas" &&
!isSubscribed(resource.orgId, tierMatrix.domainNamespaces)
) {
// grandfather in existing users
const lastAllowedDate = new Date("2026-04-13");
const userCreatedDate = new Date(
req.user?.dateCreated || new Date()
);
if (userCreatedDate > lastAllowedDate) {
// check if this domain id is a namespace domain and if so, reject
const domain = await db
.select()
.from(domainNamespaces)
.where(eq(domainNamespaces.domainId, domainId))
.limit(1);
if (domain.length > 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
)
);
}
}
}
// Validate domain and construct full domain // Validate domain and construct full domain
const domainResult = await validateAndConstructDomain( const domainResult = await validateAndConstructDomain(
domainId, domainId,
@@ -366,7 +397,7 @@ async function updateHttpResource(
); );
} }
} }
if (build != "oss") { if (build != "oss") {
const existingLoginPages = await db const existingLoginPages = await db
.select() .select()

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, Site, siteResources } from "@server/db"; import { db, Site, siteNetworks, siteResources } from "@server/db";
import { newts, newtSessions, sites } from "@server/db"; import { newts, newtSessions, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -71,18 +71,23 @@ export async function deleteSite(
await deletePeer(site.exitNodeId!, site.pubKey); await deletePeer(site.exitNodeId!, site.pubKey);
} }
} else if (site.type == "newt") { } else if (site.type == "newt") {
// delete all of the site resources on this site const networks = await trx
const siteResourcesOnSite = trx .select({ networkId: siteNetworks.networkId })
.delete(siteResources) .from(siteNetworks)
.where(eq(siteResources.siteId, siteId)) .where(eq(siteNetworks.siteId, siteId));
.returning();
// loop through them // loop through them
for (const removedSiteResource of await siteResourcesOnSite) { for (const network of await networks) {
await rebuildClientAssociationsFromSiteResource( const [siteResource] = await trx
removedSiteResource, .select()
trx .from(siteResources)
); .where(eq(siteResources.networkId, network.networkId));
if (siteResource) {
await rebuildClientAssociationsFromSiteResource(
siteResource,
trx
);
}
} }
// get the newt on the site by querying the newt table for siteId // get the newt on the site by querying the newt table for siteId

View File

@@ -5,6 +5,8 @@ import {
orgs, orgs,
roles, roles,
roleSiteResources, roleSiteResources,
siteNetworks,
networks,
SiteResource, SiteResource,
siteResources, siteResources,
sites, sites,
@@ -17,17 +19,18 @@ import {
portRangeStringSchema portRangeStringSchema
} from "@server/lib/ip"; } from "@server/lib/ip";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
const createSiteResourceParamsSchema = z.strictObject({ const createSiteResourceParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -36,11 +39,12 @@ const createSiteResourceParamsSchema = z.strictObject({
const createSiteResourceSchema = z const createSiteResourceSchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "port"]), mode: z.enum(["host", "cidr", "http"]),
siteId: z.int(), ssl: z.boolean().optional(), // only used for http mode
// protocol: z.enum(["tcp", "udp"]).optional(), scheme: z.enum(["http", "https"]).optional(),
siteIds: z.array(z.int()),
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
// destinationPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(),
destination: z.string().min(1), destination: z.string().min(1),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
alias: z alias: z
@@ -57,20 +61,24 @@ const createSiteResourceSchema = z
udpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(), disableIcmp: z.boolean().optional(),
authDaemonPort: z.int().positive().optional(), authDaemonPort: z.int().positive().optional(),
authDaemonMode: z.enum(["site", "remote"]).optional() authDaemonMode: z.enum(["site", "remote"]).optional(),
domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org
}) })
.strict() .strict()
.refine( .refine(
(data) => { (data) => {
if (data.mode === "host") { if (data.mode === "host") {
// Check if it's a valid IP address using zod (v4 or v6) if (data.mode == "host") {
const isValidIP = z // Check if it's a valid IP address using zod (v4 or v6)
// .union([z.ipv4(), z.ipv6()]) const isValidIP = z
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere // .union([z.ipv4(), z.ipv6()])
.safeParse(data.destination).success; .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.safeParse(data.destination).success;
if (isValidIP) { if (isValidIP) {
return true; return true;
}
} }
// Check if it's a valid domain (hostname pattern, TLD not required) // Check if it's a valid domain (hostname pattern, TLD not required)
@@ -105,6 +113,21 @@ const createSiteResourceSchema = z
{ {
message: "Destination must be a valid CIDR notation for cidr mode" message: "Destination must be a valid CIDR notation for cidr mode"
} }
)
.refine(
(data) => {
if (data.mode !== "http") return true;
return (
data.scheme !== undefined &&
data.destinationPort !== undefined &&
data.destinationPort >= 1 &&
data.destinationPort <= 65535
);
},
{
message:
"HTTP mode requires scheme (http or https) and a valid destination port"
}
); );
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>; export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
@@ -159,13 +182,14 @@ export async function createSiteResource(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const { const {
name, name,
siteId, siteIds,
mode, mode,
// protocol, scheme,
// proxyPort, // proxyPort,
// destinationPort, destinationPort,
destination, destination,
enabled, enabled,
ssl,
alias, alias,
userIds, userIds,
roleIds, roleIds,
@@ -174,18 +198,36 @@ export async function createSiteResource(
udpPortRangeString, udpPortRangeString,
disableIcmp, disableIcmp,
authDaemonPort, authDaemonPort,
authDaemonMode authDaemonMode,
domainId,
subdomain
} = parsedBody.data; } = parsedBody.data;
if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix[TierFeature.HTTPPrivateResources]
);
if (!hasHttpFeature) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"HTTP private resources are not included in your current plan. Please upgrade."
)
);
}
}
// Verify the site exists and belongs to the org // Verify the site exists and belongs to the org
const [site] = await db const sitesToAssign = await db
.select() .select()
.from(sites) .from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) .where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId)));
.limit(1);
if (!site) { if (sitesToAssign.length !== siteIds.length) {
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); return next(
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
);
} }
const [org] = await db const [org] = await db
@@ -226,29 +268,50 @@ export async function createSiteResource(
); );
} }
// // check if resource with same protocol and proxy port already exists (only for port mode) if (domainId && alias) {
// if (mode === "port" && protocol && proxyPort) { // throw an error because we can only have one or the other
// const [existingResource] = await db return next(
// .select() createHttpError(
// .from(siteResources) HttpCode.BAD_REQUEST,
// .where( "Alias and domain cannot both be set. Please choose one or the other."
// and( )
// eq(siteResources.siteId, siteId), );
// eq(siteResources.orgId, orgId), }
// eq(siteResources.protocol, protocol),
// eq(siteResources.proxyPort, proxyPort) let fullDomain: string | null = null;
// ) let finalSubdomain: string | null = null;
// ) if (domainId) {
// .limit(1); // Validate domain and construct full domain
// if (existingResource && existingResource.siteResourceId) { const domainResult = await validateAndConstructDomain(
// return next( domainId,
// createHttpError( orgId,
// HttpCode.CONFLICT, subdomain
// "A resource with the same protocol and proxy port already exists" );
// )
// ); if (!domainResult.success) {
// } return next(
// } createHttpError(HttpCode.BAD_REQUEST, domainResult.error)
);
}
fullDomain = domainResult.fullDomain;
finalSubdomain = domainResult.subdomain;
// make sure the full domain is unique
const existingResource = await db
.select()
.from(siteResources)
.where(eq(siteResources.fullDomain, fullDomain));
if (existingResource.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that domain already exists"
)
);
}
}
// make sure the alias is unique within the org if provided // make sure the alias is unique within the org if provided
if (alias) { if (alias) {
@@ -280,27 +343,49 @@ export async function createSiteResource(
const niceId = await getUniqueSiteResourceName(orgId); const niceId = await getUniqueSiteResourceName(orgId);
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
if (mode == "host") { if (mode === "host" || mode === "http") {
// we can only have an alias on a host
aliasAddress = await getNextAvailableAliasAddress(orgId); aliasAddress = await getNextAvailableAliasAddress(orgId);
} }
let newSiteResource: SiteResource | undefined; let newSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const [network] = await trx
.insert(networks)
.values({
scope: "resource",
orgId: orgId
})
.returning();
if (!network) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Failed to create network`
)
);
}
// Create the site resource // Create the site resource
const insertValues: typeof siteResources.$inferInsert = { const insertValues: typeof siteResources.$inferInsert = {
siteId,
niceId, niceId,
orgId, orgId,
name, name,
mode: mode as "host" | "cidr", mode,
ssl,
networkId: network.networkId,
destination, destination,
scheme,
destinationPort,
enabled, enabled,
alias, alias: alias ? alias.trim() : null,
aliasAddress, aliasAddress,
tcpPortRangeString, tcpPortRangeString,
udpPortRangeString, udpPortRangeString,
disableIcmp disableIcmp,
domainId,
subdomain: finalSubdomain,
fullDomain
}; };
if (isLicensedSshPam) { if (isLicensedSshPam) {
if (authDaemonPort !== undefined) if (authDaemonPort !== undefined)
@@ -317,6 +402,13 @@ export async function createSiteResource(
//////////////////// update the associations //////////////////// //////////////////// update the associations ////////////////////
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: network.networkId
});
}
const [adminRole] = await trx const [adminRole] = await trx
.select() .select()
.from(roles) .from(roles)
@@ -359,16 +451,21 @@ export async function createSiteResource(
); );
} }
const [newt] = await trx for (const siteToAssign of sitesToAssign) {
.select() const [newt] = await trx
.from(newts) .select()
.where(eq(newts.siteId, site.siteId)) .from(newts)
.limit(1); .where(eq(newts.siteId, siteToAssign.siteId))
.limit(1);
if (!newt) { if (!newt) {
return next( return next(
createHttpError(HttpCode.NOT_FOUND, "Newt not found") createHttpError(
); HttpCode.NOT_FOUND,
`Newt not found for site ${siteToAssign.siteId}`
)
);
}
} }
await rebuildClientAssociationsFromSiteResource( await rebuildClientAssociationsFromSiteResource(
@@ -387,7 +484,7 @@ export async function createSiteResource(
} }
logger.info( logger.info(
`Created site resource ${newSiteResource.siteResourceId} for site ${siteId}` `Created site resource ${newSiteResource.siteResourceId} for org ${orgId}`
); );
return response(res, { return response(res, {

View File

@@ -70,17 +70,18 @@ export async function deleteSiteResource(
.where(and(eq(siteResources.siteResourceId, siteResourceId))) .where(and(eq(siteResources.siteResourceId, siteResourceId)))
.returning(); .returning();
const [newt] = await trx // not sure why this is here...
.select() // const [newt] = await trx
.from(newts) // .select()
.where(eq(newts.siteId, removedSiteResource.siteId)) // .from(newts)
.limit(1); // .where(eq(newts.siteId, removedSiteResource.siteId))
// .limit(1);
if (!newt) { // if (!newt) {
return next( // return next(
createHttpError(HttpCode.NOT_FOUND, "Newt not found") // createHttpError(HttpCode.NOT_FOUND, "Newt not found")
); // );
} // }
await rebuildClientAssociationsFromSiteResource( await rebuildClientAssociationsFromSiteResource(
removedSiteResource, removedSiteResource,

View File

@@ -17,38 +17,34 @@ const getSiteResourceParamsSchema = z.strictObject({
.transform((val) => (val ? Number(val) : undefined)) .transform((val) => (val ? Number(val) : undefined))
.pipe(z.int().positive().optional()) .pipe(z.int().positive().optional())
.optional(), .optional(),
siteId: z.string().transform(Number).pipe(z.int().positive()),
niceId: z.string().optional(), niceId: z.string().optional(),
orgId: z.string() orgId: z.string()
}); });
async function query( async function query(
siteResourceId?: number, siteResourceId?: number,
siteId?: number,
niceId?: string, niceId?: string,
orgId?: string orgId?: string
) { ) {
if (siteResourceId && siteId && orgId) { if (siteResourceId && orgId) {
const [siteResource] = await db const [siteResource] = await db
.select() .select()
.from(siteResources) .from(siteResources)
.where( .where(
and( and(
eq(siteResources.siteResourceId, siteResourceId), eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId) eq(siteResources.orgId, orgId)
) )
) )
.limit(1); .limit(1);
return siteResource; return siteResource;
} else if (niceId && siteId && orgId) { } else if (niceId && orgId) {
const [siteResource] = await db const [siteResource] = await db
.select() .select()
.from(siteResources) .from(siteResources)
.where( .where(
and( and(
eq(siteResources.niceId, niceId), eq(siteResources.niceId, niceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId) eq(siteResources.orgId, orgId)
) )
) )
@@ -84,7 +80,6 @@ registry.registerPath({
request: { request: {
params: z.object({ params: z.object({
niceId: z.string(), niceId: z.string(),
siteId: z.number(),
orgId: z.string() orgId: z.string()
}) })
}, },
@@ -107,10 +102,10 @@ export async function getSiteResource(
); );
} }
const { siteResourceId, siteId, niceId, orgId } = parsedParams.data; const { siteResourceId, niceId, orgId } = parsedParams.data;
// Get the site resource // Get the site resource
const siteResource = await query(siteResourceId, siteId, niceId, orgId); const siteResource = await query(siteResourceId, niceId, orgId);
if (!siteResource) { if (!siteResource) {
return next( return next(

View File

@@ -1,4 +1,4 @@
import { db, SiteResource, siteResources, sites } from "@server/db"; import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -41,12 +41,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
}), }),
query: z.string().optional(), query: z.string().optional(),
mode: z mode: z
.enum(["host", "cidr"]) .enum(["host", "cidr", "http"])
.optional() .optional()
.catch(undefined) .catch(undefined)
.openapi({ .openapi({
type: "string", type: "string",
enum: ["host", "cidr"], enum: ["host", "cidr", "http"],
description: "Filter site resources by mode" description: "Filter site resources by mode"
}), }),
sort_by: z sort_by: z
@@ -73,22 +73,58 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
siteResources: (SiteResource & { siteResources: (SiteResource & {
siteName: string; siteOnlines: boolean[];
siteNiceId: string; siteIds: number[];
siteAddress: string | null; siteNames: string[];
siteNiceIds: string[];
siteAddresses: (string | null)[];
})[]; })[];
}>; }>;
/**
* Returns an aggregation expression compatible with both SQLite and PostgreSQL.
* - SQLite: json_group_array(col) → returns a JSON array string, parsed after fetch
* - PostgreSQL: array_agg(col) → returns a native array
*/
function aggCol<T>(column: any) {
if (DB_TYPE === "sqlite") {
return sql<T>`json_group_array(${column})`;
}
return sql<T>`array_agg(${column})`;
}
/**
* For SQLite the aggregated columns come back as JSON strings; parse them into
* proper arrays. For PostgreSQL the driver already returns native arrays, so
* the row is returned unchanged.
*/
function transformSiteResourceRow(row: any) {
if (DB_TYPE !== "sqlite") {
return row;
}
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[]
};
}
function querySiteResourcesBase() { function querySiteResourcesBase() {
return db return db
.select({ .select({
siteResourceId: siteResources.siteResourceId, siteResourceId: siteResources.siteResourceId,
siteId: siteResources.siteId,
orgId: siteResources.orgId, orgId: siteResources.orgId,
niceId: siteResources.niceId, niceId: siteResources.niceId,
name: siteResources.name, name: siteResources.name,
mode: siteResources.mode, mode: siteResources.mode,
protocol: siteResources.protocol, ssl: siteResources.ssl,
scheme: siteResources.scheme,
proxyPort: siteResources.proxyPort, proxyPort: siteResources.proxyPort,
destinationPort: siteResources.destinationPort, destinationPort: siteResources.destinationPort,
destination: siteResources.destination, destination: siteResources.destination,
@@ -100,12 +136,24 @@ function querySiteResourcesBase() {
disableIcmp: siteResources.disableIcmp, disableIcmp: siteResources.disableIcmp,
authDaemonMode: siteResources.authDaemonMode, authDaemonMode: siteResources.authDaemonMode,
authDaemonPort: siteResources.authDaemonPort, authDaemonPort: siteResources.authDaemonPort,
siteName: sites.name, subdomain: siteResources.subdomain,
siteNiceId: sites.niceId, domainId: siteResources.domainId,
siteAddress: sites.address fullDomain: siteResources.fullDomain,
networkId: siteResources.networkId,
defaultNetworkId: siteResources.defaultNetworkId,
siteNames: aggCol<string[]>(sites.name),
siteNiceIds: aggCol<string[]>(sites.niceId),
siteIds: aggCol<number[]>(sites.siteId),
siteAddresses: aggCol<(string | null)[]>(sites.address),
siteOnlines: aggCol<boolean[]>(sites.online)
}) })
.from(siteResources) .from(siteResources)
.innerJoin(sites, eq(siteResources.siteId, sites.siteId)); .innerJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.groupBy(siteResources.siteResourceId);
} }
registry.registerPath({ registry.registerPath({
@@ -193,10 +241,12 @@ export async function listAllSiteResourcesByOrg(
const baseQuery = querySiteResourcesBase().where(and(...conditions)); const baseQuery = querySiteResourcesBase().where(and(...conditions));
const countQuery = db.$count( const countQuery = db.$count(
querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources") querySiteResourcesBase()
.where(and(...conditions))
.as("filtered_site_resources")
); );
const [siteResourcesList, totalCount] = await Promise.all([ const [siteResourcesRaw, totalCount] = await Promise.all([
baseQuery baseQuery
.limit(pageSize) .limit(pageSize)
.offset(pageSize * (page - 1)) .offset(pageSize * (page - 1))
@@ -210,6 +260,8 @@ export async function listAllSiteResourcesByOrg(
countQuery countQuery
]); ]);
const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow);
return response<ListAllSiteResourcesByOrgResponse>(res, { return response<ListAllSiteResourcesByOrgResponse>(res, {
data: { data: {
siteResources: siteResourcesList, siteResources: siteResourcesList,
@@ -233,4 +285,4 @@ export async function listAllSiteResourcesByOrg(
) )
); );
} }
} }

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db, networks, siteNetworks } from "@server/db";
import { siteResources, sites, SiteResource } from "@server/db"; import { siteResources, sites, SiteResource } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -108,13 +108,21 @@ export async function listSiteResources(
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
} }
// Get site resources // Get site resources by joining networks to siteResources via siteNetworks
const siteResourcesList = await db const siteResourcesList = await db
.select() .select()
.from(siteResources) .from(siteNetworks)
.innerJoin(
networks,
eq(siteNetworks.networkId, networks.networkId)
)
.innerJoin(
siteResources,
eq(siteResources.networkId, networks.networkId)
)
.where( .where(
and( and(
eq(siteResources.siteId, siteId), eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId) eq(siteResources.orgId, orgId)
) )
) )
@@ -128,6 +136,7 @@ export async function listSiteResources(
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
return response(res, { return response(res, {
data: { siteResources: siteResourcesList }, data: { siteResources: siteResourcesList },
success: true, success: true,

View File

@@ -1,4 +1,3 @@
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { import {
clientSiteResources, clientSiteResources,
clientSiteResourcesAssociationsCache, clientSiteResourcesAssociationsCache,
@@ -7,13 +6,21 @@ import {
orgs, orgs,
roles, roles,
roleSiteResources, roleSiteResources,
siteNetworks,
SiteResource, SiteResource,
siteResources, siteResources,
sites, sites,
networks,
Transaction, Transaction,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import response from "@server/lib/response";
import { eq, and, ne, inArray } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { updatePeerData, updateTargets } from "@server/routers/client/targets";
import { import {
generateAliasConfig, generateAliasConfig,
generateRemoteSubnets, generateRemoteSubnets,
@@ -22,12 +29,8 @@ import {
portRangeStringSchema portRangeStringSchema
} from "@server/lib/ip"; } from "@server/lib/ip";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import { updatePeerData, updateTargets } from "@server/routers/client/targets";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { and, eq, ne } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
@@ -40,7 +43,8 @@ const updateSiteResourceParamsSchema = z.strictObject({
const updateSiteResourceSchema = z const updateSiteResourceSchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
siteId: z.int(), siteIds: z.array(z.int()),
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
niceId: z niceId: z
.string() .string()
.min(1) .min(1)
@@ -51,10 +55,11 @@ const updateSiteResourceSchema = z
) )
.optional(), .optional(),
// mode: z.enum(["host", "cidr", "port"]).optional(), // mode: z.enum(["host", "cidr", "port"]).optional(),
mode: z.enum(["host", "cidr"]).optional(), mode: z.enum(["host", "cidr", "http"]).optional(),
// protocol: z.enum(["tcp", "udp"]).nullish(), ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).nullish(),
// proxyPort: z.int().positive().nullish(), // proxyPort: z.int().positive().nullish(),
// destinationPort: z.int().positive().nullish(), destinationPort: z.int().positive().nullish(),
destination: z.string().min(1).optional(), destination: z.string().min(1).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
alias: z alias: z
@@ -71,7 +76,9 @@ const updateSiteResourceSchema = z
udpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(), disableIcmp: z.boolean().optional(),
authDaemonPort: z.int().positive().nullish(), authDaemonPort: z.int().positive().nullish(),
authDaemonMode: z.enum(["site", "remote"]).optional() authDaemonMode: z.enum(["site", "remote"]).optional(),
domainId: z.string().optional(),
subdomain: z.string().optional()
}) })
.strict() .strict()
.refine( .refine(
@@ -118,6 +125,23 @@ const updateSiteResourceSchema = z
{ {
message: "Destination must be a valid CIDR notation for cidr mode" message: "Destination must be a valid CIDR notation for cidr mode"
} }
)
.refine(
(data) => {
if (data.mode !== "http") return true;
return (
data.scheme !== undefined &&
data.scheme !== null &&
data.destinationPort !== undefined &&
data.destinationPort !== null &&
data.destinationPort >= 1 &&
data.destinationPort <= 65535
);
},
{
message:
"HTTP mode requires scheme (http or https) and a valid destination port"
}
); );
export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>; export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
@@ -172,11 +196,14 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data; const { siteResourceId } = parsedParams.data;
const { const {
name, name,
siteId, // because it can change siteIds, // because it can change
niceId, niceId,
mode, mode,
scheme,
destination, destination,
destinationPort,
alias, alias,
ssl,
enabled, enabled,
userIds, userIds,
roleIds, roleIds,
@@ -185,19 +212,11 @@ export async function updateSiteResource(
udpPortRangeString, udpPortRangeString,
disableIcmp, disableIcmp,
authDaemonPort, authDaemonPort,
authDaemonMode authDaemonMode,
domainId,
subdomain
} = parsedBody.data; } = parsedBody.data;
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site) {
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
}
// Check if site resource exists // Check if site resource exists
const [existingSiteResource] = await db const [existingSiteResource] = await db
.select() .select()
@@ -211,6 +230,21 @@ export async function updateSiteResource(
); );
} }
if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
existingSiteResource.orgId,
tierMatrix[TierFeature.HTTPPrivateResources]
);
if (!hasHttpFeature) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"HTTP private resources are not included in your current plan. Please upgrade."
)
);
}
}
const isLicensedSshPam = await isLicensedOrSubscribed( const isLicensedSshPam = await isLicensedOrSubscribed(
existingSiteResource.orgId, existingSiteResource.orgId,
tierMatrix.sshPam tierMatrix.sshPam
@@ -237,6 +271,23 @@ export async function updateSiteResource(
); );
} }
// Verify the site exists and belongs to the org
const sitesToAssign = await db
.select()
.from(sites)
.where(
and(
inArray(sites.siteId, siteIds),
eq(sites.orgId, existingSiteResource.orgId)
)
);
if (sitesToAssign.length !== siteIds.length) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
);
}
// Only check if destination is an IP address // Only check if destination is an IP address
const isIp = z const isIp = z
.union([z.ipv4(), z.ipv6()]) .union([z.ipv4(), z.ipv6()])
@@ -254,22 +305,60 @@ export async function updateSiteResource(
); );
} }
let existingSite = site; let sitesChanged = false;
let siteChanged = false; const existingSiteIds = existingSiteResource.networkId
if (existingSiteResource.siteId !== siteId) { ? await db
siteChanged = true; .select()
// get the existing site .from(siteNetworks)
[existingSite] = await db .where(
.select() eq(siteNetworks.networkId, existingSiteResource.networkId)
.from(sites) )
.where(eq(sites.siteId, existingSiteResource.siteId)) : [];
.limit(1);
if (!existingSite) { const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId));
const newSiteIdSet = new Set(siteIds);
if (
existingSiteIdSet.size !== newSiteIdSet.size ||
![...existingSiteIdSet].every((id) => newSiteIdSet.has(id))
) {
sitesChanged = true;
}
let fullDomain: string | null = null;
let finalSubdomain: string | null = null;
if (domainId) {
// Validate domain and construct full domain
const domainResult = await validateAndConstructDomain(
domainId,
org.orgId,
subdomain
);
if (!domainResult.success) {
return next(
createHttpError(HttpCode.BAD_REQUEST, domainResult.error)
);
}
fullDomain = domainResult.fullDomain;
finalSubdomain = domainResult.subdomain;
// make sure the full domain is unique
const [existingDomain] = await db
.select()
.from(siteResources)
.where(eq(siteResources.fullDomain, fullDomain));
if (
existingDomain &&
existingDomain.siteResourceId !==
existingSiteResource.siteResourceId
) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.CONFLICT,
"Existing site not found" "Resource with that domain already exists"
) )
); );
} }
@@ -302,7 +391,7 @@ export async function updateSiteResource(
let updatedSiteResource: SiteResource | undefined; let updatedSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place // if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place
if (siteChanged) { if (sitesChanged) {
// delete the existing site resource // delete the existing site resource
await trx await trx
.delete(siteResources) .delete(siteResources)
@@ -343,15 +432,20 @@ export async function updateSiteResource(
.update(siteResources) .update(siteResources)
.set({ .set({
name, name,
siteId,
niceId, niceId,
mode, mode,
scheme,
ssl,
destination, destination,
destinationPort,
enabled, enabled,
alias: alias && alias.trim() ? alias : null, alias: alias ? alias.trim() : null,
tcpPortRangeString, tcpPortRangeString,
udpPortRangeString, udpPortRangeString,
disableIcmp, disableIcmp,
domainId,
subdomain: finalSubdomain,
fullDomain,
...sshPamSet ...sshPamSet
}) })
.where( .where(
@@ -372,6 +466,23 @@ export async function updateSiteResource(
//////////////////// update the associations //////////////////// //////////////////// update the associations ////////////////////
// delete the site - site resources associations
await trx
.delete(siteNetworks)
.where(
eq(
siteNetworks.networkId,
updatedSiteResource.networkId!
)
);
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: updatedSiteResource.networkId!
});
}
const [adminRole] = await trx const [adminRole] = await trx
.select() .select()
.from(roles) .from(roles)
@@ -447,14 +558,20 @@ export async function updateSiteResource(
.update(siteResources) .update(siteResources)
.set({ .set({
name: name, name: name,
siteId: siteId, niceId: niceId,
mode: mode, mode: mode,
scheme,
ssl,
destination: destination, destination: destination,
destinationPort: destinationPort,
enabled: enabled, enabled: enabled,
alias: alias && alias.trim() ? alias : null, alias: alias ? alias.trim() : null,
tcpPortRangeString: tcpPortRangeString, tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString, udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp, disableIcmp: disableIcmp,
domainId,
subdomain: finalSubdomain,
fullDomain,
...sshPamSet ...sshPamSet
}) })
.where( .where(
@@ -464,6 +581,23 @@ export async function updateSiteResource(
//////////////////// update the associations //////////////////// //////////////////// update the associations ////////////////////
// delete the site - site resources associations
await trx
.delete(siteNetworks)
.where(
eq(
siteNetworks.networkId,
updatedSiteResource.networkId!
)
);
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: updatedSiteResource.networkId!
});
}
await trx await trx
.delete(clientSiteResources) .delete(clientSiteResources)
.where( .where(
@@ -533,14 +667,15 @@ export async function updateSiteResource(
); );
} }
logger.info( logger.info(`Updated site resource ${siteResourceId}`);
`Updated site resource ${siteResourceId} for site ${siteId}`
);
await handleMessagingForUpdatedSiteResource( await handleMessagingForUpdatedSiteResource(
existingSiteResource, existingSiteResource,
updatedSiteResource, updatedSiteResource,
{ siteId: site.siteId, orgId: site.orgId }, siteIds.map((siteId) => ({
siteId,
orgId: existingSiteResource.orgId
})),
trx trx
); );
} }
@@ -567,7 +702,7 @@ export async function updateSiteResource(
export async function handleMessagingForUpdatedSiteResource( export async function handleMessagingForUpdatedSiteResource(
existingSiteResource: SiteResource | undefined, existingSiteResource: SiteResource | undefined,
updatedSiteResource: SiteResource, updatedSiteResource: SiteResource,
site: { siteId: number; orgId: string }, sites: { siteId: number; orgId: string }[],
trx: Transaction trx: Transaction
) { ) {
logger.debug( logger.debug(
@@ -589,9 +724,14 @@ export async function handleMessagingForUpdatedSiteResource(
const destinationChanged = const destinationChanged =
existingSiteResource && existingSiteResource &&
existingSiteResource.destination !== updatedSiteResource.destination; existingSiteResource.destination !== updatedSiteResource.destination;
const destinationPortChanged =
existingSiteResource &&
existingSiteResource.destinationPort !==
updatedSiteResource.destinationPort;
const aliasChanged = const aliasChanged =
existingSiteResource && existingSiteResource &&
existingSiteResource.alias !== updatedSiteResource.alias; (existingSiteResource.alias !== updatedSiteResource.alias ||
existingSiteResource.fullDomain !== updatedSiteResource.fullDomain); // because the full domain gets sent down to the stuff as an alias
const portRangesChanged = const portRangesChanged =
existingSiteResource && existingSiteResource &&
(existingSiteResource.tcpPortRangeString !== (existingSiteResource.tcpPortRangeString !==
@@ -603,106 +743,122 @@ export async function handleMessagingForUpdatedSiteResource(
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
if (destinationChanged || aliasChanged || portRangesChanged) { if (
const [newt] = await trx destinationChanged ||
.select() aliasChanged ||
.from(newts) portRangesChanged ||
.where(eq(newts.siteId, site.siteId)) destinationPortChanged
.limit(1); ) {
for (const site of sites) {
if (!newt) { const [newt] = await trx
throw new Error(
"Newt not found for site during site resource update"
);
}
// Only update targets on newt if destination changed
if (destinationChanged || portRangesChanged) {
const oldTarget = generateSubnetProxyTargetV2(
existingSiteResource,
mergedAllClients
);
const newTarget = generateSubnetProxyTargetV2(
updatedSiteResource,
mergedAllClients
);
await updateTargets(
newt.newtId,
{
oldTargets: oldTarget ? [oldTarget] : [],
newTargets: newTarget ? [newTarget] : []
},
newt.version
);
}
const olmJobs: Promise<void>[] = [];
for (const client of mergedAllClients) {
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
// todo: optimize this query if needed
const oldDestinationStillInUseSites = await trx
.select() .select()
.from(siteResources) .from(newts)
.innerJoin( .where(eq(newts.siteId, site.siteId))
clientSiteResourcesAssociationsCache, .limit(1);
eq(
clientSiteResourcesAssociationsCache.siteResourceId, if (!newt) {
siteResources.siteResourceId throw new Error(
) "Newt not found for site during site resource update"
) );
.where( }
and(
eq( // Only update targets on newt if destination changed
clientSiteResourcesAssociationsCache.clientId, if (
client.clientId destinationChanged ||
), portRangesChanged ||
eq(siteResources.siteId, site.siteId), destinationPortChanged
eq( ) {
siteResources.destination, const oldTargets = await generateSubnetProxyTargetV2(
existingSiteResource.destination existingSiteResource,
), mergedAllClients
ne( );
siteResources.siteResourceId, const newTargets = await generateSubnetProxyTargetV2(
existingSiteResource.siteResourceId updatedSiteResource,
) mergedAllClients
)
); );
const oldDestinationStillInUseByASite = await updateTargets(
oldDestinationStillInUseSites.length > 0; newt.newtId,
{
oldTargets: oldTargets ? oldTargets : [],
newTargets: newTargets ? newTargets : []
},
newt.version
);
}
// we also need to update the remote subnets on the olms for each client that has access to this site const olmJobs: Promise<void>[] = [];
olmJobs.push( for (const client of mergedAllClients) {
updatePeerData( // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
client.clientId, // todo: optimize this query if needed
updatedSiteResource.siteId, const oldDestinationStillInUseSites = await trx
destinationChanged .select()
? { .from(siteResources)
oldRemoteSubnets: !oldDestinationStillInUseByASite .innerJoin(
? generateRemoteSubnets([ clientSiteResourcesAssociationsCache,
existingSiteResource eq(
]) clientSiteResourcesAssociationsCache.siteResourceId,
: [], siteResources.siteResourceId
newRemoteSubnets: generateRemoteSubnets([ )
updatedSiteResource )
]) .innerJoin(
} siteNetworks,
: undefined, eq(siteNetworks.networkId, siteResources.networkId)
aliasChanged )
? { .where(
oldAliases: generateAliasConfig([ and(
existingSiteResource eq(
]), clientSiteResourcesAssociationsCache.clientId,
newAliases: generateAliasConfig([ client.clientId
updatedSiteResource ),
]) eq(siteNetworks.siteId, site.siteId),
} eq(
: undefined siteResources.destination,
) existingSiteResource.destination
); ),
ne(
siteResources.siteResourceId,
existingSiteResource.siteResourceId
)
)
);
const oldDestinationStillInUseByASite =
oldDestinationStillInUseSites.length > 0;
// we also need to update the remote subnets on the olms for each client that has access to this site
olmJobs.push(
updatePeerData(
client.clientId,
site.siteId,
destinationChanged
? {
oldRemoteSubnets:
!oldDestinationStillInUseByASite
? generateRemoteSubnets([
existingSiteResource
])
: [],
newRemoteSubnets: generateRemoteSubnets([
updatedSiteResource
])
}
: undefined,
aliasChanged
? {
oldAliases: generateAliasConfig([
existingSiteResource
]),
newAliases: generateAliasConfig([
updatedSiteResource
])
}
: undefined
)
);
}
await Promise.all(olmJobs);
} }
await Promise.all(olmJobs);
} }
} }

View File

@@ -21,7 +21,8 @@ async function queryUser(userId: string) {
serverAdmin: users.serverAdmin, serverAdmin: users.serverAdmin,
idpName: idp.name, idpName: idp.name,
idpId: users.idpId, idpId: users.idpId,
locale: users.locale locale: users.locale,
dateCreated: users.dateCreated
}) })
.from(users) .from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idp, eq(users.idpId, idp.idpId))

View File

@@ -1,7 +1,14 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db"; import {
orgs,
roles,
userInviteRoles,
userInvites,
userOrgs,
users
} from "@server/db";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -37,8 +44,7 @@ const inviteUserBodySchema = z
regenerate: z.boolean().optional() regenerate: z.boolean().optional()
}) })
.refine( .refine(
(d) => (d) => (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
(d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
{ message: "roleIds or roleId is required", path: ["roleIds"] } { message: "roleIds or roleId is required", path: ["roleIds"] }
) )
.transform((data) => ({ .transform((data) => ({
@@ -265,7 +271,7 @@ export async function inviteUser(
) )
); );
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`;
if (doEmail) { if (doEmail) {
await sendEmail( await sendEmail(
@@ -314,12 +320,12 @@ export async function inviteUser(
expiresAt, expiresAt,
tokenHash tokenHash
}); });
await trx.insert(userInviteRoles).values( await trx
uniqueRoleIds.map((roleId) => ({ inviteId, roleId })) .insert(userInviteRoles)
); .values(uniqueRoleIds.map((roleId) => ({ inviteId, roleId })));
}); });
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`;
if (doEmail) { if (doEmail) {
await sendEmail( await sendEmail(

View File

@@ -64,7 +64,8 @@ export async function myDevice(
serverAdmin: users.serverAdmin, serverAdmin: users.serverAdmin,
idpName: idp.name, idpName: idp.name,
idpId: users.idpId, idpId: users.idpId,
locale: users.locale locale: users.locale,
dateCreated: users.dateCreated
}) })
.from(users) .from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idp, eq(users.idpId, idp.idpId))

View File

@@ -2,7 +2,7 @@ import { build } from "@server/build";
import { import {
handleNewtRegisterMessage, handleNewtRegisterMessage,
handleReceiveBandwidthMessage, handleReceiveBandwidthMessage,
handleGetConfigMessage, handleNewtGetConfigMessage,
handleDockerStatusMessage, handleDockerStatusMessage,
handleDockerContainersMessage, handleDockerContainersMessage,
handleNewtPingRequestMessage, handleNewtPingRequestMessage,
@@ -37,7 +37,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
"newt/disconnecting": handleNewtDisconnectingMessage, "newt/disconnecting": handleNewtDisconnectingMessage,
"newt/ping": handleNewtPingMessage, "newt/ping": handleNewtPingMessage,
"newt/wg/register": handleNewtRegisterMessage, "newt/wg/register": handleNewtRegisterMessage,
"newt/wg/get-config": handleGetConfigMessage, "newt/wg/get-config": handleNewtGetConfigMessage,
"newt/receive-bandwidth": handleReceiveBandwidthMessage, "newt/receive-bandwidth": handleReceiveBandwidthMessage,
"newt/socket/status": handleDockerStatusMessage, "newt/socket/status": handleDockerStatusMessage,
"newt/socket/containers": handleDockerContainersMessage, "newt/socket/containers": handleDockerContainersMessage,

View File

@@ -104,6 +104,42 @@ export default async function migration() {
CONSTRAINT "userOrgRoles_userId_orgId_roleId_unique" UNIQUE("userId","orgId","roleId") CONSTRAINT "userOrgRoles_userId_orgId_roleId_unique" UNIQUE("userId","orgId","roleId")
); );
`); `);
await db.execute(sql`
CREATE TABLE "eventStreamingCursors" (
"cursorId" serial PRIMARY KEY NOT NULL,
"destinationId" integer NOT NULL,
"logType" varchar(50) NOT NULL,
"lastSentId" bigint DEFAULT 0 NOT NULL,
"lastSentAt" bigint
);
`);
await db.execute(sql`
CREATE TABLE "eventStreamingDestinations" (
"destinationId" serial PRIMARY KEY NOT NULL,
"orgId" varchar(255) NOT NULL,
"sendConnectionLogs" boolean DEFAULT false NOT NULL,
"sendRequestLogs" boolean DEFAULT false NOT NULL,
"sendActionLogs" boolean DEFAULT false NOT NULL,
"sendAccessLogs" boolean DEFAULT false NOT NULL,
"type" varchar(50) NOT NULL,
"config" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"createdAt" bigint NOT NULL,
"updatedAt" bigint NOT NULL
);
`);
await db.execute(
sql`ALTER TABLE "eventStreamingCursors" ADD CONSTRAINT "eventStreamingCursors_destinationId_eventStreamingDestinations_destinationId_fk" FOREIGN KEY ("destinationId") REFERENCES "public"."eventStreamingDestinations"("destinationId") ON DELETE cascade ON UPDATE no action;`
);
await db.execute(
sql`ALTER TABLE "eventStreamingDestinations" ADD CONSTRAINT "eventStreamingDestinations_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;`
);
await db.execute(
sql`CREATE UNIQUE INDEX "idx_eventStreamingCursors_dest_type" ON "eventStreamingCursors" USING btree ("destinationId","logType");`
);
await db.execute( await db.execute(
sql`ALTER TABLE "userOrgs" DROP CONSTRAINT "userOrgs_roleId_roles_roleId_fk";` sql`ALTER TABLE "userOrgs" DROP CONSTRAINT "userOrgs_roleId_roles_roleId_fk";`
); );
@@ -177,8 +213,12 @@ export default async function migration() {
sql`CREATE INDEX "idx_accessAuditLog_siteResourceId" ON "connectionAuditLog" USING btree ("siteResourceId");` sql`CREATE INDEX "idx_accessAuditLog_siteResourceId" ON "connectionAuditLog" USING btree ("siteResourceId");`
); );
await db.execute(sql`ALTER TABLE "userInvites" DROP COLUMN "roleId";`); await db.execute(sql`ALTER TABLE "userInvites" DROP COLUMN "roleId";`);
await db.execute(sql`ALTER TABLE "siteProvisioningKeys" ADD COLUMN "approveNewSites" boolean DEFAULT true NOT NULL;`); await db.execute(
await db.execute(sql`ALTER TABLE "sites" ADD COLUMN "status" varchar DEFAULT 'approved';`); sql`ALTER TABLE "siteProvisioningKeys" ADD COLUMN "approveNewSites" boolean DEFAULT true NOT NULL;`
);
await db.execute(
sql`ALTER TABLE "sites" ADD COLUMN "status" varchar DEFAULT 'approved';`
);
await db.execute(sql`COMMIT`); await db.execute(sql`COMMIT`);
console.log("Migrated database"); console.log("Migrated database");
@@ -195,7 +235,9 @@ export default async function migration() {
for (const row of existingUserInviteRoles) { for (const row of existingUserInviteRoles) {
await db.execute(sql` await db.execute(sql`
INSERT INTO "userInviteRoles" ("inviteId", "roleId") INSERT INTO "userInviteRoles" ("inviteId", "roleId")
VALUES (${row.inviteId}, ${row.roleId}) SELECT ${row.inviteId}, ${row.roleId}
WHERE EXISTS (SELECT 1 FROM "userInvites" WHERE "inviteId" = ${row.inviteId})
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
`); `);
} }
@@ -218,7 +260,10 @@ export default async function migration() {
for (const row of existingUserOrgRoles) { for (const row of existingUserOrgRoles) {
await db.execute(sql` await db.execute(sql`
INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId") INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId")
VALUES (${row.userId}, ${row.orgId}, ${row.roleId}) SELECT ${row.userId}, ${row.orgId}, ${row.roleId}
WHERE EXISTS (SELECT 1 FROM "user" WHERE "id" = ${row.userId})
AND EXISTS (SELECT 1 FROM "orgs" WHERE "orgId" = ${row.orgId})
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
`); `);
} }

View File

@@ -76,9 +76,15 @@ export default async function migration() {
` `
).run(); ).run();
db.prepare(`CREATE INDEX 'idx_accessAuditLog_startedAt' ON 'connectionAuditLog' ('startedAt');`).run(); db.prepare(
db.prepare(`CREATE INDEX 'idx_accessAuditLog_org_startedAt' ON 'connectionAuditLog' ('orgId','startedAt');`).run(); `CREATE INDEX 'idx_accessAuditLog_startedAt' ON 'connectionAuditLog' ('startedAt');`
db.prepare(`CREATE INDEX 'idx_accessAuditLog_siteResourceId' ON 'connectionAuditLog' ('siteResourceId');`).run(); ).run();
db.prepare(
`CREATE INDEX 'idx_accessAuditLog_org_startedAt' ON 'connectionAuditLog' ('orgId','startedAt');`
).run();
db.prepare(
`CREATE INDEX 'idx_accessAuditLog_siteResourceId' ON 'connectionAuditLog' ('siteResourceId');`
).run();
db.prepare( db.prepare(
` `
@@ -139,7 +145,7 @@ export default async function migration() {
).run(); ).run();
db.prepare( db.prepare(
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs';` `INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs' WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = userOrgs.userId) AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = userOrgs.orgId);`
).run(); ).run();
db.prepare(`DROP TABLE 'userOrgs';`).run(); db.prepare(`DROP TABLE 'userOrgs';`).run();
db.prepare( db.prepare(
@@ -168,6 +174,42 @@ export default async function migration() {
); );
` `
).run(); ).run();
db.prepare(
`
CREATE TABLE 'eventStreamingCursors' (
'cursorId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
'destinationId' integer NOT NULL,
'logType' text NOT NULL,
'lastSentId' integer DEFAULT 0 NOT NULL,
'lastSentAt' integer,
FOREIGN KEY ('destinationId') REFERENCES 'eventStreamingDestinations'('destinationId') ON UPDATE no action ON DELETE cascade
);
`
).run();
db.prepare(
`
CREATE UNIQUE INDEX 'idx_eventStreamingCursors_dest_type' ON 'eventStreamingCursors' ('destinationId','logType');--> statement-breakpoint
`
).run();
db.prepare(
`
CREATE TABLE 'eventStreamingDestinations' (
'destinationId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
'orgId' text NOT NULL,
'sendConnectionLogs' integer DEFAULT false NOT NULL,
'sendRequestLogs' integer DEFAULT false NOT NULL,
'sendActionLogs' integer DEFAULT false NOT NULL,
'sendAccessLogs' integer DEFAULT false NOT NULL,
'type' text NOT NULL,
'config' text NOT NULL,
'enabled' integer DEFAULT true NOT NULL,
'createdAt' integer NOT NULL,
'updatedAt' integer NOT NULL,
FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade
);
`
).run();
db.prepare( db.prepare(
`INSERT INTO '__new_userInvites'("inviteId", "orgId", "email", "expiresAt", "token") SELECT "inviteId", "orgId", "email", "expiresAt", "token" FROM 'userInvites';` `INSERT INTO '__new_userInvites'("inviteId", "orgId", "email", "expiresAt", "token") SELECT "inviteId", "orgId", "email", "expiresAt", "token" FROM 'userInvites';`
).run(); ).run();
@@ -191,8 +233,12 @@ export default async function migration() {
`ALTER TABLE 'user' ADD 'marketingEmailConsent' integer DEFAULT false;` `ALTER TABLE 'user' ADD 'marketingEmailConsent' integer DEFAULT false;`
).run(); ).run();
db.prepare(`ALTER TABLE 'user' ADD 'locale' text;`).run(); db.prepare(`ALTER TABLE 'user' ADD 'locale' text;`).run();
db.prepare(`ALTER TABLE 'siteProvisioningKeys' ADD COLUMN 'approveNewSites' integer DEFAULT 1 NOT NULL;`).run(); db.prepare(
db.prepare(`ALTER TABLE 'sites' ADD COLUMN 'status' text DEFAULT 'approved';`).run(); `ALTER TABLE 'siteProvisioningKeys' ADD COLUMN 'approveNewSites' integer DEFAULT 1 NOT NULL;`
).run();
db.prepare(
`ALTER TABLE 'sites' ADD COLUMN 'status' text DEFAULT 'approved';`
).run();
})(); })();
db.pragma("foreign_keys = ON"); db.pragma("foreign_keys = ON");
@@ -200,12 +246,15 @@ export default async function migration() {
// Re-insert the preserved invite role assignments into the new userInviteRoles table // Re-insert the preserved invite role assignments into the new userInviteRoles table
if (existingUserInviteRoles.length > 0) { if (existingUserInviteRoles.length > 0) {
const insertUserInviteRole = db.prepare( const insertUserInviteRole = db.prepare(
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId") VALUES (?, ?)` `INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId")
SELECT ?, ?
WHERE EXISTS (SELECT 1 FROM 'userInvites' WHERE inviteId = ?)
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
); );
const insertAll = db.transaction(() => { const insertAll = db.transaction(() => {
for (const row of existingUserInviteRoles) { for (const row of existingUserInviteRoles) {
insertUserInviteRole.run(row.inviteId, row.roleId); insertUserInviteRole.run(row.inviteId, row.roleId, row.inviteId, row.roleId);
} }
}); });
@@ -219,12 +268,16 @@ export default async function migration() {
// Re-insert the preserved role assignments into the new userOrgRoles table // Re-insert the preserved role assignments into the new userOrgRoles table
if (existingUserOrgRoles.length > 0) { if (existingUserOrgRoles.length > 0) {
const insertUserOrgRole = db.prepare( const insertUserOrgRole = db.prepare(
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId") VALUES (?, ?, ?)` `INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId")
SELECT ?, ?, ?
WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = ?)
AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = ?)
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
); );
const insertAll = db.transaction(() => { const insertAll = db.transaction(() => {
for (const row of existingUserOrgRoles) { for (const row of existingUserOrgRoles) {
insertUserOrgRole.run(row.userId, row.orgId, row.roleId); insertUserOrgRole.run(row.userId, row.orgId, row.roleId, row.userId, row.orgId, row.roleId);
} }
}); });

View File

@@ -491,6 +491,10 @@ export default function BillingPage() {
const currentPlanId = getCurrentPlanId(); const currentPlanId = getCurrentPlanId();
const visiblePlanOptions = planOptions.filter(
(plan) => plan.id !== "home" || currentPlanId === "home"
);
// Check if subscription is in a problematic state that requires attention // Check if subscription is in a problematic state that requires attention
const hasProblematicSubscription = (): boolean => { const hasProblematicSubscription = (): boolean => {
if (!tierSubscription?.subscription) return false; if (!tierSubscription?.subscription) return false;
@@ -803,8 +807,8 @@ export default function BillingPage() {
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
{/* Plan Cards Grid */} {/* Plan Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4"> <div className={cn("grid grid-cols-1 gap-4", visiblePlanOptions.length === 5 ? "md:grid-cols-5" : "md:grid-cols-4")}>
{planOptions.map((plan) => { {visiblePlanOptions.map((plan) => {
const isCurrentPlan = plan.id === currentPlanId; const isCurrentPlan = plan.id === currentPlanId;
const planAction = getPlanAction(plan); const planAction = getPlanAction(plan);

View File

@@ -10,6 +10,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { GetDNSRecordsResponse } from "@server/routers/domain"; import { GetDNSRecordsResponse } from "@server/routers/domain";
import DNSRecordsTable from "@app/components/DNSRecordTable"; import DNSRecordsTable from "@app/components/DNSRecordTable";
import DomainCertForm from "@app/components/DomainCertForm"; import DomainCertForm from "@app/components/DomainCertForm";
import { build } from "@server/build";
interface DomainSettingsPageProps { interface DomainSettingsPageProps {
params: Promise<{ domainId: string; orgId: string }>; params: Promise<{ domainId: string; orgId: string }>;
@@ -65,12 +66,14 @@ export default async function DomainSettingsPage({
)} )}
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<DomainInfoCard {build != "oss" && env.flags.usePangolinDns ? (
failed={domain.failed} <DomainInfoCard
verified={domain.verified} failed={domain.failed}
type={domain.type} verified={domain.verified}
errorMessage={domain.errorMessage} type={domain.type}
/> errorMessage={domain.errorMessage}
/>
) : null}
<DNSRecordsTable records={dnsRecords} type={domain.type} /> <DNSRecordsTable records={dnsRecords} type={domain.type} />

View File

@@ -471,11 +471,7 @@ export default function GeneralPage() {
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}` : `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
} }
> >
<Button <Button variant="outline" size="sm">
variant="outline"
size="sm"
className="text-xs h-6"
>
{row.original.resourceName} {row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
</Button> </Button>

View File

@@ -451,11 +451,7 @@ export default function ConnectionLogsPage() {
<Link <Link
href={`/${row.original.orgId}/settings/resources/client/?query=${row.original.resourceNiceId}`} href={`/${row.original.orgId}/settings/resources/client/?query=${row.original.resourceNiceId}`}
> >
<Button <Button variant="outline" size="sm">
variant="outline"
size="sm"
className="text-xs h-6"
>
{row.original.resourceName} {row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
</Button> </Button>
@@ -491,17 +487,13 @@ export default function ConnectionLogsPage() {
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
const clientType = row.original.clientType === "olm" ? "machine" : "user"; const clientType = row.original.userId ? "user" : "machine";
if (row.original.clientName && row.original.clientNiceId) { if (row.original.clientName && row.original.clientNiceId) {
return ( return (
<Link <Link
href={`/${row.original.orgId}/settings/clients/${clientType}/${row.original.clientNiceId}`} href={`/${row.original.orgId}/settings/clients/${clientType}/${row.original.clientNiceId}`}
> >
<Button <Button variant="outline" size="sm">
variant="outline"
size="sm"
className="text-xs h-6"
>
<Laptop className="mr-1 h-3 w-3" /> <Laptop className="mr-1 h-3 w-3" />
{row.original.clientName} {row.original.clientName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
@@ -675,9 +667,7 @@ export default function ConnectionLogsPage() {
<div> <div>
<strong>Ended At:</strong>{" "} <strong>Ended At:</strong>{" "}
{row.endedAt {row.endedAt
? new Date( ? new Date(row.endedAt * 1000).toLocaleString()
row.endedAt * 1000
).toLocaleString()
: "Active"} : "Active"}
</div> </div>
<div> <div>

View File

@@ -360,6 +360,7 @@ export default function GeneralPage() {
// 105 - Valid Password // 105 - Valid Password
// 106 - Valid email // 106 - Valid email
// 107 - Valid SSO // 107 - Valid SSO
// 108 - Connected Client
// 201 - Resource Not Found // 201 - Resource Not Found
// 202 - Resource Blocked // 202 - Resource Blocked
@@ -377,6 +378,7 @@ export default function GeneralPage() {
105: t("validPassword"), 105: t("validPassword"),
106: t("validEmail"), 106: t("validEmail"),
107: t("validSSO"), 107: t("validSSO"),
108: t("connectedClient"),
201: t("resourceNotFound"), 201: t("resourceNotFound"),
202: t("resourceBlocked"), 202: t("resourceBlocked"),
203: t("droppedByRule"), 203: t("droppedByRule"),
@@ -510,14 +512,14 @@ export default function GeneralPage() {
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<Link <Link
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`} href={
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
? `/${row.original.orgId}/settings/resources/client?query=${row.original.resourceNiceId}`
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Button <Button variant="outline" size="sm">
variant="outline"
size="sm"
className="text-xs h-6"
>
{row.original.resourceName} {row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
</Button> </Button>
@@ -634,6 +636,7 @@ export default function GeneralPage() {
{ value: "105", label: t("validPassword") }, { value: "105", label: t("validPassword") },
{ value: "106", label: t("validEmail") }, { value: "106", label: t("validEmail") },
{ value: "107", label: t("validSSO") }, { value: "107", label: t("validSSO") },
{ value: "108", label: t("connectedClient") },
{ value: "201", label: t("resourceNotFound") }, { value: "201", label: t("resourceNotFound") },
{ value: "202", label: t("resourceBlocked") }, { value: "202", label: t("resourceBlocked") },
{ value: "203", label: t("droppedByRule") }, { value: "203", label: t("droppedByRule") },

View File

@@ -106,7 +106,9 @@ function DestinationCard({
{/* URL preview */} {/* URL preview */}
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{cfg.url || ( {cfg.url || (
<span className="italic">{t("streamingNoUrlConfigured")}</span> <span className="italic">
{t("streamingNoUrlConfigured")}
</span>
)} )}
</p> </p>
@@ -160,7 +162,9 @@ function AddDestinationCard({ onClick }: { onClick: () => void }) {
<div className="flex items-center justify-center w-9 h-9 rounded-md border-2 border-dashed border-current"> <div className="flex items-center justify-center w-9 h-9 rounded-md border-2 border-dashed border-current">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</div> </div>
<span className="text-sm font-medium">{t("streamingAddDestination")}</span> <span className="text-sm font-medium">
{t("streamingAddDestination")}
</span>
</div> </div>
</button> </button>
); );
@@ -186,7 +190,9 @@ function DestinationTypePicker({
const t = useTranslations(); const t = useTranslations();
const [selected, setSelected] = useState<DestinationType>("http"); const [selected, setSelected] = useState<DestinationType>("http");
const destinationTypeOptions: ReadonlyArray<StrategyOption<DestinationType>> = [ const destinationTypeOptions: ReadonlyArray<
StrategyOption<DestinationType>
> = [
{ {
id: "http", id: "http",
title: t("streamingHttpWebhookTitle"), title: t("streamingHttpWebhookTitle"),
@@ -233,13 +239,19 @@ function DestinationTypePicker({
<Credenza open={open} onOpenChange={onOpenChange}> <Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent className="sm:max-w-lg"> <CredenzaContent className="sm:max-w-lg">
<CredenzaHeader> <CredenzaHeader>
<CredenzaTitle>{t("streamingAddDestination")}</CredenzaTitle> <CredenzaTitle>
{t("streamingAddDestination")}
</CredenzaTitle>
<CredenzaDescription> <CredenzaDescription>
{t("streamingTypePickerDescription")} {t("streamingTypePickerDescription")}
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<div className={isPaywalled ? "pointer-events-none opacity-50" : ""}> <div
className={
isPaywalled ? "pointer-events-none opacity-50" : ""
}
>
<StrategySelect <StrategySelect
options={destinationTypeOptions} options={destinationTypeOptions}
value={selected} value={selected}
@@ -301,10 +313,7 @@ export default function StreamingDestinationsPage() {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("streamingFailedToLoad"), title: t("streamingFailedToLoad"),
description: formatAxiosError( description: formatAxiosError(e, t("streamingUnexpectedError"))
e,
t("streamingUnexpectedError")
)
}); });
} finally { } finally {
setLoading(false); setLoading(false);
@@ -341,10 +350,7 @@ export default function StreamingDestinationsPage() {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("streamingFailedToUpdate"), title: t("streamingFailedToUpdate"),
description: formatAxiosError( description: formatAxiosError(e, t("streamingUnexpectedError"))
e,
t("streamingUnexpectedError")
)
}); });
} finally { } finally {
setTogglingIds((prev) => { setTogglingIds((prev) => {
@@ -375,10 +381,7 @@ export default function StreamingDestinationsPage() {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("streamingFailedToDelete"), title: t("streamingFailedToDelete"),
description: formatAxiosError( description: formatAxiosError(e, t("streamingUnexpectedError"))
e,
t("streamingUnexpectedError")
)
}); });
} finally { } finally {
setDeleting(false); setDeleting(false);
@@ -459,13 +462,14 @@ export default function StreamingDestinationsPage() {
if (!v) setDeleteTarget(null); if (!v) setDeleteTarget(null);
}} }}
string={ string={
parseHttpConfig(deleteTarget.config).name || t("streamingDeleteDialogThisDestination") parseHttpConfig(deleteTarget.config).name ||
t("streamingDeleteDialogThisDestination")
} }
title={t("streamingDeleteTitle")} title={t("streamingDeleteTitle")}
dialog={ dialog={
<p className="text-sm text-muted-foreground"> <p>
{t("streamingDeleteDialogAreYouSure")}{" "} {t("streamingDeleteDialogAreYouSure")}{" "}
<span className="font-semibold text-foreground"> <span>
{parseHttpConfig(deleteTarget.config).name || {parseHttpConfig(deleteTarget.config).name ||
t("streamingDeleteDialogThisDestination")} t("streamingDeleteDialogThisDestination")}
</span> </span>
@@ -478,4 +482,4 @@ export default function StreamingDestinationsPage() {
)} )}
</> </>
); );
} }

View File

@@ -60,23 +60,34 @@ export default async function ClientResourcesPage(
id: siteResource.siteResourceId, id: siteResource.siteResourceId,
name: siteResource.name, name: siteResource.name,
orgId: params.orgId, orgId: params.orgId,
siteName: siteResource.siteName, sites: siteResource.siteIds.map((siteId, idx) => ({
siteAddress: siteResource.siteAddress || null, siteId,
mode: siteResource.mode || ("port" as any), siteName: siteResource.siteNames[idx],
siteNiceId: siteResource.siteNiceIds[idx],
online: siteResource.siteOnlines[idx]
})),
mode: siteResource.mode,
scheme: siteResource.scheme,
ssl: siteResource.ssl,
siteNames: siteResource.siteNames,
siteAddresses: siteResource.siteAddresses || null,
// protocol: siteResource.protocol, // protocol: siteResource.protocol,
// proxyPort: siteResource.proxyPort, // proxyPort: siteResource.proxyPort,
siteId: siteResource.siteId, siteIds: siteResource.siteIds,
destination: siteResource.destination, destination: siteResource.destination,
// destinationPort: siteResource.destinationPort, httpHttpsPort: siteResource.destinationPort ?? null,
alias: siteResource.alias || null, alias: siteResource.alias || null,
aliasAddress: siteResource.aliasAddress || null, aliasAddress: siteResource.aliasAddress || null,
siteNiceId: siteResource.siteNiceId, siteNiceIds: siteResource.siteNiceIds,
niceId: siteResource.niceId, niceId: siteResource.niceId,
tcpPortRangeString: siteResource.tcpPortRangeString || null, tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null, udpPortRangeString: siteResource.udpPortRangeString || null,
disableIcmp: siteResource.disableIcmp || false, disableIcmp: siteResource.disableIcmp || false,
authDaemonMode: siteResource.authDaemonMode ?? null, authDaemonMode: siteResource.authDaemonMode ?? null,
authDaemonPort: siteResource.authDaemonPort ?? null authDaemonPort: siteResource.authDaemonPort ?? null,
subdomain: siteResource.subdomain ?? null,
domainId: siteResource.domainId ?? null,
fullDomain: siteResource.fullDomain ?? null
}; };
} }
); );

View File

@@ -133,8 +133,7 @@ export default function ResourceAuthenticationPage() {
...orgQueries.identityProviders({ ...orgQueries.identityProviders({
orgId: org.org.orgId, orgId: org.org.orgId,
useOrgOnlyIdp: env.app.identityProviderMode === "org" useOrgOnlyIdp: env.app.identityProviderMode === "org"
}), })
enabled: isPaidUser(tierMatrix.orgOidc)
}); });
const pageLoading = const pageLoading =

View File

@@ -400,7 +400,11 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType pathMatchType: row.original.pathMatchType
}} }}
onChange={(config) => onChange={(config) =>
updateTarget(row.original.targetId, config) updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
} }
trigger={ trigger={
<Button <Button
@@ -424,7 +428,11 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType pathMatchType: row.original.pathMatchType
}} }}
onChange={(config) => onChange={(config) =>
updateTarget(row.original.targetId, config) updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
} }
trigger={ trigger={
<Button <Button
@@ -670,6 +678,7 @@ function ProxyResourceTargetsForm({
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
getRowId: (row) => String(row.targetId),
state: { state: {
pagination: { pagination: {
pageIndex: 0, pageIndex: 0,
@@ -774,8 +783,12 @@ function ProxyResourceTargetsForm({
} }
toast({ toast({
title: t("settingsUpdated"), title: targets.length === 0
description: t("settingsUpdatedDescription") ? t("targetTargetsCleared")
: t("settingsUpdated"),
description: targets.length === 0
? t("targetTargetsClearedDescription")
: t("settingsUpdatedDescription")
}); });
setTargetsToRemove([]); setTargetsToRemove([]);

View File

@@ -776,7 +776,11 @@ export default function Page() {
pathMatchType: row.original.pathMatchType pathMatchType: row.original.pathMatchType
}} }}
onChange={(config) => onChange={(config) =>
updateTarget(row.original.targetId, config) updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
} }
trigger={ trigger={
<Button <Button
@@ -800,7 +804,11 @@ export default function Page() {
pathMatchType: row.original.pathMatchType pathMatchType: row.original.pathMatchType
}} }}
onChange={(config) => onChange={(config) =>
updateTarget(row.original.targetId, config) updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
} }
trigger={ trigger={
<Button <Button
@@ -991,6 +999,7 @@ export default function Page() {
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
getRowId: (row) => String(row.targetId),
state: { state: {
pagination: { pagination: {
pageIndex: 0, pageIndex: 0,

View File

@@ -95,7 +95,8 @@ export default async function ProxyResourcesPage(
ip: target.ip, ip: target.ip,
port: target.port, port: target.port,
enabled: target.enabled, enabled: target.enabled,
healthStatus: target.healthStatus healthStatus: target.healthStatus,
siteName: target.siteName
})) }))
}; };
}); });

View File

@@ -42,7 +42,9 @@ import {
SettingsSectionFooter SettingsSectionFooter
} from "@app/components/Settings"; } from "@app/components/Settings";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Check, Heart, InfoIcon } from "lucide-react"; import { ArrowRight, Check, ExternalLink, Heart, InfoIcon, TicketCheck } from "lucide-react";
import Link from "next/link";
import DismissableBanner from "@app/components/DismissableBanner";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { SitePriceCalculator } from "@app/components/SitePriceCalculator"; import { SitePriceCalculator } from "@app/components/SitePriceCalculator";
@@ -51,6 +53,10 @@ import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
const ENTERPRISE_DOCS_URL =
"https://docs.pangolin.net/self-host/enterprise-edition";
const ENTERPRISE_PRICING_URL = "https://pangolin.net/pricing#Self-Hosted";
function obfuscateLicenseKey(key: string): string { function obfuscateLicenseKey(key: string): string {
if (key.length <= 8) return key; if (key.length <= 8) return key;
const firstPart = key.substring(0, 4); const firstPart = key.substring(0, 4);
@@ -336,6 +342,47 @@ export default function LicensePage() {
description={t("licenseTitleDescription")} description={t("licenseTitleDescription")}
/> />
{!licenseStatus?.isLicenseValid && (
<DismissableBanner
storageKey="license-banner-dismissed"
version={1}
title={t("licenseBannerTitle")}
titleIcon={
<TicketCheck className="w-5 h-5 text-primary" />
}
description={t("licenseBannerDescription")}
>
<Link
href={ENTERPRISE_PRICING_URL}
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="default"
size="sm"
className="gap-2"
>
{t("licenseBannerGetLicense")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
<Link
href={ENTERPRISE_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("licenseBannerViewDocs")}
<ExternalLink className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
)}
{/* <Alert variant="neutral" className="mb-6"> */} {/* <Alert variant="neutral" className="mb-6"> */}
{/* <InfoIcon className="h-4 w-4" /> */} {/* <InfoIcon className="h-4 w-4" /> */}
{/* <AlertTitle className="font-semibold"> */} {/* <AlertTitle className="font-semibold"> */}

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