Compare commits

..

138 Commits
1.9.0 ... patch

Author SHA1 Message Date
miloschwartz
a0a612618e fixed email undefined error on request email code 2025-09-10 14:20:25 -07:00
Owen Schwartz
56ee68d9f3 Merge pull request #1434 from fosrl/dependabot/github_actions/actions/setup-node-5
Bump actions/setup-node from 4 to 5
2025-09-07 22:01:36 -07:00
Owen Schwartz
e81fd3bb31 Merge pull request #1435 from fosrl/dependabot/github_actions/actions/setup-go-6
Bump actions/setup-go from 5 to 6
2025-09-07 22:01:28 -07:00
Owen Schwartz
938ca29777 Merge pull request #1436 from fosrl/dependabot/github_actions/actions/stale-10
Bump actions/stale from 9 to 10
2025-09-07 22:01:21 -07:00
dependabot[bot]
92ac2dbac2 Bump actions/stale from 9 to 10
Bumps [actions/stale](https://github.com/actions/stale) from 9 to 10.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v9...v10)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: '10'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 01:32:59 +00:00
dependabot[bot]
d3e6decef9 Bump actions/setup-go from 5 to 6
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 01:32:10 +00:00
dependabot[bot]
579cd9d338 Bump actions/setup-node from 4 to 5
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 01:32:04 +00:00
Milo Schwartz
3292eafe4a Update README.md 2025-09-07 01:36:37 -04:00
Owen Schwartz
042e2c1390 Merge pull request #1413 from AstralDestiny/traefik-dynamic_config-cleanup
Update Traefik to not declare an unnecessary path and make the config cleaner.
2025-09-04 10:46:42 -07:00
AstralDestiny
e6314bee35 Update Traefik to not declare an unnecessary path and make the config cleaner. 2025-09-04 11:44:20 -04:00
Owen Schwartz
cd79e77576 Merge pull request #1397 from fosrl/dev
1.9.4
2025-09-01 17:47:01 -07:00
Owen Schwartz
1f1c20d637 Merge pull request #1396 from fosrl/crowdin_dev
New Crowdin updates
2025-09-01 17:46:22 -07:00
Owen Schwartz
e87b3b1b54 New translations en-us.json (Norwegian Bokmal) 2025-09-01 17:46:02 -07:00
Owen Schwartz
a6f7b65625 New translations en-us.json (Chinese Simplified) 2025-09-01 17:46:01 -07:00
Owen Schwartz
722fa47132 New translations en-us.json (Turkish) 2025-09-01 17:46:00 -07:00
Owen Schwartz
f83e290b4c New translations en-us.json (Russian) 2025-09-01 17:45:59 -07:00
Owen Schwartz
11b4047283 New translations en-us.json (Portuguese) 2025-09-01 17:45:57 -07:00
Owen Schwartz
69b2032a86 New translations en-us.json (Polish) 2025-09-01 17:45:56 -07:00
Owen Schwartz
636298569f New translations en-us.json (Dutch) 2025-09-01 17:45:55 -07:00
Owen Schwartz
ed8a282d35 New translations en-us.json (Korean) 2025-09-01 17:45:54 -07:00
Owen Schwartz
3bd5e850e0 New translations en-us.json (Italian) 2025-09-01 17:45:52 -07:00
Owen Schwartz
070f1f9159 New translations en-us.json (German) 2025-09-01 17:45:51 -07:00
Owen Schwartz
195644cca5 New translations en-us.json (Czech) 2025-09-01 17:45:50 -07:00
Owen Schwartz
8092c86ecd New translations en-us.json (Bulgarian) 2025-09-01 17:45:49 -07:00
Owen Schwartz
28f33702da New translations en-us.json (Spanish) 2025-09-01 17:45:48 -07:00
Owen Schwartz
570632b8be New translations en-us.json (French) 2025-09-01 17:45:46 -07:00
Owen
f2881e1b31 Merge branch 'Pallavikumarimdb-enhancement-#906/persist-amount-of-entries' into dev 2025-09-01 17:40:02 -07:00
Owen
dad35e37ef Merge branch 'enhancement-#906/persist-amount-of-entries' of github.com:Pallavikumarimdb/pangolin into Pallavikumarimdb-enhancement-#906/persist-amount-of-entries 2025-09-01 17:39:16 -07:00
Owen
39afabd60e Source maps as js 2025-09-01 14:03:32 -07:00
Owen
dc7e14a34b Limit saas 2025-09-01 11:39:30 -07:00
Owen
1dca71a779 Try to include source maps 2025-09-01 11:29:49 -07:00
Pallavi
e9494efa8e quick fix 2025-09-01 23:06:39 +05:30
Owen Schwartz
8159a0f13d Merge pull request #1394 from Pallavikumarimdb/Fix/hostname-field-reset-port-and-method
Fix/hostname field reset port and method
2025-09-01 10:21:31 -07:00
Pallavi
ee9101e738 Save Amount of Entries 2025-09-01 22:26:12 +05:30
Pallavi
b670e6e3dc update parser to handle h2c 2025-09-01 21:47:50 +05:30
Pallavi
5e5754fa62 preserve port and method on host change 2025-09-01 21:22:18 +05:30
Owen Schwartz
5fcf76066f Merge pull request #1391 from fosrl/dev
1.9.3
2025-08-31 20:58:35 -07:00
Owen
601645fa72 Fix translations
Fix #1355
2025-08-31 20:56:49 -07:00
Owen
12765ad675 Merge branch 'Pallavikumarimdb-Fix/allow-unicode-domain-name' into dev 2025-08-31 19:41:35 -07:00
Owen
ad3383d23d Merge branch 'Fix/allow-unicode-domain-name' of github.com:Pallavikumarimdb/pangolin into Pallavikumarimdb-Fix/allow-unicode-domain-name 2025-08-31 19:40:13 -07:00
Pallavi
7d5961cf50 Support unicode with subdomain sanitized 2025-08-31 22:45:42 +05:30
Owen
864aa052f1 Merge branch 'Hetav21-enhancement-1318' into dev 2025-08-31 09:50:47 -07:00
Hetav21
be16196058 feat: make version numbers link to GitHub releases and add Discord link 2025-08-31 21:19:34 +05:30
Pallavi
8a62f12e8b fix lint 2025-08-31 17:53:36 +05:30
Pallavi
78f464f6ca Show/allow unicode domain name 2025-08-31 17:53:35 +05:30
Owen
f37eda4739 Fix #1376 2025-08-30 22:28:37 -07:00
Owen
4e106e9e5a Make more explicit in config telemetry
Fixes #1374
2025-08-30 22:22:42 -07:00
Owen
ccf8e5e6f4 Dont pull org from api key
Fixes #1361
2025-08-30 22:12:35 -07:00
Owen
9455adf61f Add list invitations to integration api
Fixes #1364
2025-08-30 21:18:22 -07:00
miloschwartz
970ab9818a translate managed page 2025-08-30 16:51:44 -07:00
Owen Schwartz
7848cf7141 Merge pull request #1377 from fosrl/dependabot/npm_and_yarn/dev-patch-updates-f90e31e16c
Bump the dev-patch-updates group across 1 directory with 2 updates
2025-08-30 16:12:29 -07:00
Owen
8e5aa9c195 Merge branch 'Pallavikumarimdb-feature-906/smart-host-parsing' into dev 2025-08-30 15:52:45 -07:00
Owen
a03e9ba7dd Merge branch 'feature-906/smart-host-parsing' of github.com:Pallavikumarimdb/pangolin into Pallavikumarimdb-feature-906/smart-host-parsing 2025-08-30 15:51:15 -07:00
Owen
9e646ba385 Merge branch 'Pallavikumarimdb-Fix/domain-picker-issue' into dev 2025-08-30 15:24:15 -07:00
Owen
d9a4f20fe6 Merge branch 'Fix/domain-picker-issue' of github.com:Pallavikumarimdb/pangolin into Pallavikumarimdb-Fix/domain-picker-issue 2025-08-30 15:22:02 -07:00
Owen
e659f0e75d Fix type 2025-08-29 15:39:06 -07:00
Owen
8891d6239f Handle wildcard certs 2025-08-29 15:35:57 -07:00
Pallavi
e3bd3fb985 consistent full domain 2025-08-30 02:59:23 +05:30
Pallavi
54764dfacd unify subdomain validation schema to handle edge cases 2025-08-30 01:14:03 +05:30
Owen
b156b5ff2d Make /32 to not mess with newt 2025-08-28 22:42:27 -07:00
Owen
d8e547c9a0 Configure if allow raw resources 2025-08-28 22:11:24 -07:00
dependabot[bot]
a0b93377a4 Bump the dev-patch-updates group across 1 directory with 2 updates
Bumps the dev-patch-updates group with 2 updates in the / directory: [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom).


Updates `@types/react` from 19.1.11 to 19.1.12
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/react-dom` from 19.1.8 to 19.1.9
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.1.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/react-dom"
  dependency-version: 19.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-29 01:23:18 +00:00
Pallavi
e8a6efd079 subdomain validation consistent 2025-08-29 03:58:49 +05:30
Pallavi
18bb6caf8f allows typing flow while providing helpful validation 2025-08-29 02:44:39 +05:30
Pallavi
bc335d15c0 preserve subdomain with sanitizeSubdomain and validateSubdomain 2025-08-29 01:12:12 +05:30
Owen Schwartz
2a0d440a34 Merge pull request #1366 from fosrl/dev
1.9.2
2025-08-27 12:02:21 -07:00
Owen Schwartz
b0500fac29 Merge pull request #1367 from fosrl/crowdin_dev
New Crowdin updates
2025-08-27 11:59:06 -07:00
Owen Schwartz
af16e6423a New translations en-us.json (Norwegian Bokmal) 2025-08-27 11:58:46 -07:00
Owen Schwartz
ff90471f0f New translations en-us.json (Chinese Simplified) 2025-08-27 11:58:44 -07:00
Owen Schwartz
bcc501c524 New translations en-us.json (Turkish) 2025-08-27 11:58:43 -07:00
Owen Schwartz
c4ef211a3e New translations en-us.json (Russian) 2025-08-27 11:58:42 -07:00
Owen Schwartz
592c0eb7ab New translations en-us.json (Portuguese) 2025-08-27 11:58:41 -07:00
Owen Schwartz
880a000865 New translations en-us.json (Polish) 2025-08-27 11:58:40 -07:00
Owen Schwartz
a9571f6adf New translations en-us.json (Dutch) 2025-08-27 11:58:38 -07:00
Owen Schwartz
ca91f313bc New translations en-us.json (Korean) 2025-08-27 11:58:37 -07:00
Owen Schwartz
76b9753916 New translations en-us.json (Italian) 2025-08-27 11:58:36 -07:00
Owen Schwartz
cd8bbe28bf New translations en-us.json (German) 2025-08-27 11:58:35 -07:00
Owen Schwartz
9550c11594 New translations en-us.json (Spanish) 2025-08-27 11:58:32 -07:00
Owen Schwartz
7ac21cad25 New translations en-us.json (French) 2025-08-27 11:58:30 -07:00
Owen
2008a3955a Change destructuring 2025-08-27 11:31:15 -07:00
Owen
f1641c9f3e Dont create exit node on new key
Fixes #1347
Fixes #776
Fixes #1090
2025-08-27 11:25:10 -07:00
Owen Schwartz
bec5bbd033 Merge pull request #1329 from fosrl/dependabot/go_modules/install/prod-minor-updates-b66dbea3a9
Bump golang.org/x/term from 0.33.0 to 0.34.0 in /install in the prod-minor-updates group
2025-08-27 10:55:48 -07:00
Owen Schwartz
ae11f72e28 Merge pull request #1362 from fosrl/dependabot/npm_and_yarn/dev-patch-updates-43a727f364
Bump the dev-patch-updates group across 1 directory with 3 updates
2025-08-27 10:50:20 -07:00
Owen Schwartz
6b88cb3920 Merge pull request #1354 from fosrl/crowdin_dev
New Crowdin updates
2025-08-27 10:47:58 -07:00
dependabot[bot]
38772111e8 Bump the dev-patch-updates group across 1 directory with 3 updates
Bumps the dev-patch-updates group with 3 updates in the / directory: [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react), [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) and [tsx](https://github.com/privatenumber/tsx).


Updates `@types/react` from 19.1.10 to 19.1.11
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/react-dom` from 19.1.7 to 19.1.8
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `tsx` from 4.20.4 to 4.20.5
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.20.4...v4.20.5)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/react-dom"
  dependency-version: 19.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tsx
  dependency-version: 4.20.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-27 11:45:42 +00:00
Owen Schwartz
14f50c3e66 New translations en-us.json (Czech) 2025-08-27 02:22:26 -07:00
Owen
b89a2c9e49 Trust the gerbil proxy for proxyProtcol 2025-08-26 22:36:33 -07:00
Owen Schwartz
2d2eda988c New translations en-us.json (Norwegian Bokmal) 2025-08-26 22:34:55 -07:00
Owen Schwartz
432033969b New translations en-us.json (Chinese Simplified) 2025-08-26 22:34:54 -07:00
Owen Schwartz
1fbf74e1f7 New translations en-us.json (Turkish) 2025-08-26 22:34:53 -07:00
Owen Schwartz
93648ff00b New translations en-us.json (Russian) 2025-08-26 22:34:52 -07:00
Owen Schwartz
9513136610 New translations en-us.json (Portuguese) 2025-08-26 22:34:50 -07:00
Owen Schwartz
43a2a39f8d New translations en-us.json (Polish) 2025-08-26 22:34:49 -07:00
Owen Schwartz
218351de9a New translations en-us.json (Dutch) 2025-08-26 22:34:48 -07:00
Owen Schwartz
b92b922eee New translations en-us.json (Korean) 2025-08-26 22:34:46 -07:00
Owen Schwartz
91be4937ee New translations en-us.json (Italian) 2025-08-26 22:34:45 -07:00
Owen Schwartz
19b36a5fae New translations en-us.json (German) 2025-08-26 22:34:44 -07:00
Owen Schwartz
bb9ee7dfd2 New translations en-us.json (Czech) 2025-08-26 22:34:42 -07:00
Owen Schwartz
ac0351b525 New translations en-us.json (Bulgarian) 2025-08-26 22:34:41 -07:00
Owen Schwartz
405f5ad7cc New translations en-us.json (Spanish) 2025-08-26 22:34:40 -07:00
Owen Schwartz
8cc2712da3 New translations en-us.json (French) 2025-08-26 22:34:39 -07:00
Owen
c02ac8d1bf Seperate out function 2025-08-26 17:19:04 -07:00
Owen
a1802add19 Geoblocking works 2025-08-26 17:14:55 -07:00
Owen
218a6642a2 Merge branch 'dev' into geoip 2025-08-25 21:07:17 -07:00
dependabot[bot]
21a83a5755 Bump golang.org/x/term in /install in the prod-minor-updates group
Bumps the prod-minor-updates group in /install with 1 update: [golang.org/x/term](https://github.com/golang/term).


Updates `golang.org/x/term` from 0.33.0 to 0.34.0
- [Commits](https://github.com/golang/term/compare/v0.33.0...v0.34.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-version: 0.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-26 02:12:49 +00:00
Owen Schwartz
bec75e51f6 New translations en-us.json (French) 2025-08-25 17:41:14 -07:00
Owen Schwartz
6c9b445be6 Merge pull request #1353 from fosrl/dev
1.9.1
2025-08-25 17:13:33 -07:00
Owen
06b17fa941 Merge branch 'main' into dev 2025-08-25 17:07:28 -07:00
Owen
e1d4c029e7 Remove cancel button
Fixes #1312
2025-08-25 17:00:51 -07:00
Owen
293fd70ccb Remove bad file 2025-08-25 16:51:33 -07:00
Owen Schwartz
4ee863db5a Merge pull request #1352 from fosrl/crowdin_dev
New Crowdin updates
2025-08-25 16:42:41 -07:00
Owen Schwartz
2717be0fed New translations en-us.json (Norwegian Bokmal) 2025-08-25 16:42:22 -07:00
Owen Schwartz
1f312e146f New translations en-us.json (Chinese Simplified) 2025-08-25 16:42:21 -07:00
Owen Schwartz
b91557ebb0 New translations en-us.json (Turkish) 2025-08-25 16:42:20 -07:00
Owen Schwartz
465380b5a3 New translations en-us.json (Russian) 2025-08-25 16:42:19 -07:00
Owen Schwartz
60af901feb New translations en-us.json (Portuguese) 2025-08-25 16:42:17 -07:00
Owen Schwartz
ea78a654ff New translations en-us.json (Polish) 2025-08-25 16:42:16 -07:00
Owen Schwartz
f28b6ad0a5 New translations en-us.json (Dutch) 2025-08-25 16:42:15 -07:00
Owen Schwartz
a3bdab1318 New translations en-us.json (Korean) 2025-08-25 16:42:14 -07:00
Owen Schwartz
f8c5d01e3c New translations en-us.json (Italian) 2025-08-25 16:42:13 -07:00
Owen Schwartz
3ebe218b7f New translations en-us.json (German) 2025-08-25 16:42:12 -07:00
Owen Schwartz
7d039ab729 New translations en-us.json (Czech) 2025-08-25 16:42:10 -07:00
Owen Schwartz
b2b6c8c268 New translations en-us.json (Bulgarian) 2025-08-25 16:42:09 -07:00
Owen Schwartz
4950f25063 New translations en-us.json (Spanish) 2025-08-25 16:42:08 -07:00
Owen Schwartz
524d6b48d9 New translations en-us.json (French) 2025-08-25 16:42:07 -07:00
Owen
29fb5735e2 Add missing api endpoints to integration
Fixes #1344
2025-08-25 16:23:22 -07:00
Owen
247fc85440 Fix #1339 2025-08-25 16:08:37 -07:00
Owen
2b4302572c Fix #1343 2025-08-25 13:58:21 -07:00
Owen
9b28780e62 Merge branch 'main' of github.com:fosrl/pangolin 2025-08-25 13:56:34 -07:00
Owen Schwartz
8656f68008 Merge pull request #1341 from SINF-KEN/main
fix typos french.
2025-08-25 11:10:35 -07:00
SINF-KEN
15651b6919 fix typos french. 2025-08-25 12:33:45 +02:00
Owen
78d3861382 Add pass rule 2025-08-24 22:20:09 -07:00
Owen
72f19274cd Add ip lookup 2025-08-24 21:58:52 -07:00
Owen
adbcd1a2e0 Add missing cols 2025-08-24 13:51:03 -07:00
Owen
5b7727fab4 Fix #1332 2025-08-24 12:22:54 -07:00
Owen
9627dfa90c Add ipKeyGenerator 2025-08-24 12:18:34 -07:00
Pallavi
fb1481c69c fix lint issue 2025-08-22 19:18:47 +05:30
Pallavi
9557f755a5 Add Smart Host Parsing 2025-08-22 13:07:03 +05:30
91 changed files with 2576 additions and 739 deletions

View File

@@ -28,7 +28,7 @@ jobs:
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: Install Go - name: Install Go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version: 1.24 go-version: 1.24

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: '22' node-version: '22'

View File

@@ -14,7 +14,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v9 - uses: actions/stale@v10
with: with:
days-before-stale: 14 days-before-stale: 14
days-before-close: 14 days-before-close: 14

View File

@@ -13,7 +13,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- uses: actions/setup-node@v4 - uses: actions/setup-node@v5
with: with:
node-version: '22' node-version: '22'

View File

@@ -20,11 +20,11 @@ _Pangolin tunnels your services to the internet so you can access anything from
Website Website
</a> </a>
<span> | </span> <span> | </span>
<a href="https://docs.digpangolin.com/self-host/quick-install"> <a href="https://docs.digpangolin.com/self-host/quick-install-managed">
Install Guide Quick Install Guide
</a> </a>
<span> | </span> <span> | </span>
<a href="mailto:numbat@fossorial.io"> <a href="mailto:contact@fossorial.io">
Contact Us Contact Us
</a> </a>
</h5> </h5>

View File

@@ -16,8 +16,9 @@ http:
# Next.js router (handles everything except API and WebSocket paths) # Next.js router (handles everything except API and WebSocket paths)
next-router: next-router:
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)" rule: "Host(`{{.DashboardDomain}}`)"
service: next-service service: next-service
priority: 10
entryPoints: entryPoints:
- websecure - websecure
tls: tls:
@@ -27,15 +28,7 @@ http:
api-router: api-router:
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)" rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
service: api-service service: api-service
entryPoints: priority: 100
- websecure
tls:
certResolver: letsencrypt
# WebSocket router
ws-router:
rule: "Host(`{{.DashboardDomain}}`)"
service: api-service
entryPoints: entryPoints:
- websecure - websecure
tls: tls:

View File

@@ -63,7 +63,7 @@ esbuild
packagePath: getPackagePaths(), packagePath: getPackagePaths(),
}), }),
], ],
sourcemap: true, sourcemap: "external",
target: "node22", target: "node22",
}) })
.then(() => { .then(() => {

View File

@@ -13,6 +13,8 @@ managed:
app: app:
dashboard_url: "https://{{.DashboardDomain}}" dashboard_url: "https://{{.DashboardDomain}}"
log_level: "info" log_level: "info"
telemetry:
anonymous_usage: true
domains: domains:
domain1: domain1:

View File

@@ -42,6 +42,10 @@ entryPoints:
address: ":80" address: ":80"
websecure: websecure:
address: ":443" address: ":443"
{{if .HybridMode}} proxyProtocol:
trustedIPs:
- 0.0.0.0/0
- ::1/128{{end}}
transport: transport:
respondingTimeouts: respondingTimeouts:
readTimeout: "30m" readTimeout: "30m"

View File

@@ -3,8 +3,8 @@ module installer
go 1.24 go 1.24
require ( require (
golang.org/x/term v0.33.0 golang.org/x/term v0.34.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require golang.org/x/sys v0.34.0 // indirect require golang.org/x/sys v0.35.0 // indirect

View File

@@ -1,7 +1,7 @@
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -205,6 +205,7 @@
"resourceSetting": "{resourceName} Settings", "resourceSetting": "{resourceName} Settings",
"alwaysAllow": "Always Allow", "alwaysAllow": "Always Allow",
"alwaysDeny": "Always Deny", "alwaysDeny": "Always Deny",
"passToAuth": "Pass to Auth",
"orgSettingsDescription": "Configure your organization's general settings", "orgSettingsDescription": "Configure your organization's general settings",
"orgGeneralSettings": "Organization Settings", "orgGeneralSettings": "Organization Settings",
"orgGeneralSettingsDescription": "Manage your organization details and configuration", "orgGeneralSettingsDescription": "Manage your organization details and configuration",
@@ -545,6 +546,7 @@
"rulesActions": "Actions", "rulesActions": "Actions",
"rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods", "rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods",
"rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted", "rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted",
"rulesActionPassToAuth": "Pass to Auth: Allow authentication methods to be attempted",
"rulesMatchCriteria": "Matching Criteria", "rulesMatchCriteria": "Matching Criteria",
"rulesMatchCriteriaIpAddress": "Match a specific IP address", "rulesMatchCriteriaIpAddress": "Match a specific IP address",
"rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation", "rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "Update Client", "actionUpdateClient": "Update Client",
"actionListClients": "List Clients", "actionListClients": "List Clients",
"actionGetClient": "Get Client", "actionGetClient": "Get Client",
"actionCreateSiteResource": "Create Site Resource",
"actionDeleteSiteResource": "Delete Site Resource",
"actionGetSiteResource": "Get Site Resource",
"actionListSiteResources": "List Site Resources",
"actionUpdateSiteResource": "Update Site Resource",
"actionListInvitations": "List Invitations",
"noneSelected": "None selected", "noneSelected": "None selected",
"orgNotFound2": "No organizations found.", "orgNotFound2": "No organizations found.",
"searchProgress": "Search...", "searchProgress": "Search...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "Redirecting to login...", "autoLoginRedirecting": "Redirecting to login...",
"autoLoginError": "Auto Login Error", "autoLoginError": "Auto Login Error",
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.", "autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL." "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
"managedSelfHosted": {
"title": "Managed Self-Hosted",
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
"introTitle": "Managed Self-Hosted Pangolin",
"introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.",
"introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
"benefitSimplerOperations": {
"title": "Simpler operations",
"description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box."
},
"benefitAutomaticUpdates": {
"title": "Automatic updates",
"description": "The cloud dashboard evolves quickly, so you get new features and bug fixes without having to manually pull new containers every time."
},
"benefitLessMaintenance": {
"title": "Less maintenance",
"description": "No database migrations, backups, or extra infrastructure to manage. We handle that in the cloud."
},
"benefitCloudFailover": {
"title": "Cloud failover",
"description": "If your node goes down, your tunnels can temporarily fail over to our cloud points of presence until you bring it back online."
},
"benefitHighAvailability": {
"title": "High availability (PoPs)",
"description": "You can also attach multiple nodes to your account for redundancy and better performance."
},
"benefitFutureEnhancements": {
"title": "Future enhancements",
"description": "We're planning to add more analytics, alerting, and management tools to make your deployment even more robust."
},
"docsAlert": {
"text": "Learn more about the Managed Self-Hosted option in our",
"documentation": "documentation"
},
"convertButton": "Convert This Node to Managed Self-Hosted"
},
"internationaldomaindetected": "International Domain Detected",
"willbestoredas": "Will be stored as:"
} }

View File

@@ -149,44 +149,44 @@
"resourceDescription": "Vytvořte bezpečné proxy služby pro přístup k privátním aplikacím", "resourceDescription": "Vytvořte bezpečné proxy služby pro přístup k privátním aplikacím",
"resourcesSearch": "Prohledat zdroje...", "resourcesSearch": "Prohledat zdroje...",
"resourceAdd": "Přidat zdroj", "resourceAdd": "Přidat zdroj",
"resourceErrorDelte": "Error deleting resource", "resourceErrorDelte": "Chyba při odstraňování zdroje",
"authentication": "Authentication", "authentication": "Autentifikace",
"protected": "Protected", "protected": "Chráněno",
"notProtected": "Not Protected", "notProtected": "Nechráněno",
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.", "resourceMessageRemove": "Jakmile zdroj odstraníte, nebude dostupný. Všechny související služby a cíle budou také odstraněny.",
"resourceMessageConfirm": "To confirm, please type the name of the resource below.", "resourceMessageConfirm": "Pro potvrzení zadejte prosím název zdroje.",
"resourceQuestionRemove": "Are you sure you want to remove the resource {selectedResource} from the organization?", "resourceQuestionRemove": "Opravdu chcete odstranit zdroj {selectedResource} z organizace?",
"resourceHTTP": "HTTPS Resource", "resourceHTTP": "Zdroj HTTPS",
"resourceHTTPDescription": "Proxy requests to your app over HTTPS using a subdomain or base domain.", "resourceHTTPDescription": "Proxy requests to your app over HTTPS using a subdomain or base domain.",
"resourceRaw": "Raw TCP/UDP Resource", "resourceRaw": "Raw TCP/UDP Resource",
"resourceRawDescription": "Proxy requests to your app over TCP/UDP using a port number.", "resourceRawDescription": "Proxy requests to your app over TCP/UDP using a port number.",
"resourceCreate": "Create Resource", "resourceCreate": "Vytvořit zdroj",
"resourceCreateDescription": "Follow the steps below to create a new resource", "resourceCreateDescription": "Postupujte podle níže uvedených kroků, abyste vytvořili a připojili nový zdroj",
"resourceSeeAll": "See All Resources", "resourceSeeAll": "Zobrazit všechny zdroje",
"resourceInfo": "Resource Information", "resourceInfo": "Informace o zdroji",
"resourceNameDescription": "This is the display name for the resource.", "resourceNameDescription": "Toto je zobrazovaný název zdroje.",
"siteSelect": "Select site", "siteSelect": "Vybrat lokalitu",
"siteSearch": "Search site", "siteSearch": "Hledat lokalitu",
"siteNotFound": "No site found.", "siteNotFound": "Nebyla nalezena žádná lokalita.",
"siteSelectionDescription": "This site will provide connectivity to the target.", "siteSelectionDescription": "Tato lokalita poskytne připojení k cíli.",
"resourceType": "Resource Type", "resourceType": "Typ zdroje",
"resourceTypeDescription": "Determine how you want to access your resource", "resourceTypeDescription": "Určete, jak chcete přistupovat ke svému zdroji",
"resourceHTTPSSettings": "HTTPS Settings", "resourceHTTPSSettings": "Nastavení HTTPS",
"resourceHTTPSSettingsDescription": "Configure how your resource will be accessed over HTTPS", "resourceHTTPSSettingsDescription": "Nakonfigurujte, jak bude váš zdroj přístupný přes HTTPS",
"domainType": "Domain Type", "domainType": "Typ domény",
"subdomain": "Subdomain", "subdomain": "Subdoména",
"baseDomain": "Base Domain", "baseDomain": "Základní doména",
"subdomnainDescription": "The subdomain where your resource will be accessible.", "subdomnainDescription": "Subdoména, kde bude váš zdroj přístupný.",
"resourceRawSettings": "TCP/UDP Settings", "resourceRawSettings": "Nastavení TCP/UDP",
"resourceRawSettingsDescription": "Configure how your resource will be accessed over TCP/UDP", "resourceRawSettingsDescription": "Nakonfigurujte, jak bude váš dokument přístupný přes TCP/UDP",
"protocol": "Protocol", "protocol": "Protokol",
"protocolSelect": "Select a protocol", "protocolSelect": "Vybrat protokol",
"resourcePortNumber": "Port Number", "resourcePortNumber": "Číslo portu",
"resourcePortNumberDescription": "The external port number to proxy requests.", "resourcePortNumberDescription": "Externí port k požadavkům proxy serveru.",
"cancel": "Cancel", "cancel": "Zrušit",
"resourceConfig": "Configuration Snippets", "resourceConfig": "Konfigurační snippety",
"resourceConfigDescription": "Copy and paste these configuration snippets to set up your TCP/UDP resource", "resourceConfigDescription": "Zkopírujte a vložte tyto konfigurační snippety pro nastavení TCP/UDP zdroje",
"resourceAddEntrypoints": "Traefik: Add Entrypoints", "resourceAddEntrypoints": "Traefik: Přidat vstupní body",
"resourceExposePorts": "Gerbil: Expose Ports in Docker Compose", "resourceExposePorts": "Gerbil: Expose Ports in Docker Compose",
"resourceLearnRaw": "Learn how to configure TCP/UDP resources", "resourceLearnRaw": "Learn how to configure TCP/UDP resources",
"resourceBack": "Back to Resources", "resourceBack": "Back to Resources",
@@ -205,6 +205,7 @@
"resourceSetting": "{resourceName} Settings", "resourceSetting": "{resourceName} Settings",
"alwaysAllow": "Always Allow", "alwaysAllow": "Always Allow",
"alwaysDeny": "Always Deny", "alwaysDeny": "Always Deny",
"passToAuth": "Pass to Auth",
"orgSettingsDescription": "Configure your organization's general settings", "orgSettingsDescription": "Configure your organization's general settings",
"orgGeneralSettings": "Organization Settings", "orgGeneralSettings": "Organization Settings",
"orgGeneralSettingsDescription": "Manage your organization details and configuration", "orgGeneralSettingsDescription": "Manage your organization details and configuration",
@@ -545,6 +546,7 @@
"rulesActions": "Actions", "rulesActions": "Actions",
"rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods", "rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods",
"rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted", "rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted",
"rulesActionPassToAuth": "Pass to Auth: Allow authentication methods to be attempted",
"rulesMatchCriteria": "Matching Criteria", "rulesMatchCriteria": "Matching Criteria",
"rulesMatchCriteriaIpAddress": "Match a specific IP address", "rulesMatchCriteriaIpAddress": "Match a specific IP address",
"rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation", "rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "Update Client", "actionUpdateClient": "Update Client",
"actionListClients": "List Clients", "actionListClients": "List Clients",
"actionGetClient": "Get Client", "actionGetClient": "Get Client",
"actionCreateSiteResource": "Create Site Resource",
"actionDeleteSiteResource": "Delete Site Resource",
"actionGetSiteResource": "Get Site Resource",
"actionListSiteResources": "List Site Resources",
"actionUpdateSiteResource": "Update Site Resource",
"actionListInvitations": "List Invitations",
"noneSelected": "None selected", "noneSelected": "None selected",
"orgNotFound2": "No organizations found.", "orgNotFound2": "No organizations found.",
"searchProgress": "Search...", "searchProgress": "Search...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "Redirecting to login...", "autoLoginRedirecting": "Redirecting to login...",
"autoLoginError": "Auto Login Error", "autoLoginError": "Auto Login Error",
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.", "autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL." "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
"managedSelfHosted": {
"title": "Managed Self-Hosted",
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
"introTitle": "Managed Self-Hosted Pangolin",
"introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.",
"introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
"benefitSimplerOperations": {
"title": "Simpler operations",
"description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box."
},
"benefitAutomaticUpdates": {
"title": "Automatic updates",
"description": "The cloud dashboard evolves quickly, so you get new features and bug fixes without having to manually pull new containers every time."
},
"benefitLessMaintenance": {
"title": "Less maintenance",
"description": "No database migrations, backups, or extra infrastructure to manage. We handle that in the cloud."
},
"benefitCloudFailover": {
"title": "Cloud failover",
"description": "If your node goes down, your tunnels can temporarily fail over to our cloud points of presence until you bring it back online."
},
"benefitHighAvailability": {
"title": "High availability (PoPs)",
"description": "You can also attach multiple nodes to your account for redundancy and better performance."
},
"benefitFutureEnhancements": {
"title": "Future enhancements",
"description": "We're planning to add more analytics, alerting, and management tools to make your deployment even more robust."
},
"docsAlert": {
"text": "Learn more about the Managed Self-Hosted option in our",
"documentation": "documentation"
},
"convertButton": "Convert This Node to Managed Self-Hosted"
},
"internationaldomaindetected": "International Domain Detected",
"willbestoredas": "Will be stored as:"
} }

View File

@@ -205,6 +205,7 @@
"resourceSetting": "{resourceName} Einstellungen", "resourceSetting": "{resourceName} Einstellungen",
"alwaysAllow": "Immer erlauben", "alwaysAllow": "Immer erlauben",
"alwaysDeny": "Immer ablehnen", "alwaysDeny": "Immer ablehnen",
"passToAuth": "Weiterleiten zur Authentifizierung",
"orgSettingsDescription": "Konfiguriere die allgemeinen Einstellungen deiner Organisation", "orgSettingsDescription": "Konfiguriere die allgemeinen Einstellungen deiner Organisation",
"orgGeneralSettings": "Organisations-Einstellungen", "orgGeneralSettings": "Organisations-Einstellungen",
"orgGeneralSettingsDescription": "Organisationsdetails und Konfiguration verwalten", "orgGeneralSettingsDescription": "Organisationsdetails und Konfiguration verwalten",
@@ -545,6 +546,7 @@
"rulesActions": "Aktionen", "rulesActions": "Aktionen",
"rulesActionAlwaysAllow": "Immer erlauben: Alle Authentifizierungsmethoden umgehen", "rulesActionAlwaysAllow": "Immer erlauben: Alle Authentifizierungsmethoden umgehen",
"rulesActionAlwaysDeny": "Immer verweigern: Alle Anfragen blockieren; keine Authentifizierung möglich", "rulesActionAlwaysDeny": "Immer verweigern: Alle Anfragen blockieren; keine Authentifizierung möglich",
"rulesActionPassToAuth": "Weiterleiten zur Authentifizierung: Erlaubt das Versuchen von Authentifizierungsmethoden",
"rulesMatchCriteria": "Übereinstimmungskriterien", "rulesMatchCriteria": "Übereinstimmungskriterien",
"rulesMatchCriteriaIpAddress": "Mit einer bestimmten IP-Adresse übereinstimmen", "rulesMatchCriteriaIpAddress": "Mit einer bestimmten IP-Adresse übereinstimmen",
"rulesMatchCriteriaIpAddressRange": "Mit einem IP-Adressbereich in CIDR-Notation übereinstimmen", "rulesMatchCriteriaIpAddressRange": "Mit einem IP-Adressbereich in CIDR-Notation übereinstimmen",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "Kunde aktualisieren", "actionUpdateClient": "Kunde aktualisieren",
"actionListClients": "Kunden auflisten", "actionListClients": "Kunden auflisten",
"actionGetClient": "Kunde holen", "actionGetClient": "Kunde holen",
"actionCreateSiteResource": "Site-Ressource erstellen",
"actionDeleteSiteResource": "Site-Ressource löschen",
"actionGetSiteResource": "Site-Ressource abrufen",
"actionListSiteResources": "Site-Ressourcen auflisten",
"actionUpdateSiteResource": "Site-Ressource aktualisieren",
"actionListInvitations": "Einladungen auflisten",
"noneSelected": "Keine ausgewählt", "noneSelected": "Keine ausgewählt",
"orgNotFound2": "Keine Organisationen gefunden.", "orgNotFound2": "Keine Organisationen gefunden.",
"searchProgress": "Suche...", "searchProgress": "Suche...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "Weiterleitung zur Anmeldung...", "autoLoginRedirecting": "Weiterleitung zur Anmeldung...",
"autoLoginError": "Fehler bei der automatischen Anmeldung", "autoLoginError": "Fehler bei der automatischen Anmeldung",
"autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.", "autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.",
"autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL." "autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL.",
"managedSelfHosted": {
"title": "Verwaltetes Selbsthosted",
"description": "Zuverlässiger und wartungsarmer Pangolin Server mit zusätzlichen Glocken und Pfeifen",
"introTitle": "Verwalteter selbstgehosteter Pangolin",
"introDescription": "ist eine Deployment-Option, die für Personen konzipiert wurde, die Einfachheit und zusätzliche Zuverlässigkeit wünschen, während sie ihre Daten privat und selbstgehostet halten.",
"introDetail": "Mit dieser Option haben Sie immer noch Ihren eigenen Pangolin-Knoten Ihre Tunnel, SSL-Terminierung und Traffic bleiben auf Ihrem Server. Der Unterschied besteht darin, dass Verwaltung und Überwachung über unser Cloud-Dashboard abgewickelt werden, das eine Reihe von Vorteilen freischaltet:",
"benefitSimplerOperations": {
"title": "Einfachere Operationen",
"description": "Sie brauchen keinen eigenen Mail-Server auszuführen oder komplexe Warnungen einzurichten. Sie erhalten Gesundheitschecks und Ausfallwarnungen aus dem Box."
},
"benefitAutomaticUpdates": {
"title": "Automatische Updates",
"description": "Das Cloud-Dashboard entwickelt sich schnell, so dass Sie neue Funktionen und Fehlerbehebungen erhalten, ohne jedes Mal neue Container manuell ziehen zu müssen."
},
"benefitLessMaintenance": {
"title": "Weniger Wartung",
"description": "Keine Datenbankmigrationen, Sicherungen oder zusätzliche Infrastruktur zum Verwalten. Wir kümmern uns um das in der Cloud."
},
"benefitCloudFailover": {
"title": "Cloud-Ausfall",
"description": "Wenn Ihr Knoten runtergeht, können Ihre Tunnel vorübergehend an unsere Cloud-Punkte scheitern, bis Sie ihn wieder online bringen."
},
"benefitHighAvailability": {
"title": "Hohe Verfügbarkeit (PoPs)",
"description": "Sie können auch mehrere Knoten an Ihr Konto anhängen, um Redundanz und bessere Leistung zu erzielen."
},
"benefitFutureEnhancements": {
"title": "Zukünftige Verbesserungen",
"description": "Wir planen weitere Analyse-, Alarm- und Management-Tools hinzuzufügen, um Ihren Einsatz noch robuster zu machen."
},
"docsAlert": {
"text": "Erfahren Sie mehr über die Managed Self-Hosted Option in unserer",
"documentation": "dokumentation"
},
"convertButton": "Diesen Knoten in Managed Self-Hosted umwandeln"
},
"internationaldomaindetected": "Internationale Domain erkannt",
"willbestoredas": "Wird gespeichert als:"
} }

View File

@@ -205,6 +205,7 @@
"resourceSetting": "{resourceName} Settings", "resourceSetting": "{resourceName} Settings",
"alwaysAllow": "Always Allow", "alwaysAllow": "Always Allow",
"alwaysDeny": "Always Deny", "alwaysDeny": "Always Deny",
"passToAuth": "Pass to Auth",
"orgSettingsDescription": "Configure your organization's general settings", "orgSettingsDescription": "Configure your organization's general settings",
"orgGeneralSettings": "Organization Settings", "orgGeneralSettings": "Organization Settings",
"orgGeneralSettingsDescription": "Manage your organization details and configuration", "orgGeneralSettingsDescription": "Manage your organization details and configuration",
@@ -545,6 +546,7 @@
"rulesActions": "Actions", "rulesActions": "Actions",
"rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods", "rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods",
"rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted", "rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted",
"rulesActionPassToAuth": "Pass to Auth: Allow authentication methods to be attempted",
"rulesMatchCriteria": "Matching Criteria", "rulesMatchCriteria": "Matching Criteria",
"rulesMatchCriteriaIpAddress": "Match a specific IP address", "rulesMatchCriteriaIpAddress": "Match a specific IP address",
"rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation", "rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "Update Client", "actionUpdateClient": "Update Client",
"actionListClients": "List Clients", "actionListClients": "List Clients",
"actionGetClient": "Get Client", "actionGetClient": "Get Client",
"actionCreateSiteResource": "Create Site Resource",
"actionDeleteSiteResource": "Delete Site Resource",
"actionGetSiteResource": "Get Site Resource",
"actionListSiteResources": "List Site Resources",
"actionUpdateSiteResource": "Update Site Resource",
"actionListInvitations": "List Invitations",
"noneSelected": "None selected", "noneSelected": "None selected",
"orgNotFound2": "No organizations found.", "orgNotFound2": "No organizations found.",
"searchProgress": "Search...", "searchProgress": "Search...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "Redirecting to login...", "autoLoginRedirecting": "Redirecting to login...",
"autoLoginError": "Auto Login Error", "autoLoginError": "Auto Login Error",
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.", "autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL." "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
"managedSelfHosted": {
"title": "Managed Self-Hosted",
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
"introTitle": "Managed Self-Hosted Pangolin",
"introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.",
"introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
"benefitSimplerOperations": {
"title": "Simpler operations",
"description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box."
},
"benefitAutomaticUpdates": {
"title": "Automatic updates",
"description": "The cloud dashboard evolves quickly, so you get new features and bug fixes without having to manually pull new containers every time."
},
"benefitLessMaintenance": {
"title": "Less maintenance",
"description": "No database migrations, backups, or extra infrastructure to manage. We handle that in the cloud."
},
"benefitCloudFailover": {
"title": "Cloud failover",
"description": "If your node goes down, your tunnels can temporarily fail over to our cloud points of presence until you bring it back online."
},
"benefitHighAvailability": {
"title": "High availability (PoPs)",
"description": "You can also attach multiple nodes to your account for redundancy and better performance."
},
"benefitFutureEnhancements": {
"title": "Future enhancements",
"description": "We're planning to add more analytics, alerting, and management tools to make your deployment even more robust."
},
"docsAlert": {
"text": "Learn more about the Managed Self-Hosted option in our",
"documentation": "documentation"
},
"convertButton": "Convert This Node to Managed Self-Hosted"
},
"internationaldomaindetected": "International Domain Detected",
"willbestoredas": "Will be stored as:"
} }

View File

@@ -205,6 +205,7 @@
"resourceSetting": "Ajustes {resourceName}", "resourceSetting": "Ajustes {resourceName}",
"alwaysAllow": "Permitir siempre", "alwaysAllow": "Permitir siempre",
"alwaysDeny": "Denegar siempre", "alwaysDeny": "Denegar siempre",
"passToAuth": "Pasar a Autenticación",
"orgSettingsDescription": "Configurar la configuración general de su organización", "orgSettingsDescription": "Configurar la configuración general de su organización",
"orgGeneralSettings": "Configuración de la organización", "orgGeneralSettings": "Configuración de la organización",
"orgGeneralSettingsDescription": "Administra los detalles y la configuración de tu organización", "orgGeneralSettingsDescription": "Administra los detalles y la configuración de tu organización",
@@ -545,6 +546,7 @@
"rulesActions": "Acciones", "rulesActions": "Acciones",
"rulesActionAlwaysAllow": "Permitir siempre: pasar todos los métodos de autenticación", "rulesActionAlwaysAllow": "Permitir siempre: pasar todos los métodos de autenticación",
"rulesActionAlwaysDeny": "Denegar siempre: Bloquear todas las peticiones; no se puede intentar autenticación", "rulesActionAlwaysDeny": "Denegar siempre: Bloquear todas las peticiones; no se puede intentar autenticación",
"rulesActionPassToAuth": "Pasar a Autenticación: Permitir que se intenten los métodos de autenticación",
"rulesMatchCriteria": "Criterios coincidentes", "rulesMatchCriteria": "Criterios coincidentes",
"rulesMatchCriteriaIpAddress": "Coincidir con una dirección IP específica", "rulesMatchCriteriaIpAddress": "Coincidir con una dirección IP específica",
"rulesMatchCriteriaIpAddressRange": "Coincide con un rango de direcciones IP en notación CIDR", "rulesMatchCriteriaIpAddressRange": "Coincide con un rango de direcciones IP en notación CIDR",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "Actualizar cliente", "actionUpdateClient": "Actualizar cliente",
"actionListClients": "Listar clientes", "actionListClients": "Listar clientes",
"actionGetClient": "Obtener cliente", "actionGetClient": "Obtener cliente",
"actionCreateSiteResource": "Crear Recurso del Sitio",
"actionDeleteSiteResource": "Eliminar recurso del sitio",
"actionGetSiteResource": "Obtener recurso del sitio",
"actionListSiteResources": "Listar recursos del sitio",
"actionUpdateSiteResource": "Actualizar recurso del sitio",
"actionListInvitations": "Listar invitaciones",
"noneSelected": "Ninguno seleccionado", "noneSelected": "Ninguno seleccionado",
"orgNotFound2": "No se encontraron organizaciones.", "orgNotFound2": "No se encontraron organizaciones.",
"searchProgress": "Buscar...", "searchProgress": "Buscar...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "Redirigiendo al inicio de sesión...", "autoLoginRedirecting": "Redirigiendo al inicio de sesión...",
"autoLoginError": "Error de inicio de sesión automático", "autoLoginError": "Error de inicio de sesión automático",
"autoLoginErrorNoRedirectUrl": "No se recibió URL de redirección del proveedor de identidad.", "autoLoginErrorNoRedirectUrl": "No se recibió URL de redirección del proveedor de identidad.",
"autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación." "autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación.",
"managedSelfHosted": {
"title": "Autogestionado",
"description": "Servidor Pangolin autoalojado más fiable y de bajo mantenimiento con campanas y silbidos extra",
"introTitle": "Pangolin autogestionado",
"introDescription": "es una opción de despliegue diseñada para personas que quieren simplicidad y fiabilidad extra mientras mantienen sus datos privados y autoalojados.",
"introDetail": "Con esta opción, todavía ejecuta su propio nodo Pangolin, sus túneles, terminación SSL y tráfico permanecen en su servidor. La diferencia es que la gestión y el control se gestionan a través de nuestro panel de control en la nube, que desbloquea una serie de ventajas:",
"benefitSimplerOperations": {
"title": "Operaciones simples",
"description": "No necesitas ejecutar tu propio servidor de correo o configurar alertas complejas. Recibirás cheques de salud y alertas de tiempo de inactividad."
},
"benefitAutomaticUpdates": {
"title": "Actualizaciones automáticas",
"description": "El tablero de la nube evolucionará rápidamente, por lo que obtendrá nuevas características y correcciones de errores sin tener que extraer manualmente nuevos contenedores cada vez."
},
"benefitLessMaintenance": {
"title": "Menos mantenimiento",
"description": "No hay migraciones de base de datos, copias de seguridad o infraestructura extra para administrar. Lo manejamos en la nube."
},
"benefitCloudFailover": {
"title": "Fallo en la nube",
"description": "Si tu nodo cae, tus túneles pueden fallar temporalmente a nuestros puntos de presencia en la nube hasta que lo vuelvas a conectar."
},
"benefitHighAvailability": {
"title": "Alta disponibilidad (PoPs)",
"description": "También puede adjuntar múltiples nodos a su cuenta para redundancia y mejor rendimiento."
},
"benefitFutureEnhancements": {
"title": "Mejoras futuras",
"description": "Estamos planeando añadir más herramientas analíticas, alertas y de administración para hacer su despliegue aún más robusto."
},
"docsAlert": {
"text": "Aprenda más acerca de la opción de autoalojamiento administrado en nuestra",
"documentation": "documentación"
},
"convertButton": "Convierte este nodo a autoalojado administrado"
},
"internationaldomaindetected": "Dominio Internacional detectado",
"willbestoredas": "Se almacenará como:"
} }

View File

@@ -205,6 +205,7 @@
"resourceSetting": "Réglages {resourceName}", "resourceSetting": "Réglages {resourceName}",
"alwaysAllow": "Toujours autoriser", "alwaysAllow": "Toujours autoriser",
"alwaysDeny": "Toujours refuser", "alwaysDeny": "Toujours refuser",
"passToAuth": "Paser à l'authentification",
"orgSettingsDescription": "Configurer les paramètres généraux de votre organisation", "orgSettingsDescription": "Configurer les paramètres généraux de votre organisation",
"orgGeneralSettings": "Paramètres de l'organisation", "orgGeneralSettings": "Paramètres de l'organisation",
"orgGeneralSettingsDescription": "Gérer les détails et la configuration de votre organisation", "orgGeneralSettingsDescription": "Gérer les détails et la configuration de votre organisation",
@@ -545,6 +546,7 @@
"rulesActions": "Actions", "rulesActions": "Actions",
"rulesActionAlwaysAllow": "Toujours autoriser : Contourner toutes les méthodes d'authentification", "rulesActionAlwaysAllow": "Toujours autoriser : Contourner toutes les méthodes d'authentification",
"rulesActionAlwaysDeny": "Toujours refuser : Bloquer toutes les requêtes ; aucune authentification ne peut être tentée", "rulesActionAlwaysDeny": "Toujours refuser : Bloquer toutes les requêtes ; aucune authentification ne peut être tentée",
"rulesActionPassToAuth": "Passer à l'authentification : Autoriser les méthodes d'authentification à être tentées",
"rulesMatchCriteria": "Critères de correspondance", "rulesMatchCriteria": "Critères de correspondance",
"rulesMatchCriteriaIpAddress": "Correspondre à une adresse IP spécifique", "rulesMatchCriteriaIpAddress": "Correspondre à une adresse IP spécifique",
"rulesMatchCriteriaIpAddressRange": "Correspondre à une plage d'adresses IP en notation CIDR", "rulesMatchCriteriaIpAddressRange": "Correspondre à une plage d'adresses IP en notation CIDR",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "Mettre à jour le client", "actionUpdateClient": "Mettre à jour le client",
"actionListClients": "Liste des clients", "actionListClients": "Liste des clients",
"actionGetClient": "Obtenir le client", "actionGetClient": "Obtenir le client",
"actionCreateSiteResource": "Créer une ressource de site",
"actionDeleteSiteResource": "Supprimer une ressource de site",
"actionGetSiteResource": "Obtenir une ressource de site",
"actionListSiteResources": "Lister les ressources de site",
"actionUpdateSiteResource": "Mettre à jour une ressource de site",
"actionListInvitations": "Lister les invitations",
"noneSelected": "Aucune sélection", "noneSelected": "Aucune sélection",
"orgNotFound2": "Aucune organisation trouvée.", "orgNotFound2": "Aucune organisation trouvée.",
"searchProgress": "Rechercher...", "searchProgress": "Rechercher...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "Redirection vers la connexion...", "autoLoginRedirecting": "Redirection vers la connexion...",
"autoLoginError": "Erreur de connexion automatique", "autoLoginError": "Erreur de connexion automatique",
"autoLoginErrorNoRedirectUrl": "Aucune URL de redirection reçue du fournisseur d'identité.", "autoLoginErrorNoRedirectUrl": "Aucune URL de redirection reçue du fournisseur d'identité.",
"autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification." "autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification.",
"managedSelfHosted": {
"title": "Gestion autonome",
"description": "Serveur Pangolin auto-hébergé avec des cloches et des sifflets supplémentaires",
"introTitle": "Pangolin auto-hébergé géré",
"introDescription": "est une option de déploiement conçue pour les personnes qui veulent de la simplicité et de la fiabilité tout en gardant leurs données privées et auto-hébergées.",
"introDetail": "Avec cette option, vous exécutez toujours votre propre nœud Pangolin — vos tunnels, la terminaison SSL et le trafic restent sur votre serveur. La différence est que la gestion et la surveillance sont gérées via notre tableau de bord du cloud, qui déverrouille un certain nombre d'avantages :",
"benefitSimplerOperations": {
"title": "Opérations plus simples",
"description": "Pas besoin de faire tourner votre propre serveur de messagerie ou de configurer des alertes complexes. Vous obtiendrez des contrôles de santé et des alertes de temps d'arrêt par la suite."
},
"benefitAutomaticUpdates": {
"title": "Mises à jour automatiques",
"description": "Le tableau de bord du cloud évolue rapidement, de sorte que vous obtenez de nouvelles fonctionnalités et des corrections de bugs sans avoir à extraire manuellement de nouveaux conteneurs à chaque fois."
},
"benefitLessMaintenance": {
"title": "Moins de maintenance",
"description": "Aucune migration de base de données, sauvegarde ou infrastructure supplémentaire à gérer. Nous gérons cela dans le cloud."
},
"benefitCloudFailover": {
"title": "Basculement du Cloud",
"description": "Si votre nœud descend, vos tunnels peuvent temporairement échouer jusqu'à ce que vous le rapatriez en ligne."
},
"benefitHighAvailability": {
"title": "Haute disponibilité (PoPs)",
"description": "Vous pouvez également attacher plusieurs nœuds à votre compte pour une redondance et de meilleures performances."
},
"benefitFutureEnhancements": {
"title": "Améliorations futures",
"description": "Nous prévoyons d'ajouter plus d'outils d'analyse, d'alerte et de gestion pour rendre votre déploiement encore plus robuste."
},
"docsAlert": {
"text": "En savoir plus sur l'option Auto-Hébergement géré dans notre",
"documentation": "documentation"
},
"convertButton": "Convertir ce noeud en auto-hébergé géré"
},
"internationaldomaindetected": "Domaine international détecté",
"willbestoredas": "Sera stocké comme :"
} }

View File

@@ -205,6 +205,7 @@
"resourceSetting": "Impostazioni {resourceName}", "resourceSetting": "Impostazioni {resourceName}",
"alwaysAllow": "Consenti Sempre", "alwaysAllow": "Consenti Sempre",
"alwaysDeny": "Nega Sempre", "alwaysDeny": "Nega Sempre",
"passToAuth": "Passa all'autenticazione",
"orgSettingsDescription": "Configura le impostazioni generali della tua organizzazione", "orgSettingsDescription": "Configura le impostazioni generali della tua organizzazione",
"orgGeneralSettings": "Impostazioni Organizzazione", "orgGeneralSettings": "Impostazioni Organizzazione",
"orgGeneralSettingsDescription": "Gestisci i dettagli dell'organizzazione e la configurazione", "orgGeneralSettingsDescription": "Gestisci i dettagli dell'organizzazione e la configurazione",
@@ -545,6 +546,7 @@
"rulesActions": "Azioni", "rulesActions": "Azioni",
"rulesActionAlwaysAllow": "Consenti Sempre: Ignora tutti i metodi di autenticazione", "rulesActionAlwaysAllow": "Consenti Sempre: Ignora tutti i metodi di autenticazione",
"rulesActionAlwaysDeny": "Nega Sempre: Blocca tutte le richieste; nessuna autenticazione può essere tentata", "rulesActionAlwaysDeny": "Nega Sempre: Blocca tutte le richieste; nessuna autenticazione può essere tentata",
"rulesActionPassToAuth": "Passa all'autenticazione: Consenti di tentare i metodi di autenticazione",
"rulesMatchCriteria": "Criteri di Corrispondenza", "rulesMatchCriteria": "Criteri di Corrispondenza",
"rulesMatchCriteriaIpAddress": "Corrisponde a un indirizzo IP specifico", "rulesMatchCriteriaIpAddress": "Corrisponde a un indirizzo IP specifico",
"rulesMatchCriteriaIpAddressRange": "Corrisponde a un intervallo di indirizzi IP in notazione CIDR", "rulesMatchCriteriaIpAddressRange": "Corrisponde a un intervallo di indirizzi IP in notazione CIDR",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "Aggiorna Client", "actionUpdateClient": "Aggiorna Client",
"actionListClients": "Elenco Clienti", "actionListClients": "Elenco Clienti",
"actionGetClient": "Ottieni Client", "actionGetClient": "Ottieni Client",
"actionCreateSiteResource": "Crea Risorsa del Sito",
"actionDeleteSiteResource": "Elimina Risorsa del Sito",
"actionGetSiteResource": "Ottieni Risorsa del Sito",
"actionListSiteResources": "Elenca Risorse del Sito",
"actionUpdateSiteResource": "Aggiorna Risorsa del Sito",
"actionListInvitations": "Elenco Inviti",
"noneSelected": "Nessuna selezione", "noneSelected": "Nessuna selezione",
"orgNotFound2": "Nessuna organizzazione trovata.", "orgNotFound2": "Nessuna organizzazione trovata.",
"searchProgress": "Ricerca...", "searchProgress": "Ricerca...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "Reindirizzamento al login...", "autoLoginRedirecting": "Reindirizzamento al login...",
"autoLoginError": "Errore di Accesso Automatico", "autoLoginError": "Errore di Accesso Automatico",
"autoLoginErrorNoRedirectUrl": "Nessun URL di reindirizzamento ricevuto dal provider di identità.", "autoLoginErrorNoRedirectUrl": "Nessun URL di reindirizzamento ricevuto dal provider di identità.",
"autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione." "autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione.",
"managedSelfHosted": {
"title": "Gestito Auto-Ospitato",
"description": "Server Pangolin self-hosted più affidabile e a bassa manutenzione con campanelli e fischietti extra",
"introTitle": "Managed Self-Hosted Pangolin",
"introDescription": "è un'opzione di distribuzione progettata per le persone che vogliono la semplicità e l'affidabilità extra mantenendo i loro dati privati e self-hosted.",
"introDetail": "Con questa opzione, esegui ancora il tuo nodo Pangolin — i tunnel, la terminazione SSL e il traffico rimangono tutti sul tuo server. La differenza è che la gestione e il monitoraggio sono gestiti attraverso il nostro cruscotto cloud, che sblocca una serie di vantaggi:",
"benefitSimplerOperations": {
"title": "Operazioni più semplici",
"description": "Non è necessario eseguire il proprio server di posta o impostare un avviso complesso. Otterrai controlli di salute e avvisi di inattività fuori dalla casella."
},
"benefitAutomaticUpdates": {
"title": "Aggiornamenti automatici",
"description": "Il cruscotto cloud si evolve rapidamente, in modo da ottenere nuove funzionalità e correzioni di bug senza dover tirare manualmente nuovi contenitori ogni volta."
},
"benefitLessMaintenance": {
"title": "Meno manutenzione",
"description": "Nessuna migrazione di database, backup o infrastruttura extra da gestire. Gestiamo questo problema nel cloud."
},
"benefitCloudFailover": {
"title": "failover del cloud",
"description": "Se il tuo nodo scende, i tuoi tunnel possono temporaneamente fallire nei nostri punti di presenza cloud fino a quando non lo riporti online."
},
"benefitHighAvailability": {
"title": "Alta disponibilità (PoPs)",
"description": "Puoi anche allegare più nodi al tuo account per ridondanza e prestazioni migliori."
},
"benefitFutureEnhancements": {
"title": "Miglioramenti futuri",
"description": "Stiamo pianificando di aggiungere più strumenti di analisi, allerta e gestione per rendere la tua distribuzione ancora più robusta."
},
"docsAlert": {
"text": "Scopri di più sull'opzione Managed Self-Hosted nella nostra",
"documentation": "documentazione"
},
"convertButton": "Converti questo nodo in auto-ospitato gestito"
},
"internationaldomaindetected": "Dominio Internazionale Rilevato",
"willbestoredas": "Verrà conservato come:"
} }

View File

@@ -205,6 +205,7 @@
"resourceSetting": "{resourceName} 설정", "resourceSetting": "{resourceName} 설정",
"alwaysAllow": "항상 허용", "alwaysAllow": "항상 허용",
"alwaysDeny": "항상 거부", "alwaysDeny": "항상 거부",
"passToAuth": "인증으로 전달",
"orgSettingsDescription": "조직의 일반 설정을 구성하세요", "orgSettingsDescription": "조직의 일반 설정을 구성하세요",
"orgGeneralSettings": "조직 설정", "orgGeneralSettings": "조직 설정",
"orgGeneralSettingsDescription": "조직 세부정보 및 구성을 관리하세요.", "orgGeneralSettingsDescription": "조직 세부정보 및 구성을 관리하세요.",
@@ -545,6 +546,7 @@
"rulesActions": "작업", "rulesActions": "작업",
"rulesActionAlwaysAllow": "항상 허용: 모든 인증 방법 우회", "rulesActionAlwaysAllow": "항상 허용: 모든 인증 방법 우회",
"rulesActionAlwaysDeny": "항상 거부: 모든 요청을 차단합니다. 인증을 시도할 수 없습니다.", "rulesActionAlwaysDeny": "항상 거부: 모든 요청을 차단합니다. 인증을 시도할 수 없습니다.",
"rulesActionPassToAuth": "인증으로 전달: 인증 방법 시도를 허용합니다",
"rulesMatchCriteria": "일치 기준", "rulesMatchCriteria": "일치 기준",
"rulesMatchCriteriaIpAddress": "특정 IP 주소와 일치", "rulesMatchCriteriaIpAddress": "특정 IP 주소와 일치",
"rulesMatchCriteriaIpAddressRange": "CIDR 표기법으로 IP 주소 범위를 일치시킵니다", "rulesMatchCriteriaIpAddressRange": "CIDR 표기법으로 IP 주소 범위를 일치시킵니다",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "클라이언트 업데이트", "actionUpdateClient": "클라이언트 업데이트",
"actionListClients": "클라이언트 목록", "actionListClients": "클라이언트 목록",
"actionGetClient": "클라이언트 가져오기", "actionGetClient": "클라이언트 가져오기",
"actionCreateSiteResource": "사이트 리소스 생성",
"actionDeleteSiteResource": "사이트 리소스 삭제",
"actionGetSiteResource": "사이트 리소스 가져오기",
"actionListSiteResources": "사이트 리소스 목록",
"actionUpdateSiteResource": "사이트 리소스 업데이트",
"actionListInvitations": "초대 목록",
"noneSelected": "선택된 항목 없음", "noneSelected": "선택된 항목 없음",
"orgNotFound2": "조직이 없습니다.", "orgNotFound2": "조직이 없습니다.",
"searchProgress": "검색...", "searchProgress": "검색...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "로그인으로 리디렉션 중...", "autoLoginRedirecting": "로그인으로 리디렉션 중...",
"autoLoginError": "자동 로그인 오류", "autoLoginError": "자동 로그인 오류",
"autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.", "autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.",
"autoLoginErrorGeneratingUrl": "인증 URL 생성 실패." "autoLoginErrorGeneratingUrl": "인증 URL 생성 실패.",
"managedSelfHosted": {
"title": "관리 자체 호스팅",
"description": "더 신뢰할 수 있고 낮은 유지보수의 자체 호스팅 팡골린 서버, 추가 기능 포함",
"introTitle": "관리 자체 호스팅 팡골린",
"introDescription": "는 자신의 데이터를 프라이빗하고 자체 호스팅을 유지하면서 더 간단하고 추가적인 신뢰성을 원하는 사람들을 위한 배포 옵션입니다.",
"introDetail": "이 옵션을 사용하면 여전히 자신의 팡골린 노드를 운영하고 - 터널, SSL 종료 및 트래픽 모두 서버에 유지됩니다. 차이점은 관리 및 모니터링이 클라우드 대시보드를 통해 처리되어 여러 혜택을 제공합니다.",
"benefitSimplerOperations": {
"title": "더 간단한 운영",
"description": "자체 메일 서버를 운영하거나 복잡한 경고를 설정할 필요가 없습니다. 기본적으로 상태 점검 및 다운타임 경고를 받을 수 있습니다."
},
"benefitAutomaticUpdates": {
"title": "자동 업데이트",
"description": "클라우드 대시보드는 빠르게 발전하므로 새로운 기능과 버그 수정 사항을 수동으로 새로운 컨테이너를 가져오지 않고도 받을 수 있습니다."
},
"benefitLessMaintenance": {
"title": "유지보수 감소",
"description": "데이터베이스 마이그레이션, 백업 또는 추가 인프라를 관리할 필요가 없습니다. 저희가 클라우드에서 처리합니다."
},
"benefitCloudFailover": {
"title": "클라우드 장애 조치",
"description": "노드가 다운되면 터널이 클라우드의 프레즌스 포인트로 임시 전환되어 노드를 다시 온라인으로 가져올 때까지 유지됩니다."
},
"benefitHighAvailability": {
"title": "고가용성 (PoPs)",
"description": "계정에 여러 노드를 연결하여 이중성과 성능을 향상시킬 수 있습니다."
},
"benefitFutureEnhancements": {
"title": "향후 개선",
"description": "배포를 더욱 견고하게 만들기 위해 더 많은 분석, 경고, 및 관리 도구를 추가할 계획입니다."
},
"docsAlert": {
"text": "관리 자체 호스팅 옵션에 대해 더 알아보세요",
"documentation": "문서"
},
"convertButton": "이 노드를 관리 자체 호스팅으로 변환"
},
"internationaldomaindetected": "국제 도메인 감지됨",
"willbestoredas": "다음으로 저장됩니다:"
} }

View File

@@ -205,6 +205,7 @@
"resourceSetting": "{resourceName} Innstillinger", "resourceSetting": "{resourceName} Innstillinger",
"alwaysAllow": "Alltid tillat", "alwaysAllow": "Alltid tillat",
"alwaysDeny": "Alltid avslå", "alwaysDeny": "Alltid avslå",
"passToAuth": "Pass til Autentisering",
"orgSettingsDescription": "Konfigurer organisasjonens generelle innstillinger", "orgSettingsDescription": "Konfigurer organisasjonens generelle innstillinger",
"orgGeneralSettings": "Organisasjonsinnstillinger", "orgGeneralSettings": "Organisasjonsinnstillinger",
"orgGeneralSettingsDescription": "Administrer dine organisasjonsdetaljer og konfigurasjon", "orgGeneralSettingsDescription": "Administrer dine organisasjonsdetaljer og konfigurasjon",
@@ -545,6 +546,7 @@
"rulesActions": "Handlinger", "rulesActions": "Handlinger",
"rulesActionAlwaysAllow": "Alltid Tillat: Omgå alle autentiserings metoder", "rulesActionAlwaysAllow": "Alltid Tillat: Omgå alle autentiserings metoder",
"rulesActionAlwaysDeny": "Alltid Nekt: Blokker alle forespørsler; ingen autentisering kan forsøkes", "rulesActionAlwaysDeny": "Alltid Nekt: Blokker alle forespørsler; ingen autentisering kan forsøkes",
"rulesActionPassToAuth": "Pass til Autentisering: Tillat at autentiseringsmetoder forsøkes",
"rulesMatchCriteria": "Samsvarende kriterier", "rulesMatchCriteria": "Samsvarende kriterier",
"rulesMatchCriteriaIpAddress": "Samsvar med en spesifikk IP-adresse", "rulesMatchCriteriaIpAddress": "Samsvar med en spesifikk IP-adresse",
"rulesMatchCriteriaIpAddressRange": "Samsvar et IP-adresseområde i CIDR-notasjon", "rulesMatchCriteriaIpAddressRange": "Samsvar et IP-adresseområde i CIDR-notasjon",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "Oppdater klient", "actionUpdateClient": "Oppdater klient",
"actionListClients": "List klienter", "actionListClients": "List klienter",
"actionGetClient": "Hent klient", "actionGetClient": "Hent klient",
"actionCreateSiteResource": "Opprett stedsressurs",
"actionDeleteSiteResource": "Slett Stedsressurs",
"actionGetSiteResource": "Hent Stedsressurs",
"actionListSiteResources": "List opp Stedsressurser",
"actionUpdateSiteResource": "Oppdater Stedsressurs",
"actionListInvitations": "Liste invitasjoner",
"noneSelected": "Ingen valgt", "noneSelected": "Ingen valgt",
"orgNotFound2": "Ingen organisasjoner funnet.", "orgNotFound2": "Ingen organisasjoner funnet.",
"searchProgress": "Søker...", "searchProgress": "Søker...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "Omdirigerer til innlogging...", "autoLoginRedirecting": "Omdirigerer til innlogging...",
"autoLoginError": "Feil ved automatisk innlogging", "autoLoginError": "Feil ved automatisk innlogging",
"autoLoginErrorNoRedirectUrl": "Ingen omdirigerings-URL mottatt fra identitetsleverandøren.", "autoLoginErrorNoRedirectUrl": "Ingen omdirigerings-URL mottatt fra identitetsleverandøren.",
"autoLoginErrorGeneratingUrl": "Kunne ikke generere autentiserings-URL." "autoLoginErrorGeneratingUrl": "Kunne ikke generere autentiserings-URL.",
"managedSelfHosted": {
"title": "Administrert selv-hostet",
"description": "Sikre og lavvedlikeholdsservere, selvbetjente Pangolin med ekstra klokker, og understell",
"introTitle": "Administrert Self-Hosted Pangolin",
"introDescription": "er et alternativ for bruk utviklet for personer som ønsker enkel og ekstra pålitelighet mens de fortsatt holder sine data privat og selvdrevne.",
"introDetail": "Med dette valget kjører du fortsatt din egen Pangolin-node - tunneler, SSL-terminering og trafikken ligger på serveren din. Forskjellen er at behandling og overvåking håndteres gjennom vårt skydashbord, som låser opp en rekke fordeler:",
"benefitSimplerOperations": {
"title": "Enklere operasjoner",
"description": "Ingen grunn til å kjøre din egen e-postserver eller sette opp kompleks varsling. Du vil få helsesjekk og nedetid varsler ut av boksen."
},
"benefitAutomaticUpdates": {
"title": "Automatiske oppdateringer",
"description": "Cloud dashbordet utvikler seg raskt, så du får nye funksjoner og feilrettinger uten at du trenger å trekke nye beholdere manuelt hver gang."
},
"benefitLessMaintenance": {
"title": "Mindre vedlikehold",
"description": "Ingen databasestyrer, sikkerhetskopier eller ekstra infrastruktur for å forvalte. Vi håndterer det i skyen."
},
"benefitCloudFailover": {
"title": "Sky feilslått",
"description": "Hvis EK-gruppen din går ned, kan tunnlene midlertidig mislykkes i å nå våre sky-punkter til du tar den tilbake på nett."
},
"benefitHighAvailability": {
"title": "Høy tilgjengelighet (PoPs)",
"description": "Du kan også legge ved flere noder til kontoen din for redundans og bedre ytelse."
},
"benefitFutureEnhancements": {
"title": "Fremtidige forbedringer",
"description": "Vi planlegger å legge inn mer analyser, varsle og styringsverktøy for å gjøre din distribusjon enda mer robust."
},
"docsAlert": {
"text": "Lær mer om Managed Self-Hosted alternativet i vår",
"documentation": "dokumentasjon"
},
"convertButton": "Konverter denne noden til manuelt bruk"
},
"internationaldomaindetected": "Internasjonalt domene oppdaget",
"willbestoredas": "Vil bli lagret som:"
} }

View File

@@ -205,6 +205,7 @@
"resourceSetting": "{resourceName} instellingen", "resourceSetting": "{resourceName} instellingen",
"alwaysAllow": "Altijd toestaan", "alwaysAllow": "Altijd toestaan",
"alwaysDeny": "Altijd weigeren", "alwaysDeny": "Altijd weigeren",
"passToAuth": "Passeren naar Auth",
"orgSettingsDescription": "Configureer de algemene instellingen van je organisatie", "orgSettingsDescription": "Configureer de algemene instellingen van je organisatie",
"orgGeneralSettings": "Organisatie Instellingen", "orgGeneralSettings": "Organisatie Instellingen",
"orgGeneralSettingsDescription": "Beheer de details en configuratie van uw organisatie", "orgGeneralSettingsDescription": "Beheer de details en configuratie van uw organisatie",
@@ -545,6 +546,7 @@
"rulesActions": "acties", "rulesActions": "acties",
"rulesActionAlwaysAllow": "Altijd toegestaan: Omzeil alle authenticatiemethoden", "rulesActionAlwaysAllow": "Altijd toegestaan: Omzeil alle authenticatiemethoden",
"rulesActionAlwaysDeny": "Altijd weigeren: Blokkeer alle aanvragen, er kan geen verificatie worden geprobeerd", "rulesActionAlwaysDeny": "Altijd weigeren: Blokkeer alle aanvragen, er kan geen verificatie worden geprobeerd",
"rulesActionPassToAuth": "Doorgeven aan Auth: Toestaan dat authenticatiemethoden worden geprobeerd",
"rulesMatchCriteria": "Overeenkomende criteria", "rulesMatchCriteria": "Overeenkomende criteria",
"rulesMatchCriteriaIpAddress": "Overeenkomen met een specifiek IP-adres", "rulesMatchCriteriaIpAddress": "Overeenkomen met een specifiek IP-adres",
"rulesMatchCriteriaIpAddressRange": "Overeenkomen met een bereik van IP-adressen in de CIDR-notatie", "rulesMatchCriteriaIpAddressRange": "Overeenkomen met een bereik van IP-adressen in de CIDR-notatie",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "Klant bijwerken", "actionUpdateClient": "Klant bijwerken",
"actionListClients": "Lijst klanten", "actionListClients": "Lijst klanten",
"actionGetClient": "Client ophalen", "actionGetClient": "Client ophalen",
"actionCreateSiteResource": "Sitebron maken",
"actionDeleteSiteResource": "Document verwijderen van site",
"actionGetSiteResource": "Bron van site ophalen",
"actionListSiteResources": "Bronnen van site weergeven",
"actionUpdateSiteResource": "Document bijwerken van site",
"actionListInvitations": "Toon uitnodigingen",
"noneSelected": "Niet geselecteerd", "noneSelected": "Niet geselecteerd",
"orgNotFound2": "Geen organisaties gevonden.", "orgNotFound2": "Geen organisaties gevonden.",
"searchProgress": "Zoeken...", "searchProgress": "Zoeken...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "Redirecting naar inloggen...", "autoLoginRedirecting": "Redirecting naar inloggen...",
"autoLoginError": "Auto Login Fout", "autoLoginError": "Auto Login Fout",
"autoLoginErrorNoRedirectUrl": "Geen redirect URL ontvangen van de identity provider.", "autoLoginErrorNoRedirectUrl": "Geen redirect URL ontvangen van de identity provider.",
"autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt." "autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt.",
"managedSelfHosted": {
"title": "Beheerde Self-Hosted",
"description": "betrouwbaardere en slecht onderhouden Pangolin server met extra klokken en klokkenluiders",
"introTitle": "Beheerde zelfgehoste pangolin",
"introDescription": "is een implementatieoptie ontworpen voor mensen die eenvoud en extra betrouwbaarheid willen, terwijl hun gegevens privé en zelf georganiseerd blijven.",
"introDetail": "Met deze optie beheert u nog steeds uw eigen Pangolin node - uw tunnels, SSL-verbinding en verkeer alles op uw server. Het verschil is dat beheer en monitoring worden behandeld via onze cloud dashboard, wat een aantal voordelen oplevert:",
"benefitSimplerOperations": {
"title": "Simpler operaties",
"description": "Je hoeft geen eigen mailserver te draaien of complexe waarschuwingen in te stellen. Je krijgt gezondheidscontroles en downtime meldingen uit de box."
},
"benefitAutomaticUpdates": {
"title": "Automatische updates",
"description": "Het cloud dashboard evolueert snel, zodat u nieuwe functies en bug fixes krijgt zonder elke keer handmatig nieuwe containers te moeten trekken."
},
"benefitLessMaintenance": {
"title": "Minder onderhoud",
"description": "Geen database migratie, back-ups of extra infrastructuur om te beheren. Dat behandelen we in de cloud."
},
"benefitCloudFailover": {
"title": "Cloud fout",
"description": "Als uw node omlaag gaat, kunnen uw tunnels tijdelijk niet meer naar onze aanwezigheidspunten gaan totdat u hem weer online brengt."
},
"benefitHighAvailability": {
"title": "Hoge beschikbaarheid (PoPs)",
"description": "U kunt ook meerdere nodes koppelen aan uw account voor ontslag en betere prestaties."
},
"benefitFutureEnhancements": {
"title": "Toekomstige verbeteringen",
"description": "We zijn van plan om meer analytica, waarschuwing en beheerhulpmiddelen toe te voegen om uw implementatie nog steviger te maken."
},
"docsAlert": {
"text": "Meer informatie over de optie voor zelf-verzorging in onze",
"documentation": "documentatie"
},
"convertButton": "Converteer deze node naar Beheerde Zelf-Hosted"
},
"internationaldomaindetected": "Internationaal Domein Gedetecteerd",
"willbestoredas": "Zal worden opgeslagen als:"
} }

View File

@@ -205,6 +205,7 @@
"resourceSetting": "Ustawienia {resourceName}", "resourceSetting": "Ustawienia {resourceName}",
"alwaysAllow": "Zawsze zezwalaj", "alwaysAllow": "Zawsze zezwalaj",
"alwaysDeny": "Zawsze odmawiaj", "alwaysDeny": "Zawsze odmawiaj",
"passToAuth": "Przekaż do Autoryzacji",
"orgSettingsDescription": "Skonfiguruj ustawienia ogólne swojej organizacji", "orgSettingsDescription": "Skonfiguruj ustawienia ogólne swojej organizacji",
"orgGeneralSettings": "Ustawienia organizacji", "orgGeneralSettings": "Ustawienia organizacji",
"orgGeneralSettingsDescription": "Zarządzaj szczegółami swojej organizacji i konfiguracją", "orgGeneralSettingsDescription": "Zarządzaj szczegółami swojej organizacji i konfiguracją",
@@ -545,6 +546,7 @@
"rulesActions": "Akcje", "rulesActions": "Akcje",
"rulesActionAlwaysAllow": "Zawsze zezwalaj: Pomiń wszystkie metody uwierzytelniania", "rulesActionAlwaysAllow": "Zawsze zezwalaj: Pomiń wszystkie metody uwierzytelniania",
"rulesActionAlwaysDeny": "Zawsze odmawiaj: Blokuj wszystkie żądania; nie można próbować uwierzytelniania", "rulesActionAlwaysDeny": "Zawsze odmawiaj: Blokuj wszystkie żądania; nie można próbować uwierzytelniania",
"rulesActionPassToAuth": "Przekaż do Autoryzacji: Zezwól na próby metod uwierzytelniania",
"rulesMatchCriteria": "Kryteria dopasowania", "rulesMatchCriteria": "Kryteria dopasowania",
"rulesMatchCriteriaIpAddress": "Dopasuj konkretny adres IP", "rulesMatchCriteriaIpAddress": "Dopasuj konkretny adres IP",
"rulesMatchCriteriaIpAddressRange": "Dopasuj zakres adresów IP w notacji CIDR", "rulesMatchCriteriaIpAddressRange": "Dopasuj zakres adresów IP w notacji CIDR",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "Aktualizuj klienta", "actionUpdateClient": "Aktualizuj klienta",
"actionListClients": "Lista klientów", "actionListClients": "Lista klientów",
"actionGetClient": "Pobierz klienta", "actionGetClient": "Pobierz klienta",
"actionCreateSiteResource": "Utwórz zasób witryny",
"actionDeleteSiteResource": "Usuń zasób strony",
"actionGetSiteResource": "Pobierz zasób strony",
"actionListSiteResources": "Lista zasobów strony",
"actionUpdateSiteResource": "Aktualizuj zasób strony",
"actionListInvitations": "Lista zaproszeń",
"noneSelected": "Nie wybrano", "noneSelected": "Nie wybrano",
"orgNotFound2": "Nie znaleziono organizacji.", "orgNotFound2": "Nie znaleziono organizacji.",
"searchProgress": "Szukaj...", "searchProgress": "Szukaj...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "Przekierowanie do logowania...", "autoLoginRedirecting": "Przekierowanie do logowania...",
"autoLoginError": "Błąd automatycznego logowania", "autoLoginError": "Błąd automatycznego logowania",
"autoLoginErrorNoRedirectUrl": "Nie otrzymano URL przekierowania od dostawcy tożsamości.", "autoLoginErrorNoRedirectUrl": "Nie otrzymano URL przekierowania od dostawcy tożsamości.",
"autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania." "autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania.",
"managedSelfHosted": {
"title": "Zarządzane Samodzielnie-Hostingowane",
"description": "Większa niezawodność i niska konserwacja serwera Pangolin z dodatkowymi dzwonkami i sygnałami",
"introTitle": "Zarządzany samowystarczalny Pangolin",
"introDescription": "jest opcją wdrażania zaprojektowaną dla osób, które chcą prostoty i dodatkowej niezawodności, przy jednoczesnym utrzymaniu swoich danych prywatnych i samodzielnych.",
"introDetail": "Z tą opcją nadal obsługujesz swój własny węzeł Pangolin — tunele, zakończenie SSL i ruch na Twoim serwerze. Różnica polega na tym, że zarządzanie i monitorowanie odbywa się za pomocą naszej tablicy rozdzielczej, która odblokowuje szereg korzyści:",
"benefitSimplerOperations": {
"title": "Uproszczone operacje",
"description": "Nie ma potrzeby uruchamiania własnego serwera pocztowego lub ustawiania skomplikowanych powiadomień. Będziesz mieć kontrolę zdrowia i powiadomienia o przestoju."
},
"benefitAutomaticUpdates": {
"title": "Automatyczne aktualizacje",
"description": "Panel chmury rozwija się szybko, więc otrzymujesz nowe funkcje i poprawki błędów bez konieczności ręcznego ciągnięcia nowych kontenerów za każdym razem."
},
"benefitLessMaintenance": {
"title": "Mniej konserwacji",
"description": "Brak migracji bazy danych, kopii zapasowych lub dodatkowej infrastruktury do zarządzania. Obsługujemy to w chmurze."
},
"benefitCloudFailover": {
"title": "Przegrywanie w chmurze",
"description": "Jeśli Twój węzeł zostanie wyłączony, tunele mogą tymczasowo zawieść do naszych punktów w chmurze, dopóki nie przyniesiesz go z powrotem do trybu online."
},
"benefitHighAvailability": {
"title": "Wysoka dostępność (PoPs)",
"description": "Możesz również dołączyć wiele węzłów do swojego konta w celu nadmiarowości i lepszej wydajności."
},
"benefitFutureEnhancements": {
"title": "Przyszłe ulepszenia",
"description": "Planujemy dodać więcej narzędzi analitycznych, ostrzegawczych i zarządzania, aby zwiększyć odporność wdrożenia."
},
"docsAlert": {
"text": "Dowiedz się więcej o opcji zarządzania samodzielnym hostingiem w naszym",
"documentation": "dokumentacja"
},
"convertButton": "Konwertuj ten węzeł do zarządzanego samodzielnie"
},
"internationaldomaindetected": "Wykryto międzynarodową domenę",
"willbestoredas": "Będą przechowywane jako:"
} }

View File

@@ -205,6 +205,7 @@
"resourceSetting": "Configurações do {resourceName}", "resourceSetting": "Configurações do {resourceName}",
"alwaysAllow": "Sempre permitir", "alwaysAllow": "Sempre permitir",
"alwaysDeny": "Sempre negar", "alwaysDeny": "Sempre negar",
"passToAuth": "Passar para Autenticação",
"orgSettingsDescription": "Configurar as configurações gerais da sua organização", "orgSettingsDescription": "Configurar as configurações gerais da sua organização",
"orgGeneralSettings": "Configurações da organização", "orgGeneralSettings": "Configurações da organização",
"orgGeneralSettingsDescription": "Gerencie os detalhes e a configuração da sua organização", "orgGeneralSettingsDescription": "Gerencie os detalhes e a configuração da sua organização",
@@ -545,6 +546,7 @@
"rulesActions": "Ações", "rulesActions": "Ações",
"rulesActionAlwaysAllow": "Sempre Permitir: Ignorar todos os métodos de autenticação", "rulesActionAlwaysAllow": "Sempre Permitir: Ignorar todos os métodos de autenticação",
"rulesActionAlwaysDeny": "Sempre Negar: Bloquear todas as requisições; nenhuma autenticação pode ser tentada", "rulesActionAlwaysDeny": "Sempre Negar: Bloquear todas as requisições; nenhuma autenticação pode ser tentada",
"rulesActionPassToAuth": "Passar para Autenticação: Permitir que métodos de autenticação sejam tentados",
"rulesMatchCriteria": "Critérios de Correspondência", "rulesMatchCriteria": "Critérios de Correspondência",
"rulesMatchCriteriaIpAddress": "Corresponder a um endereço IP específico", "rulesMatchCriteriaIpAddress": "Corresponder a um endereço IP específico",
"rulesMatchCriteriaIpAddressRange": "Corresponder a uma faixa de endereços IP em notação CIDR", "rulesMatchCriteriaIpAddressRange": "Corresponder a uma faixa de endereços IP em notação CIDR",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "Atualizar Cliente", "actionUpdateClient": "Atualizar Cliente",
"actionListClients": "Listar Clientes", "actionListClients": "Listar Clientes",
"actionGetClient": "Obter Cliente", "actionGetClient": "Obter Cliente",
"actionCreateSiteResource": "Criar Recurso do Site",
"actionDeleteSiteResource": "Eliminar Recurso do Site",
"actionGetSiteResource": "Obter Recurso do Site",
"actionListSiteResources": "Listar Recursos do Site",
"actionUpdateSiteResource": "Atualizar Recurso do Site",
"actionListInvitations": "Listar Convites",
"noneSelected": "Nenhum selecionado", "noneSelected": "Nenhum selecionado",
"orgNotFound2": "Nenhuma organização encontrada.", "orgNotFound2": "Nenhuma organização encontrada.",
"searchProgress": "Pesquisar...", "searchProgress": "Pesquisar...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "Redirecionando para login...", "autoLoginRedirecting": "Redirecionando para login...",
"autoLoginError": "Erro de Login Automático", "autoLoginError": "Erro de Login Automático",
"autoLoginErrorNoRedirectUrl": "Nenhum URL de redirecionamento recebido do provedor de identidade.", "autoLoginErrorNoRedirectUrl": "Nenhum URL de redirecionamento recebido do provedor de identidade.",
"autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação." "autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação.",
"managedSelfHosted": {
"title": "Gerenciado Auto-Hospedado",
"description": "Servidor Pangolin auto-hospedado mais confiável e com baixa manutenção com sinos extras e assobiamentos",
"introTitle": "Pangolin Auto-Hospedado Gerenciado",
"introDescription": "é uma opção de implantação projetada para pessoas que querem simplicidade e confiança adicional, mantendo os seus dados privados e auto-hospedados.",
"introDetail": "Com esta opção, você ainda roda seu próprio nó Pangolin — seus túneis, terminação SSL e tráfego todos permanecem no seu servidor. A diferença é que a gestão e a monitorização são geridos através do nosso painel de nuvem, que desbloqueia vários benefícios:",
"benefitSimplerOperations": {
"title": "Operações simples",
"description": "Não é necessário executar o seu próprio servidor de e-mail ou configurar um alerta complexo. Você receberá fora de caixa verificações de saúde e alertas de tempo de inatividade."
},
"benefitAutomaticUpdates": {
"title": "Atualizações automáticas",
"description": "O painel em nuvem evolui rapidamente, para que você obtenha novos recursos e correções de bugs sem ter de puxar manualmente novos contêineres toda vez."
},
"benefitLessMaintenance": {
"title": "Menos manutenção",
"description": "Sem migrações, backups ou infraestrutura extra para gerenciar. Lidamos com isso na nuvem."
},
"benefitCloudFailover": {
"title": "Falha na nuvem",
"description": "Se o seu nó descer, seus túneis podem falhar temporariamente nos nossos pontos de presença na nuvem até que você o traga online."
},
"benefitHighAvailability": {
"title": "Alta disponibilidade (Ppos)",
"description": "Você também pode anexar vários nós à sua conta para um melhor desempenho."
},
"benefitFutureEnhancements": {
"title": "Aprimoramentos futuros",
"description": "Estamos planejando adicionar mais análises, alertas e ferramentas de gerenciamento para tornar sua implantação ainda mais robusta."
},
"docsAlert": {
"text": "Saiba mais sobre a opção Hospedagem Auto-Gerenciada no nosso",
"documentation": "documentação"
},
"convertButton": "Converter este nó para Auto-Hospedado Gerenciado"
},
"internationaldomaindetected": "Domínio Internacional Detectado",
"willbestoredas": "Será armazenado como:"
} }

View File

@@ -205,6 +205,7 @@
"resourceSetting": "Настройки {resourceName}", "resourceSetting": "Настройки {resourceName}",
"alwaysAllow": "Всегда разрешать", "alwaysAllow": "Всегда разрешать",
"alwaysDeny": "Всегда запрещать", "alwaysDeny": "Всегда запрещать",
"passToAuth": "Переход к аутентификации",
"orgSettingsDescription": "Настройте общие параметры вашей организации", "orgSettingsDescription": "Настройте общие параметры вашей организации",
"orgGeneralSettings": "Настройки организации", "orgGeneralSettings": "Настройки организации",
"orgGeneralSettingsDescription": "Управляйте данными и конфигурацией вашей организации", "orgGeneralSettingsDescription": "Управляйте данными и конфигурацией вашей организации",
@@ -545,6 +546,7 @@
"rulesActions": "Действия", "rulesActions": "Действия",
"rulesActionAlwaysAllow": "Всегда разрешать: Обойти все методы аутентификации", "rulesActionAlwaysAllow": "Всегда разрешать: Обойти все методы аутентификации",
"rulesActionAlwaysDeny": "Всегда запрещать: Блокировать все запросы; аутентификация не может быть выполнена", "rulesActionAlwaysDeny": "Всегда запрещать: Блокировать все запросы; аутентификация не может быть выполнена",
"rulesActionPassToAuth": "Переход к аутентификации: Разрешить попытки методов аутентификации",
"rulesMatchCriteria": "Критерии совпадения", "rulesMatchCriteria": "Критерии совпадения",
"rulesMatchCriteriaIpAddress": "Совпадение с конкретным IP адресом", "rulesMatchCriteriaIpAddress": "Совпадение с конкретным IP адресом",
"rulesMatchCriteriaIpAddressRange": "Совпадение с диапазоном IP адресов в нотации CIDR", "rulesMatchCriteriaIpAddressRange": "Совпадение с диапазоном IP адресов в нотации CIDR",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "Обновить Клиента", "actionUpdateClient": "Обновить Клиента",
"actionListClients": "Список Клиентов", "actionListClients": "Список Клиентов",
"actionGetClient": "Получить Клиента", "actionGetClient": "Получить Клиента",
"actionCreateSiteResource": "Создать ресурс сайта",
"actionDeleteSiteResource": "Удалить ресурс сайта ",
"actionGetSiteResource": "Получить ресурс сайта",
"actionListSiteResources": "Список ресурсов сайта",
"actionUpdateSiteResource": "Обновить ресурс сайта",
"actionListInvitations": "Список приглашений",
"noneSelected": "Ничего не выбрано", "noneSelected": "Ничего не выбрано",
"orgNotFound2": "Организации не найдены.", "orgNotFound2": "Организации не найдены.",
"searchProgress": "Поиск...", "searchProgress": "Поиск...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "Перенаправление к входу...", "autoLoginRedirecting": "Перенаправление к входу...",
"autoLoginError": "Ошибка автоматического входа", "autoLoginError": "Ошибка автоматического входа",
"autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.", "autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.",
"autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации." "autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации.",
"managedSelfHosted": {
"title": "Управляемый с самовывоза",
"description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками",
"introTitle": "Управляемый Само-Хост Панголина",
"introDescription": "- это вариант развертывания, предназначенный для людей, которые хотят простоты и надёжности, сохраняя при этом свои данные конфиденциальными и самостоятельными.",
"introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin — туннели, SSL, и весь остающийся на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через нашу панель инструментов из облака, которая открывает ряд преимуществ:",
"benefitSimplerOperations": {
"title": "Более простые операции",
"description": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки."
},
"benefitAutomaticUpdates": {
"title": "Автоматическое обновление",
"description": "Панель управления в облаке развивается быстро, так что вы получаете новые функции и исправления ошибок, без необходимости каждый раз получать новые контейнеры."
},
"benefitLessMaintenance": {
"title": "Меньше обслуживания",
"description": "Нет миграции баз данных, резервных копий или дополнительной инфраструктуры для управления. Мы обрабатываем это в облаке."
},
"benefitCloudFailover": {
"title": "Облачное срабатывание",
"description": "Если ваш узел исчезнет, ваши туннели могут временно прерваться до наших облачных точек присутствия, пока вы не вернете его в сети."
},
"benefitHighAvailability": {
"title": "Высокая доступность (PoP)",
"description": "Вы также можете прикрепить несколько узлов к вашему аккаунту для избыточности и лучшей производительности."
},
"benefitFutureEnhancements": {
"title": "Будущие улучшения",
"description": "Мы планируем добавить дополнительные инструменты аналитики, оповещения и управления, чтобы сделать установку еще более надежной."
},
"docsAlert": {
"text": "Узнайте больше о опции Managed Self-Hosted в нашей",
"documentation": "документация"
},
"convertButton": "Конвертировать этот узел в управляемый себе-хост"
},
"internationaldomaindetected": "Обнаружен международный домен",
"willbestoredas": "Будет храниться как:"
} }

View File

@@ -205,6 +205,7 @@
"resourceSetting": "{resourceName} Ayarları", "resourceSetting": "{resourceName} Ayarları",
"alwaysAllow": "Her Zaman İzin Ver", "alwaysAllow": "Her Zaman İzin Ver",
"alwaysDeny": "Her Zaman Reddet", "alwaysDeny": "Her Zaman Reddet",
"passToAuth": "Kimlik Doğrulamasına Geç",
"orgSettingsDescription": "Organizasyonunuzun genel ayarlarını yapılandırın", "orgSettingsDescription": "Organizasyonunuzun genel ayarlarını yapılandırın",
"orgGeneralSettings": "Organizasyon Ayarları", "orgGeneralSettings": "Organizasyon Ayarları",
"orgGeneralSettingsDescription": "Organizasyon detaylarınızı ve yapılandırmanızı yönetin", "orgGeneralSettingsDescription": "Organizasyon detaylarınızı ve yapılandırmanızı yönetin",
@@ -545,6 +546,7 @@
"rulesActions": "Aksiyonlar", "rulesActions": "Aksiyonlar",
"rulesActionAlwaysAllow": "Her Zaman İzin Ver: Tüm kimlik doğrulama yöntemlerini atlayın", "rulesActionAlwaysAllow": "Her Zaman İzin Ver: Tüm kimlik doğrulama yöntemlerini atlayın",
"rulesActionAlwaysDeny": "Her Zaman Reddedin: Tüm istekleri engelleyin; kimlik doğrulaması yapılamaz", "rulesActionAlwaysDeny": "Her Zaman Reddedin: Tüm istekleri engelleyin; kimlik doğrulaması yapılamaz",
"rulesActionPassToAuth": "Kimlik Doğrulamasına Geç: Kimlik doğrulama yöntemlerinin denenmesine izin ver",
"rulesMatchCriteria": "Eşleşme Kriterleri", "rulesMatchCriteria": "Eşleşme Kriterleri",
"rulesMatchCriteriaIpAddress": "Belirli bir IP adresi ile eşleşme", "rulesMatchCriteriaIpAddress": "Belirli bir IP adresi ile eşleşme",
"rulesMatchCriteriaIpAddressRange": "CIDR gösteriminde bir IP adresi aralığı ile eşleşme", "rulesMatchCriteriaIpAddressRange": "CIDR gösteriminde bir IP adresi aralığı ile eşleşme",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "Müşteri Güncelle", "actionUpdateClient": "Müşteri Güncelle",
"actionListClients": "Müşterileri Listele", "actionListClients": "Müşterileri Listele",
"actionGetClient": "Müşteriyi Al", "actionGetClient": "Müşteriyi Al",
"actionCreateSiteResource": "Site Kaynağı Oluştur",
"actionDeleteSiteResource": "Site Kaynağını Sil",
"actionGetSiteResource": "Site Kaynağını Al",
"actionListSiteResources": "Site Kaynaklarını Listele",
"actionUpdateSiteResource": "Site Kaynağını Güncelle",
"actionListInvitations": "Davetiyeleri Listele",
"noneSelected": "Hiçbiri seçili değil", "noneSelected": "Hiçbiri seçili değil",
"orgNotFound2": "Hiçbir organizasyon bulunamadı.", "orgNotFound2": "Hiçbir organizasyon bulunamadı.",
"searchProgress": "Ara...", "searchProgress": "Ara...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "Girişe yönlendiriliyorsunuz...", "autoLoginRedirecting": "Girişe yönlendiriliyorsunuz...",
"autoLoginError": "Otomatik Giriş Hatası", "autoLoginError": "Otomatik Giriş Hatası",
"autoLoginErrorNoRedirectUrl": "Kimlik sağlayıcıdan yönlendirme URL'si alınamadı.", "autoLoginErrorNoRedirectUrl": "Kimlik sağlayıcıdan yönlendirme URL'si alınamadı.",
"autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı." "autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı.",
"managedSelfHosted": {
"title": "Yönetilen Self-Hosted",
"description": "Daha güvenilir ve düşük bakım gerektiren, ekstra özelliklere sahip kendi kendine barındırabileceğiniz Pangolin sunucusu",
"introTitle": "Yönetilen Kendi Kendine Barındırılan Pangolin",
"introDescription": "Bu, basitlik ve ekstra güvenilirlik arayan, ancak verilerini gizli tutmak ve kendi sunucularında barındırmak isteyen kişiler için tasarlanmış bir dağıtım seçeneğidir.",
"introDetail": "Bu seçenekle, kendi Pangolin düğümünüzü çalıştırmaya devam edersiniz — tünelleriniz, SSL bitişiniz ve trafiğiniz tamamen sunucunuzda kalır. Fark, yönetim ve izlemeyi bulut panomuz üzerinden gerçekleştiririz, bu da bir dizi avantaj sağlar:",
"benefitSimplerOperations": {
"title": "Daha basit işlemler",
"description": "Kendi e-posta sunucunuzu çalıştırmanıza veya karmaşık uyarılar kurmanıza gerek yok. Sağlık kontrolleri ve kesinti uyarılarını kutudan çıktığı gibi alırsınız."
},
"benefitAutomaticUpdates": {
"title": "Otomatik güncellemeler",
"description": "Bulut panosu hızla gelişir, böylece her seferinde yeni konteynerler manuel olarak çekmeden yeni özellikler ve hata düzeltmeleri alırsınız."
},
"benefitLessMaintenance": {
"title": "Daha az bakım",
"description": "Veritabanı geçişleri, yedeklemeler veya ekstra altyapı yönetimi yok. Biz bunu bulutta hallederiz."
},
"benefitCloudFailover": {
"title": "Bulut yedekleme",
"description": "Düğümünüz kapandığında, tünelleriniz geçici olarak bulut bağlantı noktalarımıza geçebilir, böylece tekrar çevrimiçi hale getirene kadar tünelleriniz kesintiye uğramaz."
},
"benefitHighAvailability": {
"title": "Yüksek kullanılabilirlik (Bağlantı Noktaları)",
"description": "Yedeklilik ve daha iyi performans için hesabınıza birden fazla düğüm bağlayabilirsiniz."
},
"benefitFutureEnhancements": {
"title": "Gelecek iyileştirmeler",
"description": "Dağıtımınızı daha sağlam hale getirmek amacıyla daha fazla analiz, uyarı ve yönetim aracı eklemeyi planlıyoruz."
},
"docsAlert": {
"text": "Yönetilen Kendi Kendine Barındırılan seçeneği hakkında daha fazla bilgi edinin",
"documentation": "dokümantasyon"
},
"convertButton": "Bu Düğümü Yönetilen Kendi Kendine Barındırma Dönüştürün"
},
"internationaldomaindetected": "Uluslararası Alan Adı Tespit Edildi",
"willbestoredas": "Şu şekilde depolanacak:"
} }

View File

@@ -205,6 +205,7 @@
"resourceSetting": "{resourceName} 设置", "resourceSetting": "{resourceName} 设置",
"alwaysAllow": "一律允许", "alwaysAllow": "一律允许",
"alwaysDeny": "一律拒绝", "alwaysDeny": "一律拒绝",
"passToAuth": "传递至认证",
"orgSettingsDescription": "配置您组织的一般设置", "orgSettingsDescription": "配置您组织的一般设置",
"orgGeneralSettings": "组织设置", "orgGeneralSettings": "组织设置",
"orgGeneralSettingsDescription": "管理您的机构详细信息和配置", "orgGeneralSettingsDescription": "管理您的机构详细信息和配置",
@@ -545,6 +546,7 @@
"rulesActions": "行动", "rulesActions": "行动",
"rulesActionAlwaysAllow": "总是允许:绕过所有身份验证方法", "rulesActionAlwaysAllow": "总是允许:绕过所有身份验证方法",
"rulesActionAlwaysDeny": "总是拒绝:阻止所有请求;无法尝试验证", "rulesActionAlwaysDeny": "总是拒绝:阻止所有请求;无法尝试验证",
"rulesActionPassToAuth": "传递至认证:允许尝试身份验证方法",
"rulesMatchCriteria": "匹配条件", "rulesMatchCriteria": "匹配条件",
"rulesMatchCriteriaIpAddress": "匹配一个指定的 IP 地址", "rulesMatchCriteriaIpAddress": "匹配一个指定的 IP 地址",
"rulesMatchCriteriaIpAddressRange": "在 CIDR 符号中匹配一系列IP地址", "rulesMatchCriteriaIpAddressRange": "在 CIDR 符号中匹配一系列IP地址",
@@ -1052,6 +1054,12 @@
"actionUpdateClient": "更新客户端", "actionUpdateClient": "更新客户端",
"actionListClients": "列出客户端", "actionListClients": "列出客户端",
"actionGetClient": "获取客户端", "actionGetClient": "获取客户端",
"actionCreateSiteResource": "创建站点资源",
"actionDeleteSiteResource": "删除站点资源",
"actionGetSiteResource": "获取站点资源",
"actionListSiteResources": "列出站点资源",
"actionUpdateSiteResource": "更新站点资源",
"actionListInvitations": "邀请列表",
"noneSelected": "未选择", "noneSelected": "未选择",
"orgNotFound2": "未找到组织。", "orgNotFound2": "未找到组织。",
"searchProgress": "搜索中...", "searchProgress": "搜索中...",
@@ -1450,5 +1458,43 @@
"autoLoginRedirecting": "重定向到登录...", "autoLoginRedirecting": "重定向到登录...",
"autoLoginError": "自动登录错误", "autoLoginError": "自动登录错误",
"autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。", "autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。",
"autoLoginErrorGeneratingUrl": "生成身份验证URL失败。" "autoLoginErrorGeneratingUrl": "生成身份验证URL失败。",
"managedSelfHosted": {
"title": "托管自托管",
"description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器",
"introTitle": "托管自托管的潘戈林公司",
"introDescription": "这是一种部署选择,为那些希望简洁和额外可靠的人设计,同时仍然保持他们的数据的私密性和自我托管性。",
"introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 — — 您的隧道、SSL 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:",
"benefitSimplerOperations": {
"title": "简单的操作",
"description": "无需运行您自己的邮件服务器或设置复杂的警报。您将从方框中获得健康检查和下限提醒。"
},
"benefitAutomaticUpdates": {
"title": "自动更新",
"description": "云仪表盘快速演化,所以您可以获得新的功能和错误修复,而不必每次手动拉取新的容器。"
},
"benefitLessMaintenance": {
"title": "减少维护时间",
"description": "没有要管理的数据库迁移、备份或额外的基础设施。我们在云端处理这个问题。"
},
"benefitCloudFailover": {
"title": "云失败",
"description": "如果您的节点被关闭,您的隧道可能暂时无法连接到我们的云端,直到您将其重新连接上线。"
},
"benefitHighAvailability": {
"title": "高可用率(PoPs)",
"description": "您还可以将多个节点添加到您的帐户中以获取冗余和更好的性能。"
},
"benefitFutureEnhancements": {
"title": "将来的改进",
"description": "我们正在计划添加更多的分析、警报和管理工具,使你的部署更加有力。"
},
"docsAlert": {
"text": "在我们中更多地了解管理下的自托管选项",
"documentation": "文档"
},
"convertButton": "将此节点转换为管理自托管的"
},
"internationaldomaindetected": "检测到国际域",
"willbestoredas": "储存为:"
} }

28
package-lock.json generated
View File

@@ -84,6 +84,7 @@
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"rebuild": "0.1.2", "rebuild": "0.1.2",
"semver": "^7.7.2", "semver": "^7.7.2",
"source-map-support": "0.5.21",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"tailwind-merge": "3.3.1", "tailwind-merge": "3.3.1",
"tw-animate-css": "^1.3.7", "tw-animate-css": "^1.3.7",
@@ -112,8 +113,8 @@
"@types/node": "^24", "@types/node": "^24",
"@types/nodemailer": "6.4.17", "@types/nodemailer": "6.4.17",
"@types/pg": "8.15.5", "@types/pg": "8.15.5",
"@types/react": "19.1.10", "@types/react": "19.1.12",
"@types/react-dom": "19.1.7", "@types/react-dom": "19.1.9",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
"@types/swagger-ui-express": "^4.1.8", "@types/swagger-ui-express": "^4.1.8",
"@types/ws": "8.18.1", "@types/ws": "8.18.1",
@@ -125,7 +126,7 @@
"react-email": "4.2.8", "react-email": "4.2.8",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"tsc-alias": "1.8.16", "tsc-alias": "1.8.16",
"tsx": "4.20.4", "tsx": "4.20.5",
"typescript": "^5", "typescript": "^5",
"typescript-eslint": "^8.40.0" "typescript-eslint": "^8.40.0"
} }
@@ -5026,9 +5027,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.1.10", "version": "19.1.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
"integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5036,9 +5037,9 @@
} }
}, },
"node_modules/@types/react-dom": { "node_modules/@types/react-dom": {
"version": "19.1.7", "version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz",
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
@@ -6199,7 +6200,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bytes": { "node_modules/bytes": {
@@ -15590,7 +15590,6 @@
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -15609,7 +15608,6 @@
"version": "0.5.21", "version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"buffer-from": "^1.0.0", "buffer-from": "^1.0.0",
@@ -16268,9 +16266,9 @@
} }
}, },
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.20.4", "version": "4.20.5",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.4.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz",
"integrity": "sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==", "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -101,6 +101,7 @@
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"rebuild": "0.1.2", "rebuild": "0.1.2",
"semver": "^7.7.2", "semver": "^7.7.2",
"source-map-support": "0.5.21",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"tailwind-merge": "3.3.1", "tailwind-merge": "3.3.1",
"tw-animate-css": "^1.3.7", "tw-animate-css": "^1.3.7",
@@ -129,8 +130,8 @@
"@types/node": "^24", "@types/node": "^24",
"@types/nodemailer": "6.4.17", "@types/nodemailer": "6.4.17",
"@types/pg": "8.15.5", "@types/pg": "8.15.5",
"@types/react": "19.1.10", "@types/react": "19.1.12",
"@types/react-dom": "19.1.7", "@types/react-dom": "19.1.9",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
"@types/swagger-ui-express": "^4.1.8", "@types/swagger-ui-express": "^4.1.8",
"@types/ws": "8.18.1", "@types/ws": "8.18.1",
@@ -142,7 +143,7 @@
"react-email": "4.2.8", "react-email": "4.2.8",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"tsc-alias": "1.8.16", "tsc-alias": "1.8.16",
"tsx": "4.20.4", "tsx": "4.20.5",
"typescript": "^5", "typescript": "^5",
"typescript-eslint": "^8.40.0" "typescript-eslint": "^8.40.0"
}, },

View File

@@ -12,7 +12,7 @@ import { router as wsRouter, handleWSUpgrade } from "@server/routers/ws";
import { logIncomingMiddleware } from "./middlewares/logIncoming"; import { logIncomingMiddleware } from "./middlewares/logIncoming";
import { csrfProtectionMiddleware } from "./middlewares/csrfProtection"; import { csrfProtectionMiddleware } from "./middlewares/csrfProtection";
import helmet from "helmet"; import helmet from "helmet";
import rateLimit from "express-rate-limit"; import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "./types/HttpCode"; import HttpCode from "./types/HttpCode";
import requestTimeoutMiddleware from "./middlewares/requestTimeout"; import requestTimeoutMiddleware from "./middlewares/requestTimeout";
@@ -70,7 +70,7 @@ export function createApiServer() {
60 * 60 *
1000, 1000,
max: config.getRawConfig().rate_limits.global.max_requests, max: config.getRawConfig().rate_limits.global.max_requests,
keyGenerator: (req) => `apiServerGlobal:${req.ip}:${req.path}`, keyGenerator: (req) => `apiServerGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `Rate limit exceeded. You can make ${config.getRawConfig().rate_limits.global.max_requests} requests every ${config.getRawConfig().rate_limits.global.window_minutes} minute(s).`; const message = `Rate limit exceeded. You can make ${config.getRawConfig().rate_limits.global.max_requests} requests every ${config.getRawConfig().rate_limits.global.window_minutes} minute(s).`;
return next( return next(

View File

@@ -430,7 +430,7 @@ export const resourceRules = pgTable("resourceRules", {
.references(() => resources.resourceId, { onDelete: "cascade" }), .references(() => resources.resourceId, { onDelete: "cascade" }),
enabled: boolean("enabled").notNull().default(true), enabled: boolean("enabled").notNull().default(true),
priority: integer("priority").notNull(), priority: integer("priority").notNull(),
action: varchar("action").notNull(), // ACCEPT, DROP action: varchar("action").notNull(), // ACCEPT, DROP, PASS
match: varchar("match").notNull(), // CIDR, PATH, IP match: varchar("match").notNull(), // CIDR, PATH, IP
value: varchar("value").notNull() value: varchar("value").notNull()
}); });

View File

@@ -570,7 +570,7 @@ export const resourceRules = sqliteTable("resourceRules", {
.references(() => resources.resourceId, { onDelete: "cascade" }), .references(() => resources.resourceId, { onDelete: "cascade" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(), priority: integer("priority").notNull(),
action: text("action").notNull(), // ACCEPT, DROP action: text("action").notNull(), // ACCEPT, DROP, PASS
match: text("match").notNull(), // CIDR, PATH, IP match: text("match").notNull(), // CIDR, PATH, IP
value: text("value").notNull() value: text("value").notNull()
}); });

View File

@@ -1,5 +1,6 @@
#! /usr/bin/env node #! /usr/bin/env node
import "./extendZod.ts"; import "./extendZod.ts";
import 'source-map-support/register.js'
import { runSetupFunctions } from "./setup"; import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer"; import { createApiServer } from "./apiServer";

32
server/lib/geoip.ts Normal file
View File

@@ -0,0 +1,32 @@
import axios from "axios";
import config from "./config";
import { tokenManager } from "./tokenManager";
import logger from "@server/logger";
export async function getCountryCodeForIp(
ip: string
): Promise<string | undefined> {
try {
const response = await axios.get(
`${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/geoip/${ip}`,
await tokenManager.getAuthHeader()
);
return response.data.data.countryCode;
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error("Error fetching config in verify session:", {
message: error.message,
code: error.code,
status: error.response?.status,
statusText: error.response?.statusText,
url: error.config?.url,
method: error.config?.method
});
} else {
logger.error("Error fetching config in verify session:", error);
}
}
return;
}

View File

@@ -1,2 +1,3 @@
export * from "./response"; export * from "./response";
export { tokenManager, TokenManager } from "./tokenManager"; export { tokenManager, TokenManager } from "./tokenManager";
export * from "./geoip";

View File

@@ -129,7 +129,6 @@ export const configSchema = z
trust_proxy: z.number().int().gte(0).optional().default(1), trust_proxy: z.number().int().gte(0).optional().default(1),
secret: z secret: z
.string() .string()
.transform(getEnvOrYaml("SERVER_SECRET"))
.pipe(z.string().min(8)) .pipe(z.string().min(8))
.optional() .optional()
}).optional().default({ }).optional().default({
@@ -180,6 +179,7 @@ export const configSchema = z
.default("/var/dynamic/router_config.yml"), .default("/var/dynamic/router_config.yml"),
static_domains: z.array(z.string()).optional().default([]), static_domains: z.array(z.string()).optional().default([]),
site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]), site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]),
allow_raw_resources: z.boolean().optional().default(true),
file_mode: z.boolean().optional().default(false) file_mode: z.boolean().optional().default(false)
}) })
.optional() .optional()
@@ -324,7 +324,10 @@ export const configSchema = z
if (data.managed) { if (data.managed) {
return true; return true;
} }
// If hybrid is not defined, server secret must be defined // If hybrid is not defined, server secret must be defined. If its not defined already then pull it from env
if (data.server?.secret === undefined) {
data.server.secret = process.env.SERVER_SECRET;
}
return data.server?.secret !== undefined && data.server.secret.length > 0; return data.server?.secret !== undefined && data.server.secret.length > 0;
}, },
{ {

View File

@@ -10,6 +10,7 @@ export async function getValidCertificatesForDomainsHybrid(domains: Set<string>)
Array<{ Array<{
id: number; id: number;
domain: string; domain: string;
wildcard: boolean | null;
certFile: string | null; certFile: string | null;
keyFile: string | null; keyFile: string | null;
expiresAt: Date | null; expiresAt: Date | null;
@@ -68,6 +69,7 @@ export async function getValidCertificatesForDomains(domains: Set<string>): Prom
Array<{ Array<{
id: number; id: number;
domain: string; domain: string;
wildcard: boolean | null;
certFile: string | null; certFile: string | null;
keyFile: string | null; keyFile: string | null;
expiresAt: Date | null; expiresAt: Date | null;

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
export const subdomainSchema = z export const subdomainSchema = z
.string() .string()
.regex( .regex(
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/, /^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/,
"Invalid subdomain format" "Invalid subdomain format"
) )
.min(1, "Subdomain must be at least 1 character long") .min(1, "Subdomain must be at least 1 character long")
@@ -12,7 +12,8 @@ export const subdomainSchema = z
export const tlsNameSchema = z export const tlsNameSchema = z
.string() .string()
.regex( .regex(
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$|^$/, /^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$|^$/,
"Invalid subdomain format" "Invalid subdomain format"
) )
.transform((val) => val.toLowerCase()); .transform((val) => val.toLowerCase());

View File

@@ -0,0 +1,235 @@
import { assertEquals } from "@test/assert";
import { isDomainCoveredByWildcard } from "./traefikConfig";
function runTests() {
console.log('Running wildcard domain coverage tests...');
// Test case 1: Basic wildcard certificate at example.com
const basicWildcardCerts = new Map([
['example.com', { exists: true, wildcard: true }]
]);
// Should match first-level subdomains
assertEquals(
isDomainCoveredByWildcard('level1.example.com', basicWildcardCerts),
true,
'Wildcard cert at example.com should match level1.example.com'
);
assertEquals(
isDomainCoveredByWildcard('api.example.com', basicWildcardCerts),
true,
'Wildcard cert at example.com should match api.example.com'
);
assertEquals(
isDomainCoveredByWildcard('www.example.com', basicWildcardCerts),
true,
'Wildcard cert at example.com should match www.example.com'
);
// Should match the root domain (exact match)
assertEquals(
isDomainCoveredByWildcard('example.com', basicWildcardCerts),
true,
'Wildcard cert at example.com should match example.com itself'
);
// Should NOT match second-level subdomains
assertEquals(
isDomainCoveredByWildcard('level2.level1.example.com', basicWildcardCerts),
false,
'Wildcard cert at example.com should NOT match level2.level1.example.com'
);
assertEquals(
isDomainCoveredByWildcard('deep.nested.subdomain.example.com', basicWildcardCerts),
false,
'Wildcard cert at example.com should NOT match deep.nested.subdomain.example.com'
);
// Should NOT match different domains
assertEquals(
isDomainCoveredByWildcard('test.otherdomain.com', basicWildcardCerts),
false,
'Wildcard cert at example.com should NOT match test.otherdomain.com'
);
assertEquals(
isDomainCoveredByWildcard('notexample.com', basicWildcardCerts),
false,
'Wildcard cert at example.com should NOT match notexample.com'
);
// Test case 2: Multiple wildcard certificates
const multipleWildcardCerts = new Map([
['example.com', { exists: true, wildcard: true }],
['test.org', { exists: true, wildcard: true }],
['api.service.net', { exists: true, wildcard: true }]
]);
assertEquals(
isDomainCoveredByWildcard('app.example.com', multipleWildcardCerts),
true,
'Should match subdomain of first wildcard cert'
);
assertEquals(
isDomainCoveredByWildcard('staging.test.org', multipleWildcardCerts),
true,
'Should match subdomain of second wildcard cert'
);
assertEquals(
isDomainCoveredByWildcard('v1.api.service.net', multipleWildcardCerts),
true,
'Should match subdomain of third wildcard cert'
);
assertEquals(
isDomainCoveredByWildcard('deep.nested.api.service.net', multipleWildcardCerts),
false,
'Should NOT match multi-level subdomain of third wildcard cert'
);
// Test exact domain matches for multiple certs
assertEquals(
isDomainCoveredByWildcard('example.com', multipleWildcardCerts),
true,
'Should match exact domain of first wildcard cert'
);
assertEquals(
isDomainCoveredByWildcard('test.org', multipleWildcardCerts),
true,
'Should match exact domain of second wildcard cert'
);
assertEquals(
isDomainCoveredByWildcard('api.service.net', multipleWildcardCerts),
true,
'Should match exact domain of third wildcard cert'
);
// Test case 3: Non-wildcard certificates (should not match anything)
const nonWildcardCerts = new Map([
['example.com', { exists: true, wildcard: false }],
['specific.domain.com', { exists: true, wildcard: false }]
]);
assertEquals(
isDomainCoveredByWildcard('sub.example.com', nonWildcardCerts),
false,
'Non-wildcard cert should not match subdomains'
);
assertEquals(
isDomainCoveredByWildcard('example.com', nonWildcardCerts),
false,
'Non-wildcard cert should not match even exact domain via this function'
);
// Test case 4: Non-existent certificates (should not match)
const nonExistentCerts = new Map([
['example.com', { exists: false, wildcard: true }],
['missing.com', { exists: false, wildcard: true }]
]);
assertEquals(
isDomainCoveredByWildcard('sub.example.com', nonExistentCerts),
false,
'Non-existent wildcard cert should not match'
);
// Test case 5: Edge cases with special domain names
const specialDomainCerts = new Map([
['localhost', { exists: true, wildcard: true }],
['127-0-0-1.nip.io', { exists: true, wildcard: true }],
['xn--e1afmkfd.xn--p1ai', { exists: true, wildcard: true }] // IDN domain
]);
assertEquals(
isDomainCoveredByWildcard('app.localhost', specialDomainCerts),
true,
'Should match subdomain of localhost wildcard'
);
assertEquals(
isDomainCoveredByWildcard('test.127-0-0-1.nip.io', specialDomainCerts),
true,
'Should match subdomain of nip.io wildcard'
);
assertEquals(
isDomainCoveredByWildcard('sub.xn--e1afmkfd.xn--p1ai', specialDomainCerts),
true,
'Should match subdomain of IDN wildcard'
);
// Test case 6: Empty input and edge cases
const emptyCerts = new Map();
assertEquals(
isDomainCoveredByWildcard('any.domain.com', emptyCerts),
false,
'Empty certificate map should not match any domain'
);
// Test case 7: Domains with single character components
const singleCharCerts = new Map([
['a.com', { exists: true, wildcard: true }],
['x.y.z', { exists: true, wildcard: true }]
]);
assertEquals(
isDomainCoveredByWildcard('b.a.com', singleCharCerts),
true,
'Should match single character subdomain'
);
assertEquals(
isDomainCoveredByWildcard('w.x.y.z', singleCharCerts),
true,
'Should match single character subdomain of multi-part domain'
);
assertEquals(
isDomainCoveredByWildcard('v.w.x.y.z', singleCharCerts),
false,
'Should NOT match multi-level subdomain of single char domain'
);
// Test case 8: Domains with numbers and hyphens
const numericCerts = new Map([
['api-v2.service-1.com', { exists: true, wildcard: true }],
['123.456.net', { exists: true, wildcard: true }]
]);
assertEquals(
isDomainCoveredByWildcard('staging.api-v2.service-1.com', numericCerts),
true,
'Should match subdomain with hyphens and numbers'
);
assertEquals(
isDomainCoveredByWildcard('test.123.456.net', numericCerts),
true,
'Should match subdomain with numeric components'
);
assertEquals(
isDomainCoveredByWildcard('deep.staging.api-v2.service-1.com', numericCerts),
false,
'Should NOT match multi-level subdomain with hyphens and numbers'
);
console.log('All wildcard domain coverage tests passed!');
}
// Run all tests
try {
runTests();
} catch (error) {
console.error('Test failed:', error);
process.exit(1);
}

View File

@@ -29,6 +29,7 @@ export class TraefikConfigManager {
exists: boolean; exists: boolean;
lastModified: Date | null; lastModified: Date | null;
expiresAt: Date | null; expiresAt: Date | null;
wildcard: boolean | null;
} }
>(); >();
@@ -115,6 +116,7 @@ export class TraefikConfigManager {
exists: boolean; exists: boolean;
lastModified: Date | null; lastModified: Date | null;
expiresAt: Date | null; expiresAt: Date | null;
wildcard: boolean;
} }
> >
> { > {
@@ -136,13 +138,16 @@ export class TraefikConfigManager {
const certPath = path.join(domainDir, "cert.pem"); const certPath = path.join(domainDir, "cert.pem");
const keyPath = path.join(domainDir, "key.pem"); const keyPath = path.join(domainDir, "key.pem");
const lastUpdatePath = path.join(domainDir, ".last_update"); const lastUpdatePath = path.join(domainDir, ".last_update");
const wildcardPath = path.join(domainDir, ".wildcard");
const certExists = await this.fileExists(certPath); const certExists = await this.fileExists(certPath);
const keyExists = await this.fileExists(keyPath); const keyExists = await this.fileExists(keyPath);
const lastUpdateExists = await this.fileExists(lastUpdatePath); const lastUpdateExists = await this.fileExists(lastUpdatePath);
const wildcardExists = await this.fileExists(wildcardPath);
let lastModified: Date | null = null; let lastModified: Date | null = null;
const expiresAt: Date | null = null; const expiresAt: Date | null = null;
let wildcard = false;
if (lastUpdateExists) { if (lastUpdateExists) {
try { try {
@@ -161,10 +166,26 @@ export class TraefikConfigManager {
} }
} }
// Check if this is a wildcard certificate
if (wildcardExists) {
try {
const wildcardContent = fs
.readFileSync(wildcardPath, "utf8")
.trim();
wildcard = wildcardContent === "true";
} catch (error) {
logger.warn(
`Could not read wildcard file for ${domain}:`,
error
);
}
}
state.set(domain, { state.set(domain, {
exists: certExists && keyExists, exists: certExists && keyExists,
lastModified, lastModified,
expiresAt expiresAt,
wildcard
}); });
} }
} catch (error) { } catch (error) {
@@ -192,19 +213,36 @@ export class TraefikConfigManager {
return true; return true;
} }
// Fetch if domains have changed // Filter out domains covered by wildcard certificates
const domainsNeedingCerts = new Set<string>();
for (const domain of currentDomains) {
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
domainsNeedingCerts.add(domain);
}
}
// Fetch if domains needing certificates have changed
const lastDomainsNeedingCerts = new Set<string>();
for (const domain of this.lastKnownDomains) {
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
lastDomainsNeedingCerts.add(domain);
}
}
if ( if (
this.lastKnownDomains.size !== currentDomains.size || domainsNeedingCerts.size !== lastDomainsNeedingCerts.size ||
!Array.from(this.lastKnownDomains).every((domain) => !Array.from(domainsNeedingCerts).every((domain) =>
currentDomains.has(domain) lastDomainsNeedingCerts.has(domain)
) )
) { ) {
logger.info("Fetching certificates due to domain changes"); logger.info(
"Fetching certificates due to domain changes (after wildcard filtering)"
);
return true; return true;
} }
// Check if any local certificates are missing or appear to be outdated // Check if any local certificates are missing or appear to be outdated
for (const domain of currentDomains) { for (const domain of domainsNeedingCerts) {
const localState = this.lastLocalCertificateState.get(domain); const localState = this.lastLocalCertificateState.get(domain);
if (!localState || !localState.exists) { if (!localState || !localState.exists) {
logger.info( logger.info(
@@ -273,6 +311,7 @@ export class TraefikConfigManager {
let validCertificates: Array<{ let validCertificates: Array<{
id: number; id: number;
domain: string; domain: string;
wildcard: boolean | null;
certFile: string | null; certFile: string | null;
keyFile: string | null; keyFile: string | null;
expiresAt: Date | null; expiresAt: Date | null;
@@ -280,23 +319,50 @@ export class TraefikConfigManager {
}> = []; }> = [];
if (this.shouldFetchCertificates(domains)) { if (this.shouldFetchCertificates(domains)) {
// Get valid certificates for active domains // Filter out domains that are already covered by wildcard certificates
if (config.isManagedMode()) { const domainsToFetch = new Set<string>();
validCertificates = for (const domain of domains) {
await getValidCertificatesForDomainsHybrid(domains); if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
} else { domainsToFetch.add(domain);
validCertificates = } else {
await getValidCertificatesForDomains(domains); logger.debug(
`Domain ${domain} is covered by existing wildcard certificate, skipping fetch`
);
}
} }
this.lastCertificateFetch = new Date();
this.lastKnownDomains = new Set(domains);
logger.info( if (domainsToFetch.size > 0) {
`Fetched ${validCertificates.length} certificates from remote` // Get valid certificates for domains not covered by wildcards
); if (config.isManagedMode()) {
validCertificates =
await getValidCertificatesForDomainsHybrid(
domainsToFetch
);
} else {
validCertificates =
await getValidCertificatesForDomains(
domainsToFetch
);
}
this.lastCertificateFetch = new Date();
this.lastKnownDomains = new Set(domains);
// Download and decrypt new certificates logger.info(
await this.processValidCertificates(validCertificates); `Fetched ${validCertificates.length} certificates from remote (${domains.size - domainsToFetch.size} domains covered by wildcards)`
);
// Download and decrypt new certificates
await this.processValidCertificates(validCertificates);
} else {
logger.info(
"All domains are covered by existing wildcard certificates, no fetch needed"
);
this.lastCertificateFetch = new Date();
this.lastKnownDomains = new Set(domains);
}
// Always ensure all existing certificates (including wildcards) are in the config
await this.updateDynamicConfigFromLocalCerts(domains);
} else { } else {
const timeSinceLastFetch = this.lastCertificateFetch const timeSinceLastFetch = this.lastCertificateFetch
? Math.round( ? Math.round(
@@ -544,7 +610,11 @@ export class TraefikConfigManager {
// Clear existing certificates and rebuild from local state // Clear existing certificates and rebuild from local state
dynamicConfig.tls.certificates = []; dynamicConfig.tls.certificates = [];
// Keep track of certificates we've already added to avoid duplicates
const addedCertPaths = new Set<string>();
for (const domain of domains) { for (const domain of domains) {
// First, try to find an exact match certificate
const localState = this.lastLocalCertificateState.get(domain); const localState = this.lastLocalCertificateState.get(domain);
if (localState && localState.exists) { if (localState && localState.exists) {
const domainDir = path.join( const domainDir = path.join(
@@ -554,11 +624,47 @@ export class TraefikConfigManager {
const certPath = path.join(domainDir, "cert.pem"); const certPath = path.join(domainDir, "cert.pem");
const keyPath = path.join(domainDir, "key.pem"); const keyPath = path.join(domainDir, "key.pem");
const certEntry = { if (!addedCertPaths.has(certPath)) {
certFile: certPath, const certEntry = {
keyFile: keyPath certFile: certPath,
}; keyFile: keyPath
dynamicConfig.tls.certificates.push(certEntry); };
dynamicConfig.tls.certificates.push(certEntry);
addedCertPaths.add(certPath);
}
continue;
}
// If no exact match, check for wildcard certificates that cover this domain
for (const [certDomain, certState] of this.lastLocalCertificateState) {
if (certState.exists && certState.wildcard) {
// Check if this wildcard certificate covers the domain
if (domain.endsWith("." + certDomain)) {
// Verify it's only one level deep (wildcard only covers one level)
const prefix = domain.substring(
0,
domain.length - ("." + certDomain).length
);
if (!prefix.includes(".")) {
const domainDir = path.join(
config.getRawConfig().traefik.certificates_path,
certDomain
);
const certPath = path.join(domainDir, "cert.pem");
const keyPath = path.join(domainDir, "key.pem");
if (!addedCertPaths.has(certPath)) {
const certEntry = {
certFile: certPath,
keyFile: keyPath
};
dynamicConfig.tls.certificates.push(certEntry);
addedCertPaths.add(certPath);
}
break; // Found a wildcard that covers this domain
}
}
}
} }
} }
@@ -577,6 +683,7 @@ export class TraefikConfigManager {
validCertificates: Array<{ validCertificates: Array<{
id: number; id: number;
domain: string; domain: string;
wildcard: boolean | null;
certFile: string | null; certFile: string | null;
keyFile: string | null; keyFile: string | null;
expiresAt: Date | null; expiresAt: Date | null;
@@ -651,15 +758,24 @@ export class TraefikConfigManager {
"utf8" "utf8"
); );
// Check if this is a wildcard certificate and store it
const wildcardPath = path.join(domainDir, ".wildcard");
fs.writeFileSync(
wildcardPath,
cert.wildcard ? "true" : "false",
"utf8"
);
logger.info( logger.info(
`Certificate updated for domain: ${cert.domain}` `Certificate updated for domain: ${cert.domain}${cert.wildcard ? " (wildcard)" : ""}`
); );
// Update local state tracking // Update local state tracking
this.lastLocalCertificateState.set(cert.domain, { this.lastLocalCertificateState.set(cert.domain, {
exists: true, exists: true,
lastModified: new Date(), lastModified: new Date(),
expiresAt: cert.expiresAt expiresAt: cert.expiresAt,
wildcard: cert.wildcard
}); });
} }
@@ -810,14 +926,8 @@ export class TraefikConfigManager {
this.lastLocalCertificateState.delete(dirName); this.lastLocalCertificateState.delete(dirName);
// Remove from dynamic config // Remove from dynamic config
const certFilePath = path.join( const certFilePath = path.join(domainDir, "cert.pem");
domainDir, const keyFilePath = path.join(domainDir, "key.pem");
"cert.pem"
);
const keyFilePath = path.join(
domainDir,
"key.pem"
);
const before = dynamicConfig.tls.certificates.length; const before = dynamicConfig.tls.certificates.length;
dynamicConfig.tls.certificates = dynamicConfig.tls.certificates =
dynamicConfig.tls.certificates.filter( dynamicConfig.tls.certificates.filter(
@@ -894,14 +1004,58 @@ export class TraefikConfigManager {
monitorInterval: number; monitorInterval: number;
lastCertificateFetch: Date | null; lastCertificateFetch: Date | null;
localCertificateCount: number; localCertificateCount: number;
wildcardCertificates: string[];
domainsCoveredByWildcards: string[];
} { } {
const wildcardCertificates: string[] = [];
const domainsCoveredByWildcards: string[] = [];
// Find wildcard certificates
for (const [domain, state] of this.lastLocalCertificateState) {
if (state.exists && state.wildcard) {
wildcardCertificates.push(domain);
}
}
// Find domains covered by wildcards
for (const domain of this.activeDomains) {
if (isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
domainsCoveredByWildcards.push(domain);
}
}
return { return {
isRunning: this.isRunning, isRunning: this.isRunning,
activeDomains: Array.from(this.activeDomains), activeDomains: Array.from(this.activeDomains),
monitorInterval: monitorInterval:
config.getRawConfig().traefik.monitor_interval || 5000, config.getRawConfig().traefik.monitor_interval || 5000,
lastCertificateFetch: this.lastCertificateFetch, lastCertificateFetch: this.lastCertificateFetch,
localCertificateCount: this.lastLocalCertificateState.size localCertificateCount: this.lastLocalCertificateState.size,
wildcardCertificates,
domainsCoveredByWildcards
}; };
} }
} }
/**
* Check if a domain is covered by existing wildcard certificates
*/
export function isDomainCoveredByWildcard(domain: string, lastLocalCertificateState: Map<string, { exists: boolean; wildcard: boolean | null }>): boolean {
for (const [certDomain, state] of lastLocalCertificateState) {
if (state.exists && state.wildcard) {
// If stored as example.com but is wildcard, check subdomains
if (domain.endsWith("." + certDomain)) {
// Check that it's only one level deep (wildcard only covers one level)
const prefix = domain.substring(
0,
domain.length - ("." + certDomain).length
);
// If prefix contains a dot, it's more than one level deep
if (!prefix.includes(".")) {
return true;
}
}
}
}
return false;
}

View File

@@ -11,3 +11,4 @@ export * from "./verifyAccessTokenAccess";
export * from "./verifyApiKeyIsRoot"; export * from "./verifyApiKeyIsRoot";
export * from "./verifyApiKeyApiKeyAccess"; export * from "./verifyApiKeyApiKeyAccess";
export * from "./verifyApiKeyClientAccess"; export * from "./verifyApiKeyClientAccess";
export * from "./verifyApiKeySiteResourceAccess";

View File

@@ -0,0 +1,97 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { siteResources, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export async function verifyApiKeySiteResourceAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const siteResourceId = parseInt(req.params.siteResourceId);
const siteId = parseInt(req.params.siteId);
const orgId = req.params.orgId;
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (!siteResourceId || !siteId || !orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Missing required parameters"
)
);
}
if (apiKey.isRoot) {
// Root keys can access any resource in any org
return next();
}
// Check if the site resource exists and belongs to the specified site and org
const [siteResource] = await db
.select()
.from(siteResources)
.where(and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
))
.limit(1);
if (!siteResource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Site resource not found"
)
);
}
// Verify that the API key has access to the organization
if (!req.apiKeyOrg) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
if (apiKeyOrgRes.length === 0) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
req.apiKeyOrg = apiKeyOrgRes[0];
}
// Attach the siteResource to the request for use in the next middleware/route
// @ts-ignore - Extending Request type
req.siteResource = siteResource;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying site resource access"
)
);
}
}

View File

@@ -22,7 +22,7 @@ export async function verifyRoleAccess(
); );
} }
const { roleIds } = req.body; const roleIds = req.body?.roleIds;
const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]); const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
if (allRoleIds.length === 0) { if (allRoleIds.length === 0) {

View File

@@ -5,7 +5,6 @@ import {
validateResourceSessionToken validateResourceSessionToken
} from "@server/auth/sessions/resource"; } from "@server/auth/sessions/resource";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { db } from "@server/db";
import { import {
getResourceByDomain, getResourceByDomain,
getUserSessionWithUser, getUserSessionWithUser,
@@ -33,6 +32,7 @@ import createHttpError from "http-errors";
import NodeCache from "node-cache"; import NodeCache from "node-cache";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { getCountryCodeForIp } from "@server/lib";
// We'll see if this speeds anything up // We'll see if this speeds anything up
const cache = new NodeCache({ const cache = new NodeCache({
@@ -123,8 +123,8 @@ export async function verifyResourceSession(
let cleanHost = host; let cleanHost = host;
// if the host ends with :port, strip it // if the host ends with :port, strip it
if (cleanHost.match(/:[0-9]{1,5}$/)) { if (cleanHost.match(/:[0-9]{1,5}$/)) {
const matched = ''+cleanHost.match(/:[0-9]{1,5}$/); const matched = "" + cleanHost.match(/:[0-9]{1,5}$/);
cleanHost = cleanHost.slice(0, -1*matched.length); cleanHost = cleanHost.slice(0, -1 * matched.length);
} }
const resourceCacheKey = `resource:${cleanHost}`; const resourceCacheKey = `resource:${cleanHost}`;
@@ -176,6 +176,11 @@ export async function verifyResourceSession(
} else if (action == "DROP") { } else if (action == "DROP") {
logger.debug("Resource denied by rule"); logger.debug("Resource denied by rule");
return notAllowed(res); return notAllowed(res);
} else if (action == "PASS") {
logger.debug(
"Resource passed by rule, continuing to auth checks"
);
// Continue to authentication checks below
} }
// otherwise its undefined and we pass // otherwise its undefined and we pass
@@ -193,7 +198,10 @@ export async function verifyResourceSession(
let endpoint: string; let endpoint: string;
if (config.isManagedMode()) { if (config.isManagedMode()) {
endpoint = config.getRawConfig().managed?.redirect_endpoint || config.getRawConfig().managed?.endpoint || ""; endpoint =
config.getRawConfig().managed?.redirect_endpoint ||
config.getRawConfig().managed?.endpoint ||
"";
} else { } else {
endpoint = config.getRawConfig().app.dashboard_url!; endpoint = config.getRawConfig().app.dashboard_url!;
} }
@@ -576,7 +584,7 @@ async function checkRules(
resourceId: number, resourceId: number,
clientIp: string | undefined, clientIp: string | undefined,
path: string | undefined path: string | undefined
): Promise<"ACCEPT" | "DROP" | undefined> { ): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
const ruleCacheKey = `rules:${resourceId}`; const ruleCacheKey = `rules:${resourceId}`;
let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey); let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey);
@@ -613,6 +621,12 @@ async function checkRules(
isPathAllowed(rule.value, path) isPathAllowed(rule.value, path)
) { ) {
return rule.action as any; return rule.action as any;
} else if (
clientIp &&
rule.match == "GEOIP" &&
(await isIpInGeoIP(clientIp, rule.value))
) {
return rule.action as any;
} }
} }
@@ -737,3 +751,23 @@ export function isPathAllowed(pattern: string, path: string): boolean {
logger.debug(`Final result: ${result}`); logger.debug(`Final result: ${result}`);
return result; return result;
} }
async function isIpInGeoIP(ip: string, countryCode: string): Promise<boolean> {
if (countryCode == "ALL") {
return true;
}
const geoIpCacheKey = `geoip:${ip}`;
let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey);
if (!cachedCountryCode) {
cachedCountryCode = await getCountryCodeForIp(ip);
// Cache for longer since IP geolocation doesn't change frequently
cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
}
logger.debug(`IP ${ip} is in country: ${cachedCountryCode}`);
return cachedCountryCode?.toUpperCase() === countryCode.toUpperCase();
}

View File

@@ -42,7 +42,7 @@ import { createStore } from "@server/lib/rateLimitStore";
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
import { createNewt, getNewtToken } from "./newt"; import { createNewt, getNewtToken } from "./newt";
import { getOlmToken } from "./olm"; import { getOlmToken } from "./olm";
import rateLimit from "express-rate-limit"; import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { build } from "@server/build"; import { build } from "@server/build";
@@ -78,12 +78,16 @@ authenticated.post(
verifyUserHasAction(ActionsEnum.updateOrg), verifyUserHasAction(ActionsEnum.updateOrg),
org.updateOrg org.updateOrg
); );
authenticated.delete(
"/org/:orgId", if (build !== "saas") {
verifyOrgAccess, authenticated.delete(
verifyUserIsOrgOwner, "/org/:orgId",
org.deleteOrg verifyOrgAccess,
); verifyUserIsOrgOwner,
verifyUserHasAction(ActionsEnum.deleteOrg),
org.deleteOrg
);
}
authenticated.put( authenticated.put(
"/org/:orgId/site", "/org/:orgId/site",
@@ -815,7 +819,7 @@ authRouter.use(
rateLimit({ rateLimit({
windowMs: config.getRawConfig().rate_limits.auth.window_minutes, windowMs: config.getRawConfig().rate_limits.auth.window_minutes,
max: config.getRawConfig().rate_limits.auth.max_requests, max: config.getRawConfig().rate_limits.auth.max_requests,
keyGenerator: (req) => `authRouterGlobal:${req.ip}:${req.path}`, keyGenerator: (req) => `authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `Rate limit exceeded. You can make ${config.getRawConfig().rate_limits.auth.max_requests} requests every ${config.getRawConfig().rate_limits.auth.window_minutes} minute(s).`; const message = `Rate limit exceeded. You can make ${config.getRawConfig().rate_limits.auth.max_requests} requests every ${config.getRawConfig().rate_limits.auth.window_minutes} minute(s).`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -829,7 +833,7 @@ authRouter.put(
rateLimit({ rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => `signup:${req.ip}:${req.body.email}`, keyGenerator: (req) => `signup:${ipKeyGenerator(req.ip || "")}:${req.body.email}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`; const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -843,7 +847,7 @@ authRouter.post(
rateLimit({ rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => `login:${req.body.email || req.ip}`, keyGenerator: (req) => `login:${req.body.email || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only log in ${15} times every ${15} minutes. Please try again later.`; const message = `You can only log in ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -858,7 +862,7 @@ authRouter.post(
rateLimit({ rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 900, max: 900,
keyGenerator: (req) => `newtGetToken:${req.body.newtId || req.ip}`, keyGenerator: (req) => `newtGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only request a Newt token ${900} times every ${15} minutes. Please try again later.`; const message = `You can only request a Newt token ${900} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -872,7 +876,7 @@ authRouter.post(
rateLimit({ rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 900, max: 900,
keyGenerator: (req) => `olmGetToken:${req.body.newtId || req.ip}`, keyGenerator: (req) => `olmGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only request an Olm token ${900} times every ${15} minutes. Please try again later.`; const message = `You can only request an Olm token ${900} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -888,7 +892,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => { keyGenerator: (req) => {
return `signup:${req.body.email || req.user?.userId || req.ip}`; return `signup:${req.body.email || req.user?.userId || ipKeyGenerator(req.ip || "")}`;
}, },
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only enable 2FA ${15} times every ${15} minutes. Please try again later.`; const message = `You can only enable 2FA ${15} times every ${15} minutes. Please try again later.`;
@@ -904,7 +908,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => { keyGenerator: (req) => {
return `signup:${req.body.email || req.user?.userId || req.ip}`; return `signup:${req.body.email || req.user?.userId || ipKeyGenerator(req.ip || "")}`;
}, },
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only request a 2FA code ${15} times every ${15} minutes. Please try again later.`; const message = `You can only request a 2FA code ${15} times every ${15} minutes. Please try again later.`;
@@ -920,7 +924,7 @@ authRouter.post(
rateLimit({ rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => `signup:${req.user?.userId || req.ip}`, keyGenerator: (req) => `signup:${req.user?.userId || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only disable 2FA ${15} times every ${15} minutes. Please try again later.`; const message = `You can only disable 2FA ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -934,7 +938,7 @@ authRouter.post(
rateLimit({ rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => `signup:${req.body.email || req.ip}`, keyGenerator: (req) => `signup:${req.body.email || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`; const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -952,7 +956,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => keyGenerator: (req) =>
`requestEmailVerificationCode:${req.body.email || req.ip}`, `requestEmailVerificationCode:${req.user?.email || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`; const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -974,7 +978,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => keyGenerator: (req) =>
`requestPasswordReset:${req.body.email || req.ip}`, `requestPasswordReset:${req.body.email || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`; const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -989,7 +993,7 @@ authRouter.post(
rateLimit({ rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => `resetPassword:${req.body.email || req.ip}`, keyGenerator: (req) => `resetPassword:${req.body.email || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`; const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1005,7 +1009,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => keyGenerator: (req) =>
`authWithPassword:${req.ip}:${req.params.resourceId || req.ip}`, `authWithPassword:${ipKeyGenerator(req.ip || "")}:${req.params.resourceId || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only authenticate with password ${15} times every ${15} minutes. Please try again later.`; const message = `You can only authenticate with password ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1020,7 +1024,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => keyGenerator: (req) =>
`authWithPincode:${req.ip}:${req.params.resourceId || req.ip}`, `authWithPincode:${ipKeyGenerator(req.ip || "")}:${req.params.resourceId || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only authenticate with pincode ${15} times every ${15} minutes. Please try again later.`; const message = `You can only authenticate with pincode ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1036,7 +1040,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => keyGenerator: (req) =>
`authWithWhitelist:${req.ip}:${req.body.email}:${req.params.resourceId}`, `authWithWhitelist:${ipKeyGenerator(req.ip || "")}:${req.body.email}:${req.params.resourceId}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only request an email OTP ${15} times every ${15} minutes. Please try again later.`; const message = `You can only request an email OTP ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1069,7 +1073,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Allow 5 security key registrations per 15 minutes max: 5, // Allow 5 security key registrations per 15 minutes
keyGenerator: (req) => keyGenerator: (req) =>
`securityKeyRegister:${req.user?.userId || req.ip}`, `securityKeyRegister:${req.user?.userId || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only register a security key ${5} times every ${15} minutes. Please try again later.`; const message = `You can only register a security key ${5} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1089,7 +1093,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Allow 10 authentication attempts per 15 minutes per IP max: 10, // Allow 10 authentication attempts per 15 minutes per IP
keyGenerator: (req) => { keyGenerator: (req) => {
return `securityKeyAuth:${req.body.email || req.ip}`; return `securityKeyAuth:${req.body.email || ipKeyGenerator(req.ip || "")}`;
}, },
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only attempt security key authentication ${10} times every ${15} minutes. Please try again later.`; const message = `You can only attempt security key authentication ${10} times every ${15} minutes. Please try again later.`;
@@ -1111,7 +1115,7 @@ authRouter.delete(
rateLimit({ rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
max: 20, // Allow 10 authentication attempts per 15 minutes per IP max: 20, // Allow 10 authentication attempts per 15 minutes per IP
keyGenerator: (req) => `securityKeyAuth:${req.user?.userId || req.ip}`, keyGenerator: (req) => `securityKeyAuth:${req.user?.userId || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only delete a security key ${10} times every ${15} minutes. Please try again later.`; const message = `You can only delete a security key ${10} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));

View File

@@ -0,0 +1,58 @@
import { db, ExitNode, exitNodes } from "@server/db";
import { getUniqueExitNodeEndpointName } from "@server/db/names";
import config from "@server/lib/config";
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
import logger from "@server/logger";
import { eq } from "drizzle-orm";
export async function createExitNode(publicKey: string, reachableAt: string | undefined) {
// Fetch exit node
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
let exitNode: ExitNode;
if (!exitNodeQuery) {
const address = await getNextAvailableSubnet();
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port;
let subEndpoint = "";
if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
}
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
listenPort,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} else {
// update the existing exit node
[exitNode] = await db
.update(exitNodes)
.set({
reachableAt,
publicKey
})
.where(eq(exitNodes.publicKey, publicKey))
.returning();
logger.info(`Updated exit node`);
}
return exitNode;
}

View File

@@ -13,6 +13,8 @@ import { fromError } from "zod-validation-error";
import { getAllowedIps } from "../target/helpers"; import { getAllowedIps } from "../target/helpers";
import { proxyToRemote } from "@server/lib/remoteProxy"; import { proxyToRemote } from "@server/lib/remoteProxy";
import { getNextAvailableSubnet } from "@server/lib/exitNodes"; import { getNextAvailableSubnet } from "@server/lib/exitNodes";
import { createExitNode } from "./createExitNode";
// Define Zod schema for request validation // Define Zod schema for request validation
const getConfigSchema = z.object({ const getConfigSchema = z.object({
publicKey: z.string(), publicKey: z.string(),
@@ -53,46 +55,7 @@ export async function getConfig(
); );
} }
// Fetch exit node const exitNode = await createExitNode(publicKey, reachableAt);
const exitNodeQuery = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.publicKey, publicKey));
let exitNode;
if (exitNodeQuery.length === 0) {
const address = await getNextAvailableSubnet();
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port;
let subEndpoint = "";
if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
}
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
exitNode = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
listenPort,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode[0].name} with address ${exitNode[0].address} and port ${exitNode[0].listenPort}`
);
} else {
exitNode = exitNodeQuery;
}
if (!exitNode) { if (!exitNode) {
return next( return next(
@@ -107,13 +70,13 @@ export async function getConfig(
if (config.isManagedMode()) { if (config.isManagedMode()) {
req.body = { req.body = {
...req.body, ...req.body,
endpoint: exitNode[0].endpoint, endpoint: exitNode.endpoint,
listenPort: exitNode[0].listenPort listenPort: exitNode.listenPort
}; };
return proxyToRemote(req, res, next, "hybrid/gerbil/get-config"); return proxyToRemote(req, res, next, "hybrid/gerbil/get-config");
} }
const configResponse = await generateGerbilConfig(exitNode[0]); const configResponse = await generateGerbilConfig(exitNode);
logger.debug("Sending config: ", configResponse); logger.debug("Sending config: ", configResponse);

View File

@@ -9,6 +9,7 @@ import * as client from "./client";
import * as accessToken from "./accessToken"; import * as accessToken from "./accessToken";
import * as apiKeys from "./apiKeys"; import * as apiKeys from "./apiKeys";
import * as idp from "./idp"; import * as idp from "./idp";
import * as siteResource from "./siteResource";
import { import {
verifyApiKey, verifyApiKey,
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,
@@ -22,7 +23,8 @@ import {
verifyApiKeyAccessTokenAccess, verifyApiKeyAccessTokenAccess,
verifyApiKeyIsRoot, verifyApiKeyIsRoot,
verifyApiKeyClientAccess, verifyApiKeyClientAccess,
verifyClientsEnabled verifyClientsEnabled,
verifyApiKeySiteResourceAccess
} from "@server/middlewares"; } from "@server/middlewares";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { Router } from "express"; import { Router } from "express";
@@ -128,6 +130,69 @@ authenticated.delete(
site.deleteSite site.deleteSite
); );
authenticated.get(
"/org/:orgId/user-resources",
verifyApiKeyOrgAccess,
resource.getUserResources
);
// Site Resource endpoints
authenticated.put(
"/org/:orgId/site/:siteId/resource",
verifyApiKeyOrgAccess,
verifyApiKeySiteAccess,
verifyApiKeyHasAction(ActionsEnum.createSiteResource),
siteResource.createSiteResource
);
authenticated.get(
"/org/:orgId/site/:siteId/resources",
verifyApiKeyOrgAccess,
verifyApiKeySiteAccess,
verifyApiKeyHasAction(ActionsEnum.listSiteResources),
siteResource.listSiteResources
);
authenticated.get(
"/org/:orgId/site-resources",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listSiteResources),
siteResource.listAllSiteResourcesByOrg
);
authenticated.get(
"/org/:orgId/site/:siteId/resource/:siteResourceId",
verifyApiKeyOrgAccess,
verifyApiKeySiteAccess,
verifyApiKeySiteResourceAccess,
verifyApiKeyHasAction(ActionsEnum.getSiteResource),
siteResource.getSiteResource
);
authenticated.post(
"/org/:orgId/site/:siteId/resource/:siteResourceId",
verifyApiKeyOrgAccess,
verifyApiKeySiteAccess,
verifyApiKeySiteResourceAccess,
verifyApiKeyHasAction(ActionsEnum.updateSiteResource),
siteResource.updateSiteResource
);
authenticated.delete(
"/org/:orgId/site/:siteId/resource/:siteResourceId",
verifyApiKeyOrgAccess,
verifyApiKeySiteAccess,
verifyApiKeySiteResourceAccess,
verifyApiKeyHasAction(ActionsEnum.deleteSiteResource),
siteResource.deleteSiteResource
);
authenticated.put(
"/org/:orgId/resource",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createResource),
resource.createResource
);
authenticated.put( authenticated.put(
"/org/:orgId/site/:siteId/resource", "/org/:orgId/site/:siteId/resource",
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,
@@ -156,6 +221,13 @@ authenticated.get(
domain.listDomains domain.listDomains
); );
authenticated.get(
"/org/:orgId/invitations",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listInvitations),
user.listInvitations
);
authenticated.post( authenticated.post(
"/org/:orgId/create-invite", "/org/:orgId/create-invite",
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,

View File

@@ -49,19 +49,7 @@ export async function deleteOrg(
} }
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
// Check if the user has permission to list sites
const hasPermission = await checkUserActionPermission(
ActionsEnum.deleteOrg,
req
);
if (!hasPermission) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission to perform this action"
)
);
}
const [org] = await db const [org] = await db
.select() .select()
.from(orgs) .from(orgs)

View File

@@ -17,7 +17,7 @@ import { OpenAPITags, registry } from "@server/openApi";
const createResourceRuleSchema = z const createResourceRuleSchema = z
.object({ .object({
action: z.enum(["ACCEPT", "DROP"]), action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.enum(["CIDR", "IP", "PATH"]), match: z.enum(["CIDR", "IP", "PATH"]),
value: z.string().min(1), value: z.string().min(1),
priority: z.number().int(), priority: z.number().int(),

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, resources } from "@server/db";
import { apiKeys, roleResources, roles } from "@server/db"; import { apiKeys, roleResources, roles } 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";
@@ -74,13 +74,18 @@ export async function setResourceRoles(
const { resourceId } = parsedParams.data; const { resourceId } = parsedParams.data;
const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId; // get the resource
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!orgId) { if (!resource) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
"Organization not found" "Resource not found"
) )
); );
} }
@@ -92,7 +97,7 @@ export async function setResourceRoles(
.where( .where(
and( and(
eq(roles.name, "Admin"), eq(roles.name, "Admin"),
eq(roles.orgId, orgId) eq(roles.orgId, resource.orgId)
) )
) )
.limit(1); .limit(1);

View File

@@ -29,7 +29,7 @@ const updateResourceRuleParamsSchema = z
// Define Zod schema for request body validation // Define Zod schema for request body validation
const updateResourceRuleSchema = z const updateResourceRuleSchema = z
.object({ .object({
action: z.enum(["ACCEPT", "DROP"]).optional(), action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(),
match: z.enum(["CIDR", "IP", "PATH"]).optional(), match: z.enum(["CIDR", "IP", "PATH"]).optional(),
value: z.string().min(1).optional(), value: z.string().min(1).optional(),
priority: z.number().int(), priority: z.number().int(),

View File

@@ -60,7 +60,7 @@ export type ListRolesResponse = {
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/orgs/{orgId}/roles", path: "/org/{orgId}/roles",
description: "List roles.", description: "List roles.",
tags: [OpenAPITags.Org, OpenAPITags.Role], tags: [OpenAPITags.Org, OpenAPITags.Role],
request: { request: {

View File

@@ -272,7 +272,7 @@ export async function createSite(
type, type,
dockerSocketEnabled: false, dockerSocketEnabled: false,
online: true, online: true,
subnet: "0.0.0.0/0" subnet: "0.0.0.0/32"
}) })
.returning(); .returning();
} }

View File

@@ -1,6 +1,6 @@
import { Request, Response } from "express"; import { Request, Response } from "express";
import { db, exitNodes } from "@server/db"; import { db, exitNodes } from "@server/db";
import { and, eq, inArray, or, isNull, ne } from "drizzle-orm"; import { and, eq, inArray, or, isNull, ne, isNotNull } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -149,7 +149,10 @@ export async function getTraefikConfig(
eq(sites.exitNodeId, exitNodeId), eq(sites.exitNodeId, exitNodeId),
isNull(sites.exitNodeId) isNull(sites.exitNodeId)
), ),
inArray(sites.type, siteTypes) inArray(sites.type, siteTypes),
config.getRawConfig().traefik.allow_raw_resources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true),
) )
); );

View File

@@ -58,18 +58,23 @@ export async function addUserRole(
); );
} }
const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId; // get the role
const [role] = await db
.select()
.from(roles)
.where(eq(roles.roleId, roleId))
.limit(1);
if (!orgId) { if (!role) {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID")
); );
} }
const existingUser = await db const existingUser = await db
.select() .select()
.from(userOrgs) .from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)))
.limit(1); .limit(1);
if (existingUser.length === 0) { if (existingUser.length === 0) {
@@ -93,7 +98,7 @@ export async function addUserRole(
const roleExists = await db const roleExists = await db
.select() .select()
.from(roles) .from(roles)
.where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId))) .where(and(eq(roles.roleId, roleId), eq(roles.orgId, role.orgId)))
.limit(1); .limit(1);
if (roleExists.length === 0) { if (roleExists.length === 0) {
@@ -108,7 +113,7 @@ export async function addUserRole(
const newUserRole = await db const newUserRole = await db
.update(userOrgs) .update(userOrgs)
.set({ roleId }) .set({ roleId })
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)))
.returning(); .returning();
return response(res, { return response(res, {

View File

@@ -58,6 +58,12 @@ export default async function migration() {
await db.execute(sql`ALTER TABLE "clientSites" ADD COLUMN "endpoint" varchar;`); await db.execute(sql`ALTER TABLE "clientSites" ADD COLUMN "endpoint" varchar;`);
await db.execute(sql`ALTER TABLE "exitNodes" ADD COLUMN "online" boolean DEFAULT false NOT NULL;`);
await db.execute(sql`ALTER TABLE "exitNodes" ADD COLUMN "lastPing" integer;`);
await db.execute(sql`ALTER TABLE "exitNodes" ADD COLUMN "type" text DEFAULT 'gerbil';`);
await db.execute(sql`ALTER TABLE "olms" ADD COLUMN "version" text;`); await db.execute(sql`ALTER TABLE "olms" ADD COLUMN "version" text;`);
await db.execute(sql`ALTER TABLE "orgs" ADD COLUMN "createdAt" text;`); await db.execute(sql`ALTER TABLE "orgs" ADD COLUMN "createdAt" text;`);

View File

@@ -22,6 +22,7 @@ export function InvitationsDataTable<TData, TValue>({
<DataTable <DataTable
columns={columns} columns={columns}
data={data} data={data}
persistPageSize="invitations-table"
title={t('invite')} title={t('invite')}
searchPlaceholder={t('inviteSearch')} searchPlaceholder={t('inviteSearch')}
searchColumn="email" searchColumn="email"

View File

@@ -24,6 +24,7 @@ export function RolesDataTable<TData, TValue>({
<DataTable <DataTable
columns={columns} columns={columns}
data={data} data={data}
persistPageSize="roles-table"
title={t('roles')} title={t('roles')}
searchPlaceholder={t('accessRolesSearch')} searchPlaceholder={t('accessRolesSearch')}
searchColumn="name" searchColumn="name"

View File

@@ -24,6 +24,7 @@ export function UsersDataTable<TData, TValue>({
<DataTable <DataTable
columns={columns} columns={columns}
data={data} data={data}
persistPageSize="users-table"
title={t('users')} title={t('users')}
searchPlaceholder={t('accessUsersSearch')} searchPlaceholder={t('accessUsersSearch')}
searchColumn="email" searchColumn="email"

View File

@@ -777,15 +777,6 @@ export default function Page() {
</SettingsContainer> </SettingsContainer>
<div className="flex justify-end space-x-2 mt-8"> <div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/access/users`);
}}
>
{t("cancel")}
</Button>
{userType && dataLoaded && ( {userType && dataLoaded && (
<Button <Button
type={inviteLink ? "button" : "submit"} type={inviteLink ? "button" : "submit"}

View File

@@ -22,6 +22,7 @@ export function OrgApiKeysDataTable<TData, TValue>({
<DataTable <DataTable
columns={columns} columns={columns}
data={data} data={data}
persistPageSize="Org-apikeys-table"
title={t('apiKeys')} title={t('apiKeys')}
searchPlaceholder={t('searchApiKeys')} searchPlaceholder={t('searchApiKeys')}
searchColumn="name" searchColumn="name"

View File

@@ -20,6 +20,7 @@ export function ClientsDataTable<TData, TValue>({
<DataTable <DataTable
columns={columns} columns={columns}
data={data} data={data}
persistPageSize="clients-table"
title="Clients" title="Clients"
searchPlaceholder="Search clients..." searchPlaceholder="Search clients..."
searchColumn="name" searchColumn="name"

View File

@@ -7,12 +7,13 @@ import {
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage FormMessage,
FormDescription
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react"; import { useState, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { import {
@@ -33,7 +34,7 @@ import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain";
import { StrategySelect } from "@app/components/StrategySelect"; import { StrategySelect } from "@app/components/StrategySelect";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, AlertTriangle } from "lucide-react"; import { InfoIcon, AlertTriangle, Globe } from "lucide-react";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
import { import {
InfoSection, InfoSection,
@@ -43,9 +44,58 @@ import {
} from "@app/components/InfoSection"; } from "@app/components/InfoSection";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { build } from "@server/build"; import { build } from "@server/build";
import { toASCII, toUnicode } from 'punycode';
// Helper functions for Unicode domain handling
function toPunycode(domain: string): string {
try {
const parts = toASCII(domain);
return parts;
} catch (error) {
return domain.toLowerCase();
}
}
function fromPunycode(domain: string): string {
try {
const parts = toUnicode(domain);
return parts;
} catch (error) {
return domain;
}
}
function isValidDomainFormat(domain: string): boolean {
const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/;
if (!unicodeRegex.test(domain)) {
return false;
}
const parts = domain.split('.');
for (const part of parts) {
if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) {
return false;
}
if (part.length > 63) {
return false;
}
}
if (domain.length > 253) {
return false;
}
return true;
}
const formSchema = z.object({ const formSchema = z.object({
baseDomain: z.string().min(1, "Domain is required"), baseDomain: z
.string()
.min(1, "Domain is required")
.refine((val) => isValidDomainFormat(val), "Invalid domain format")
.transform((val) => toPunycode(val)),
type: z.enum(["ns", "cname", "wildcard"]) type: z.enum(["ns", "cname", "wildcard"])
}); });
@@ -109,8 +159,14 @@ export default function CreateDomainForm({
} }
} }
const domainType = form.watch("type");
const baseDomain = form.watch("baseDomain"); const baseDomain = form.watch("baseDomain");
const domainInputValue = form.watch("baseDomain") || "";
const punycodePreview = useMemo(() => {
if (!domainInputValue) return "";
const punycode = toPunycode(domainInputValue);
return punycode !== domainInputValue.toLowerCase() ? punycode : "";
}, [domainInputValue]);
let domainOptions: any = []; let domainOptions: any = [];
if (build == "enterprise" || build == "saas") { if (build == "enterprise" || build == "saas") {
@@ -182,10 +238,23 @@ export default function CreateDomainForm({
<FormLabel>{t("domain")}</FormLabel> <FormLabel>{t("domain")}</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="example.com" placeholder="example.com, café.com, 日本.com"
{...field} {...field}
/> />
</FormControl> </FormControl>
{punycodePreview && (
<FormDescription className="flex items-center gap-2 text-xs">
<Alert>
<Globe className="h-4 w-4" />
<AlertTitle>{t("internationaldomaindetected")}</AlertTitle>
<AlertDescription>
<div className="mt-2 space-y-1">
<p>{t("willbestoredas")} <code className="font-mono px-1 py-0.5 rounded">{punycodePreview}</code></p>
</div>
</AlertDescription>
</Alert>
</FormDescription>
)}
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -206,66 +275,73 @@ export default function CreateDomainForm({
<div className="space-y-4"> <div className="space-y-4">
{createdDomain.nsRecords && {createdDomain.nsRecords &&
createdDomain.nsRecords.length > 0 && ( createdDomain.nsRecords.length > 0 && (
<div> <div>
<h3 className="font-medium mb-3"> <h3 className="font-medium mb-3">
{t("createDomainNsRecords")} {t("createDomainNsRecords")}
</h3> </h3>
<InfoSections cols={1}> <InfoSections cols={1}>
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("createDomainRecord")} {t("createDomainRecord")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{t( {t(
"createDomainType" "createDomainType"
)} )}
</span> </span>
<span className="text-sm font-mono"> <span className="text-sm font-mono">
NS NS
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{t( {t(
"createDomainName" "createDomainName"
)} )}
</span> </span>
<span className="text-sm font-mono"> <div className="text-right">
{baseDomain} <span className="text-sm font-mono block">
</span> {fromPunycode(baseDomain)}
</div> </span>
<span className="text-sm font-medium"> {fromPunycode(baseDomain) !== baseDomain && (
{t( <span className="text-xs text-muted-foreground font-mono">
"createDomainValue" ({baseDomain})
)} </span>
</span> )}
{createdDomain.nsRecords.map(
(
nsRecord,
index
) => (
<div
className="flex justify-between items-center"
key={index}
>
<CopyToClipboard
text={
nsRecord
}
/>
</div> </div>
) </div>
)} <span className="text-sm font-medium">
</div> {t(
</InfoSectionContent> "createDomainValue"
</InfoSection> )}
</InfoSections> </span>
</div> {createdDomain.nsRecords.map(
)} (
nsRecord,
index
) => (
<div
className="flex justify-between items-center"
key={index}
>
<CopyToClipboard
text={
nsRecord
}
/>
</div>
)
)}
</div>
</InfoSectionContent>
</InfoSection>
</InfoSections>
</div>
)}
{createdDomain.cnameRecords && {createdDomain.cnameRecords &&
createdDomain.cnameRecords.length > 0 && ( createdDomain.cnameRecords.length > 0 && (
@@ -307,11 +383,16 @@ export default function CreateDomainForm({
"createDomainName" "createDomainName"
)} )}
</span> </span>
<span className="text-sm font-mono"> <div className="text-right">
{ <span className="text-sm font-mono block">
cnameRecord.baseDomain {fromPunycode(cnameRecord.baseDomain)}
} </span>
</span> {fromPunycode(cnameRecord.baseDomain) !== cnameRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({cnameRecord.baseDomain})
</span>
)}
</div>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
@@ -374,11 +455,16 @@ export default function CreateDomainForm({
"createDomainName" "createDomainName"
)} )}
</span> </span>
<span className="text-sm font-mono"> <div className="text-right">
{ <span className="text-sm font-mono block">
aRecord.baseDomain {fromPunycode(aRecord.baseDomain)}
} </span>
</span> {fromPunycode(aRecord.baseDomain) !== aRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({aRecord.baseDomain})
</span>
)}
</div>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
@@ -390,7 +476,7 @@ export default function CreateDomainForm({
{ {
aRecord.value aRecord.value
} }
</span> </span>
</div> </div>
</div> </div>
</InfoSectionContent> </InfoSectionContent>
@@ -440,11 +526,16 @@ export default function CreateDomainForm({
"createDomainName" "createDomainName"
)} )}
</span> </span>
<span className="text-sm font-mono"> <div className="text-right">
{ <span className="text-sm font-mono block">
txtRecord.baseDomain {fromPunycode(txtRecord.baseDomain)}
} </span>
</span> {fromPunycode(txtRecord.baseDomain) !== txtRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({txtRecord.baseDomain})
</span>
)}
</div>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
@@ -513,4 +604,4 @@ export default function CreateDomainForm({
</CredenzaContent> </CredenzaContent>
</Credenza> </Credenza>
); );
} }

View File

@@ -25,6 +25,7 @@ export function DomainsDataTable<TData, TValue>({
<DataTable <DataTable
columns={columns} columns={columns}
data={data} data={data}
persistPageSize="domains-table"
title={t("domains")} title={t("domains")}
searchPlaceholder={t("domainsSearch")} searchPlaceholder={t("domainsSearch")}
searchColumn="baseDomain" searchColumn="baseDomain"

View File

@@ -9,6 +9,7 @@ import { GetOrgResponse } from "@server/routers/org";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import { ListDomainsResponse } from "@server/routers/domain"; import { ListDomainsResponse } from "@server/routers/domain";
import { toUnicode } from 'punycode';
type Props = { type Props = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@@ -22,7 +23,13 @@ export default async function DomainsPage(props: Props) {
const res = await internal.get< const res = await internal.get<
AxiosResponse<ListDomainsResponse> AxiosResponse<ListDomainsResponse>
>(`/org/${params.orgId}/domains`, await authCookieHeader()); >(`/org/${params.orgId}/domains`, await authCookieHeader());
domains = res.data.data.domains as DomainRow[];
const rawDomains = res.data.data.domains as DomainRow[];
domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain),
}));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }

View File

@@ -99,6 +99,43 @@ type ResourcesTableProps = {
defaultView?: "proxy" | "internal"; defaultView?: "proxy" | "internal";
}; };
const STORAGE_KEYS = {
PAGE_SIZE: 'datatable-page-size',
getTablePageSize: (tableId?: string) =>
tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE
};
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
if (typeof window === 'undefined') return defaultSize;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
const stored = localStorage.getItem(key);
if (stored) {
const parsed = parseInt(stored, 10);
if (parsed > 0 && parsed <= 1000) {
return parsed;
}
}
} catch (error) {
console.warn('Failed to read page size from localStorage:', error);
}
return defaultSize;
};
const setStoredPageSize = (pageSize: number, tableId?: string): void => {
if (typeof window === 'undefined') return;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
localStorage.setItem(key, pageSize.toString());
} catch (error) {
console.warn('Failed to save page size to localStorage:', error);
}
};
export default function ResourcesTable({ export default function ResourcesTable({
resources, resources,
internalResources, internalResources,
@@ -113,6 +150,13 @@ export default function ResourcesTable({
const api = createApiClient({ env }); const api = createApiClient({ env });
const [proxyPageSize, setProxyPageSize] = useState<number>(() =>
getStoredPageSize('proxy-resources', 20)
);
const [internalPageSize, setInternalPageSize] = useState<number>(() =>
getStoredPageSize('internal-resources', 20)
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] = const [selectedResource, setSelectedResource] =
useState<ResourceRow | null>(); useState<ResourceRow | null>();
@@ -559,7 +603,7 @@ export default function ResourcesTable({
onGlobalFilterChange: setProxyGlobalFilter, onGlobalFilterChange: setProxyGlobalFilter,
initialState: { initialState: {
pagination: { pagination: {
pageSize: 20, pageSize: proxyPageSize,
pageIndex: 0 pageIndex: 0
} }
}, },
@@ -582,7 +626,7 @@ export default function ResourcesTable({
onGlobalFilterChange: setInternalGlobalFilter, onGlobalFilterChange: setInternalGlobalFilter,
initialState: { initialState: {
pagination: { pagination: {
pageSize: 20, pageSize: internalPageSize,
pageIndex: 0 pageIndex: 0
} }
}, },
@@ -593,6 +637,16 @@ export default function ResourcesTable({
} }
}); });
const handleProxyPageSizeChange = (newPageSize: number) => {
setProxyPageSize(newPageSize);
setStoredPageSize(newPageSize, 'proxy-resources');
};
const handleInternalPageSizeChange = (newPageSize: number) => {
setInternalPageSize(newPageSize);
setStoredPageSize(newPageSize, 'internal-resources');
};
return ( return (
<> <>
{selectedResource && ( {selectedResource && (
@@ -761,7 +815,10 @@ export default function ResourcesTable({
</TableBody> </TableBody>
</Table> </Table>
<div className="mt-4"> <div className="mt-4">
<DataTablePagination table={proxyTable} /> <DataTablePagination
table={proxyTable}
onPageSizeChange={handleProxyPageSizeChange}
/>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="internal"> <TabsContent value="internal">
@@ -861,6 +918,7 @@ export default function ResourcesTable({
<div className="mt-4"> <div className="mt-4">
<DataTablePagination <DataTablePagination
table={internalTable} table={internalTable}
onPageSizeChange={handleInternalPageSizeChange}
/> />
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -9,6 +9,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@/components/ui/select"; } from "@/components/ui/select";
import { toUnicode } from "punycode";
interface DomainOption { interface DomainOption {
baseDomain: string; baseDomain: string;
@@ -91,7 +92,7 @@ export default function CustomDomainInput({
key={option.domainId} key={option.domainId}
value={option.domainId} value={option.domainId}
> >
.{option.baseDomain} .{toUnicode(option.baseDomain)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

View File

@@ -12,15 +12,19 @@ import {
} from "@app/components/InfoSection"; } from "@app/components/InfoSection";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { build } from "@server/build"; import { build } from "@server/build";
import { toUnicode } from 'punycode';
type ResourceInfoBoxType = {}; type ResourceInfoBoxType = {};
export default function ResourceInfoBox({}: ResourceInfoBoxType) { export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
const { resource, authInfo } = useResourceContext(); const { resource, authInfo } = useResourceContext();
const t = useTranslations(); const t = useTranslations();
const fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
return ( return (
<Alert> <Alert>
@@ -34,9 +38,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{authInfo.password || {authInfo.password ||
authInfo.pincode || authInfo.pincode ||
authInfo.sso || authInfo.sso ||
authInfo.whitelist ? ( authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500"> <div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" /> <ShieldCheck className="w-4 h-4 mt-0.5" />
<span>{t("protected")}</span> <span>{t("protected")}</span>

View File

@@ -53,6 +53,9 @@ import {
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { Globe } from "lucide-react"; import { Globe } from "lucide-react";
import { build } from "@server/build"; import { build } from "@server/build";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { DomainRow } from "../../../domains/DomainsTable";
import { toASCII, toUnicode } from "punycode";
export default function GeneralForm() { export default function GeneralForm() {
const [formKey, setFormKey] = useState(0); const [formKey, setFormKey] = useState(0);
@@ -79,12 +82,13 @@ export default function GeneralForm() {
const [loadingPage, setLoadingPage] = useState(true); const [loadingPage, setLoadingPage] = useState(true);
const [resourceFullDomain, setResourceFullDomain] = useState( const [resourceFullDomain, setResourceFullDomain] = useState(
`${resource.ssl ? "https" : "http"}://${resource.fullDomain}` `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
); );
const [selectedDomain, setSelectedDomain] = useState<{ const [selectedDomain, setSelectedDomain] = useState<{
domainId: string; domainId: string;
subdomain?: string; subdomain?: string;
fullDomain: string; fullDomain: string;
baseDomain: string;
} | null>(null); } | null>(null);
const GeneralFormSchema = z const GeneralFormSchema = z
@@ -153,7 +157,11 @@ export default function GeneralForm() {
}); });
if (res?.status === 200) { if (res?.status === 200) {
const domains = res.data.data.domains; const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain),
}));
setBaseDomains(domains); setBaseDomains(domains);
setFormKey((key) => key + 1); setFormKey((key) => key + 1);
} }
@@ -178,7 +186,7 @@ export default function GeneralForm() {
{ {
enabled: data.enabled, enabled: data.enabled,
name: data.name, name: data.name,
subdomain: data.subdomain, subdomain: data.subdomain ? toASCII(data.subdomain) : undefined,
domainId: data.domainId, domainId: data.domainId,
proxyPort: data.proxyPort, proxyPort: data.proxyPort,
// ...(!resource.http && { // ...(!resource.http && {
@@ -317,10 +325,10 @@ export default function GeneralForm() {
.target .target
.value .value
? parseInt( ? parseInt(
e e
.target .target
.value .value
) )
: undefined : undefined
) )
} }
@@ -441,7 +449,8 @@ export default function GeneralForm() {
const selected = { const selected = {
domainId: res.domainId, domainId: res.domainId,
subdomain: res.subdomain, subdomain: res.subdomain,
fullDomain: res.fullDomain fullDomain: res.fullDomain,
baseDomain: res.baseDomain
}; };
setSelectedDomain(selected); setSelectedDomain(selected);
}} }}
@@ -454,18 +463,23 @@ export default function GeneralForm() {
<Button <Button
onClick={() => { onClick={() => {
if (selectedDomain) { if (selectedDomain) {
setResourceFullDomain( const sanitizedSubdomain = selectedDomain.subdomain
selectedDomain.fullDomain ? finalizeSubdomainSanitize(selectedDomain.subdomain)
); : "";
form.setValue(
"domainId", const sanitizedFullDomain = sanitizedSubdomain
selectedDomain.domainId ? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
); : selectedDomain.baseDomain;
form.setValue(
"subdomain", setResourceFullDomain(sanitizedFullDomain);
selectedDomain.subdomain form.setValue("domainId", selectedDomain.domainId);
); form.setValue("subdomain", sanitizedSubdomain);
setEditDomainOpen(false); setEditDomainOpen(false);
toast({
description: `Final domain: ${sanitizedFullDomain}`,
});
} }
}} }}
> >

View File

@@ -94,6 +94,7 @@ import {
CommandList CommandList
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import { parseHostTarget } from "@app/lib/parseHostTarget";
const addTargetSchema = z.object({ const addTargetSchema = z.object({
ip: z.string().refine(isTargetValid), ip: z.string().refine(isTargetValid),
@@ -417,11 +418,11 @@ export default function ReverseProxyTargets(props: {
targets.map((target) => targets.map((target) =>
target.targetId === targetId target.targetId === targetId
? { ? {
...target, ...target,
...data, ...data,
updated: true, updated: true,
siteType: site?.type || null siteType: site?.type || null
} }
: target : target
) )
); );
@@ -545,7 +546,7 @@ export default function ReverseProxyTargets(props: {
className={cn( className={cn(
"justify-between flex-1", "justify-between flex-1",
!row.original.siteId && !row.original.siteId &&
"text-muted-foreground" "text-muted-foreground"
)} )}
> >
{row.original.siteId {row.original.siteId
@@ -614,31 +615,31 @@ export default function ReverseProxyTargets(props: {
}, },
...(resource.http ...(resource.http
? [ ? [
{ {
accessorKey: "method", accessorKey: "method",
header: t("method"), header: t("method"),
cell: ({ row }: { row: Row<LocalTarget> }) => ( cell: ({ row }: { row: Row<LocalTarget> }) => (
<Select <Select
defaultValue={row.original.method ?? ""} defaultValue={row.original.method ?? ""}
onValueChange={(value) => onValueChange={(value) =>
updateTarget(row.original.targetId, { updateTarget(row.original.targetId, {
...row.original, ...row.original,
method: value method: value
}) })
} }
> >
<SelectTrigger> <SelectTrigger>
{row.original.method} {row.original.method}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="http">http</SelectItem> <SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem> <SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem> <SelectItem value="h2c">h2c</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
) )
} }
] ]
: []), : []),
{ {
accessorKey: "ip", accessorKey: "ip",
@@ -647,12 +648,33 @@ export default function ReverseProxyTargets(props: {
<Input <Input
defaultValue={row.original.ip} defaultValue={row.original.ip}
className="min-w-[150px]" className="min-w-[150px]"
onBlur={(e) => onBlur={(e) => {
updateTarget(row.original.targetId, { const input = e.target.value.trim();
...row.original, const hasProtocol = /^(https?|h2c):\/\//.test(input);
ip: e.target.value const hasPort = /:\d+(?:\/|$)/.test(input);
})
} if (hasProtocol || hasPort) {
const parsed = parseHostTarget(input);
if (parsed) {
updateTarget(row.original.targetId, {
...row.original,
method: hasProtocol ? parsed.protocol : row.original.method,
ip: parsed.host,
port: hasPort ? parsed.port : row.original.port
});
} else {
updateTarget(row.original.targetId, {
...row.original,
ip: input
});
}
} else {
updateTarget(row.original.targetId, {
...row.original,
ip: input
});
}
}}
/> />
) )
}, },
@@ -785,21 +807,21 @@ export default function ReverseProxyTargets(props: {
className={cn( className={cn(
"justify-between flex-1", "justify-between flex-1",
!field.value && !field.value &&
"text-muted-foreground" "text-muted-foreground"
)} )}
> >
{field.value {field.value
? sites.find( ? sites.find(
( (
site site
) => ) =>
site.siteId === site.siteId ===
field.value field.value
) )
?.name ?.name
: t( : t(
"siteSelect" "siteSelect"
)} )}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
@@ -865,18 +887,18 @@ export default function ReverseProxyTargets(props: {
); );
return selectedSite && return selectedSite &&
selectedSite.type === selectedSite.type ===
"newt" ? (() => { "newt" ? (() => {
const dockerState = getDockerStateForSite(selectedSite.siteId); const dockerState = getDockerStateForSite(selectedSite.siteId);
return ( return (
<ContainersSelector <ContainersSelector
site={selectedSite} site={selectedSite}
containers={dockerState.containers} containers={dockerState.containers}
isAvailable={dockerState.isAvailable} isAvailable={dockerState.isAvailable}
onContainerSelect={handleContainerSelect} onContainerSelect={handleContainerSelect}
onRefresh={() => refreshContainersForSite(selectedSite.siteId)} onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
/> />
); );
})() : null; })() : null;
})()} })()}
</div> </div>
<FormMessage /> <FormMessage />
@@ -942,11 +964,32 @@ export default function ReverseProxyTargets(props: {
name="ip" name="ip"
render={({ field }) => ( render={({ field }) => (
<FormItem className="relative"> <FormItem className="relative">
<FormLabel> <FormLabel>{t("targetAddr")}</FormLabel>
{t("targetAddr")}
</FormLabel>
<FormControl> <FormControl>
<Input id="ip" {...field} /> <Input
id="ip"
{...field}
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol = /^(https?|h2c):\/\//.test(input);
const hasPort = /:\d+(?:\/|$)/.test(input);
if (hasProtocol || hasPort) {
const parsed = parseHostTarget(input);
if (parsed) {
if (hasProtocol || !addTargetForm.getValues("method")) {
addTargetForm.setValue("method", parsed.protocol);
}
addTargetForm.setValue("ip", parsed.host);
if (hasPort || !addTargetForm.getValues("port")) {
addTargetForm.setValue("port", parsed.port);
}
}
} else {
field.onBlur();
}
}}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1048,12 +1091,12 @@ export default function ReverseProxyTargets(props: {
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header header
.column .column
.columnDef .columnDef
.header, .header,
header.getContext() header.getContext()
)} )}
</TableHead> </TableHead>
) )
)} )}

View File

@@ -76,7 +76,7 @@ import { useTranslations } from "next-intl";
// Schema for rule validation // Schema for rule validation
const addRuleSchema = z.object({ const addRuleSchema = z.object({
action: z.string(), action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.string(), match: z.string(),
value: z.string(), value: z.string(),
priority: z.coerce.number().int().optional() priority: z.coerce.number().int().optional()
@@ -104,7 +104,8 @@ export default function ResourceRules(props: {
const RuleAction = { const RuleAction = {
ACCEPT: t('alwaysAllow'), ACCEPT: t('alwaysAllow'),
DROP: t('alwaysDeny') DROP: t('alwaysDeny'),
PASS: t('passToAuth')
} as const; } as const;
const RuleMatch = { const RuleMatch = {
@@ -113,7 +114,7 @@ export default function ResourceRules(props: {
CIDR: t('ipAddressRange') CIDR: t('ipAddressRange')
} as const; } as const;
const addRuleForm = useForm({ const addRuleForm = useForm<z.infer<typeof addRuleSchema>>({
resolver: zodResolver(addRuleSchema), resolver: zodResolver(addRuleSchema),
defaultValues: { defaultValues: {
action: "ACCEPT", action: "ACCEPT",
@@ -437,7 +438,7 @@ export default function ResourceRules(props: {
cell: ({ row }) => ( cell: ({ row }) => (
<Select <Select
defaultValue={row.original.action} defaultValue={row.original.action}
onValueChange={(value: "ACCEPT" | "DROP") => onValueChange={(value: "ACCEPT" | "DROP" | "PASS") =>
updateRule(row.original.ruleId, { action: value }) updateRule(row.original.ruleId, { action: value })
} }
> >
@@ -449,6 +450,7 @@ export default function ResourceRules(props: {
{RuleAction.ACCEPT} {RuleAction.ACCEPT}
</SelectItem> </SelectItem>
<SelectItem value="DROP">{RuleAction.DROP}</SelectItem> <SelectItem value="DROP">{RuleAction.DROP}</SelectItem>
<SelectItem value="PASS">{RuleAction.PASS}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
) )
@@ -629,6 +631,9 @@ export default function ResourceRules(props: {
<SelectItem value="DROP"> <SelectItem value="DROP">
{RuleAction.DROP} {RuleAction.DROP}
</SelectItem> </SelectItem>
<SelectItem value="PASS">
{RuleAction.PASS}
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>

View File

@@ -88,6 +88,9 @@ import { ArrayElement } from "@server/types/ArrayElement";
import { isTargetValid } from "@server/lib/validators"; import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target"; import { ListTargetsResponse } from "@server/routers/target";
import { DockerManager, DockerState } from "@app/lib/docker"; import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { toASCII, toUnicode } from 'punycode';
import { DomainRow } from "../../domains/DomainsTable";
const baseResourceFormSchema = z.object({ const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
@@ -164,12 +167,12 @@ export default function Page() {
...(!env.flags.allowRawResources ...(!env.flags.allowRawResources
? [] ? []
: [ : [
{ {
id: "raw" as ResourceType, id: "raw" as ResourceType,
title: t("resourceRaw"), title: t("resourceRaw"),
description: t("resourceRawDescription") description: t("resourceRawDescription")
} }
]) ])
]; ];
const baseForm = useForm<BaseResourceFormValues>({ const baseForm = useForm<BaseResourceFormValues>({
@@ -301,11 +304,11 @@ export default function Page() {
targets.map((target) => targets.map((target) =>
target.targetId === targetId target.targetId === targetId
? { ? {
...target, ...target,
...data, ...data,
updated: true, updated: true,
siteType: site?.type || null siteType: site?.type || null
} }
: target : target
) )
); );
@@ -326,7 +329,7 @@ export default function Page() {
if (isHttp) { if (isHttp) {
const httpData = httpForm.getValues(); const httpData = httpForm.getValues();
Object.assign(payload, { Object.assign(payload, {
subdomain: httpData.subdomain, subdomain: httpData.subdomain ? toASCII(httpData.subdomain) : undefined,
domainId: httpData.domainId, domainId: httpData.domainId,
protocol: "tcp" protocol: "tcp"
}); });
@@ -468,7 +471,11 @@ export default function Page() {
}); });
if (res?.status === 200) { if (res?.status === 200) {
const domains = res.data.data.domains; const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain),
}));
setBaseDomains(domains); setBaseDomains(domains);
// if (domains.length) { // if (domains.length) {
// httpForm.setValue("domainId", domains[0].domainId); // httpForm.setValue("domainId", domains[0].domainId);
@@ -520,7 +527,7 @@ export default function Page() {
className={cn( className={cn(
"justify-between flex-1", "justify-between flex-1",
!row.original.siteId && !row.original.siteId &&
"text-muted-foreground" "text-muted-foreground"
)} )}
> >
{row.original.siteId {row.original.siteId
@@ -589,31 +596,31 @@ export default function Page() {
}, },
...(baseForm.watch("http") ...(baseForm.watch("http")
? [ ? [
{ {
accessorKey: "method", accessorKey: "method",
header: t("method"), header: t("method"),
cell: ({ row }: { row: Row<LocalTarget> }) => ( cell: ({ row }: { row: Row<LocalTarget> }) => (
<Select <Select
defaultValue={row.original.method ?? ""} defaultValue={row.original.method ?? ""}
onValueChange={(value) => onValueChange={(value) =>
updateTarget(row.original.targetId, { updateTarget(row.original.targetId, {
...row.original, ...row.original,
method: value method: value
}) })
} }
> >
<SelectTrigger> <SelectTrigger>
{row.original.method} {row.original.method}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="http">http</SelectItem> <SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem> <SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem> <SelectItem value="h2c">h2c</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
) )
} }
] ]
: []), : []),
{ {
accessorKey: "ip", accessorKey: "ip",
@@ -622,12 +629,23 @@ export default function Page() {
<Input <Input
defaultValue={row.original.ip} defaultValue={row.original.ip}
className="min-w-[150px]" className="min-w-[150px]"
onBlur={(e) => onBlur={(e) => {
updateTarget(row.original.targetId, { const parsed = parseHostTarget(e.target.value);
...row.original,
ip: e.target.value if (parsed) {
}) updateTarget(row.original.targetId, {
} ...row.original,
method: parsed.protocol,
ip: parsed.host,
port: parsed.port ? Number(parsed.port) : undefined,
});
} else {
updateTarget(row.original.targetId, {
...row.original,
ip: e.target.value,
});
}
}}
/> />
) )
}, },
@@ -909,10 +927,10 @@ export default function Page() {
.target .target
.value .value
? parseInt( ? parseInt(
e e
.target .target
.value .value
) )
: undefined : undefined
) )
} }
@@ -1015,21 +1033,21 @@ export default function Page() {
className={cn( className={cn(
"justify-between flex-1", "justify-between flex-1",
!field.value && !field.value &&
"text-muted-foreground" "text-muted-foreground"
)} )}
> >
{field.value {field.value
? sites.find( ? sites.find(
( (
site site
) => ) =>
site.siteId === site.siteId ===
field.value field.value
) )
?.name ?.name
: t( : t(
"siteSelect" "siteSelect"
)} )}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
@@ -1097,18 +1115,18 @@ export default function Page() {
); );
return selectedSite && return selectedSite &&
selectedSite.type === selectedSite.type ===
"newt" ? (() => { "newt" ? (() => {
const dockerState = getDockerStateForSite(selectedSite.siteId); const dockerState = getDockerStateForSite(selectedSite.siteId);
return ( return (
<ContainersSelector <ContainersSelector
site={selectedSite} site={selectedSite}
containers={dockerState.containers} containers={dockerState.containers}
isAvailable={dockerState.isAvailable} isAvailable={dockerState.isAvailable}
onContainerSelect={handleContainerSelect} onContainerSelect={handleContainerSelect}
onRefresh={() => refreshContainersForSite(selectedSite.siteId)} onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
/> />
); );
})() : null; })() : null;
})()} })()}
</div> </div>
<FormMessage /> <FormMessage />
@@ -1176,21 +1194,25 @@ export default function Page() {
)} )}
<FormField <FormField
control={ control={addTargetForm.control}
addTargetForm.control
}
name="ip" name="ip"
render={({ field }) => ( render={({ field }) => (
<FormItem className="relative"> <FormItem className="relative">
<FormLabel> <FormLabel>{t("targetAddr")}</FormLabel>
{t(
"targetAddr"
)}
</FormLabel>
<FormControl> <FormControl>
<Input <Input
id="ip" id="ip"
{...field} {...field}
onBlur={(e) => {
const parsed = parseHostTarget(e.target.value);
if (parsed) {
addTargetForm.setValue("method", parsed.protocol);
addTargetForm.setValue("ip", parsed.host);
addTargetForm.setValue("port", parsed.port);
} else {
field.onBlur();
}
}}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -1270,12 +1292,12 @@ export default function Page() {
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header header
.column .column
.columnDef .columnDef
.header, .header,
header.getContext() header.getContext()
)} )}
</TableHead> </TableHead>
) )
)} )}

View File

@@ -14,6 +14,7 @@ import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { toUnicode } from "punycode";
type ResourcesPageProps = { type ResourcesPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@@ -75,7 +76,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
id: resource.resourceId, id: resource.resourceId,
name: resource.name, name: resource.name,
orgId: params.orgId, orgId: params.orgId,
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
protocol: resource.protocol, protocol: resource.protocol,
proxyPort: resource.proxyPort, proxyPort: resource.proxyPort,
http: resource.http, http: resource.http,

View File

@@ -67,6 +67,7 @@ import {
} from "@app/components/ui/collapsible"; } from "@app/components/ui/collapsible";
import AccessTokenSection from "./AccessTokenUsage"; import AccessTokenSection from "./AccessTokenUsage";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toUnicode } from 'punycode';
type FormProps = { type FormProps = {
open: boolean; open: boolean;
@@ -159,7 +160,7 @@ export default function CreateShareLinkForm({
.map((r) => ({ .map((r) => ({
resourceId: r.resourceId, resourceId: r.resourceId,
name: r.name, name: r.name,
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/` resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
})) }))
); );
} }

View File

@@ -24,6 +24,7 @@ export function ShareLinksDataTable<TData, TValue>({
<DataTable <DataTable
columns={columns} columns={columns}
data={data} data={data}
persistPageSize="shareLinks-table"
title={t('shareLinks')} title={t('shareLinks')}
searchPlaceholder={t('shareSearch')} searchPlaceholder={t('shareSearch')}
searchColumn="name" searchColumn="name"

View File

@@ -26,6 +26,7 @@ export function SitesDataTable<TData, TValue>({
<DataTable <DataTable
columns={columns} columns={columns}
data={data} data={data}
persistPageSize="sites-table"
title={t('sites')} title={t('sites')}
searchPlaceholder={t('searchSitesProgress')} searchPlaceholder={t('searchSitesProgress')}
searchColumn="name" searchColumn="name"

View File

@@ -47,6 +47,7 @@ export function ApiKeysDataTable<TData, TValue>({
<DataTable <DataTable
columns={columns} columns={columns}
data={data} data={data}
persistPageSize="apiKeys-table"
title={t('apiKeys')} title={t('apiKeys')}
searchPlaceholder={t('searchApiKeys')} searchPlaceholder={t('searchApiKeys')}
searchColumn="name" searchColumn="name"

View File

@@ -21,6 +21,7 @@ export function IdpDataTable<TData, TValue>({
<DataTable <DataTable
columns={columns} columns={columns}
data={data} data={data}
persistPageSize="idp-table"
title={t('idp')} title={t('idp')}
searchPlaceholder={t('idpSearch')} searchPlaceholder={t('idpSearch')}
searchColumn="name" searchColumn="name"

View File

@@ -22,6 +22,7 @@ export function PolicyDataTable<TData, TValue>({
<DataTable <DataTable
columns={columns} columns={columns}
data={data} data={data}
persistPageSize="orgPolicies-table"
title={t('orgPolicies')} title={t('orgPolicies')}
searchPlaceholder={t('orgPoliciesSearch')} searchPlaceholder={t('orgPoliciesSearch')}
searchColumn="orgId" searchColumn="orgId"

View File

@@ -136,6 +136,7 @@ export function LicenseKeysDataTable({
<DataTable <DataTable
columns={columns} columns={columns}
data={licenseKeys} data={licenseKeys}
persistPageSize="licenseKeys-table"
title={t('licenseKeys')} title={t('licenseKeys')}
searchPlaceholder={t('licenseKeySearch')} searchPlaceholder={t('licenseKeySearch')}
searchColumn="licenseKey" searchColumn="licenseKey"

View File

@@ -1,3 +1,5 @@
"use client";
import { import {
SettingsContainer, SettingsContainer,
SettingsSection, SettingsSection,
@@ -18,30 +20,27 @@ import {
ExternalLink ExternalLink
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl";
export default function ManagedPage() {
const t = useTranslations();
export default async function ManagedPage() {
return ( return (
<> <>
<SettingsSectionTitle <SettingsSectionTitle
title="Managed Self-Hosted" title={t("managedSelfHosted.title")}
description="More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles" description={t("managedSelfHosted.description")}
/> />
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection>
<SettingsSectionBody> <SettingsSectionBody>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
<strong>Managed Self-Hosted Pangolin</strong> is a <strong>{t("managedSelfHosted.introTitle")}</strong>{" "}
deployment option designed for people who want {t("managedSelfHosted.introDescription")}
simplicity and extra reliability while still keeping
their data private and self-hosted.
</p> </p>
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
With this option, you still run your own Pangolin {t("managedSelfHosted.introDetail")}
node your tunnels, SSL termination, and traffic
all stay on your server. The difference is that
management and monitoring are handled through our
cloud dashboard, which unlocks a number of benefits:
</p> </p>
<div className="grid gap-4 md:grid-cols-2 py-4"> <div className="grid gap-4 md:grid-cols-2 py-4">
@@ -50,13 +49,14 @@ export default async function ManagedPage() {
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" /> <CheckCircle className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
<div> <div>
<h4 className="font-medium"> <h4 className="font-medium">
Simpler operations {t(
"managedSelfHosted.benefitSimplerOperations.title"
)}
</h4> </h4>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No need to run your own mail server {t(
or set up complex alerting. You'll "managedSelfHosted.benefitSimplerOperations.description"
get health checks and downtime )}
alerts out of the box.
</p> </p>
</div> </div>
</div> </div>
@@ -65,13 +65,14 @@ export default async function ManagedPage() {
<RefreshCw className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" /> <RefreshCw className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div> <div>
<h4 className="font-medium"> <h4 className="font-medium">
Automatic updates {t(
"managedSelfHosted.benefitAutomaticUpdates.title"
)}
</h4> </h4>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
The cloud dashboard evolves quickly, {t(
so you get new features and bug "managedSelfHosted.benefitAutomaticUpdates.description"
fixes without having to manually )}
pull new containers every time.
</p> </p>
</div> </div>
</div> </div>
@@ -80,12 +81,14 @@ export default async function ManagedPage() {
<Wrench className="w-5 h-5 text-orange-500 mt-0.5 flex-shrink-0" /> <Wrench className="w-5 h-5 text-orange-500 mt-0.5 flex-shrink-0" />
<div> <div>
<h4 className="font-medium"> <h4 className="font-medium">
Less maintenance {t(
"managedSelfHosted.benefitLessMaintenance.title"
)}
</h4> </h4>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No database migrations, backups, or {t(
extra infrastructure to manage. We "managedSelfHosted.benefitLessMaintenance.description"
handle that in the cloud. )}
</p> </p>
</div> </div>
</div> </div>
@@ -96,13 +99,14 @@ export default async function ManagedPage() {
<Activity className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" /> <Activity className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
<div> <div>
<h4 className="font-medium"> <h4 className="font-medium">
Cloud failover {t(
"managedSelfHosted.benefitCloudFailover.title"
)}
</h4> </h4>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
If your node goes down, your tunnels {t(
can temporarily fail over to our "managedSelfHosted.benefitCloudFailover.description"
cloud points of presence until you )}
bring it back online.
</p> </p>
</div> </div>
</div> </div>
@@ -110,12 +114,14 @@ export default async function ManagedPage() {
<Shield className="w-5 h-5 text-indigo-500 mt-0.5 flex-shrink-0" /> <Shield className="w-5 h-5 text-indigo-500 mt-0.5 flex-shrink-0" />
<div> <div>
<h4 className="font-medium"> <h4 className="font-medium">
High availability (PoPs) {t(
"managedSelfHosted.benefitHighAvailability.title"
)}
</h4> </h4>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
You can also attach multiple nodes {t(
to your account for redundancy and "managedSelfHosted.benefitHighAvailability.description"
better performance. )}
</p> </p>
</div> </div>
</div> </div>
@@ -124,13 +130,14 @@ export default async function ManagedPage() {
<Zap className="w-5 h-5 text-yellow-500 mt-0.5 flex-shrink-0" /> <Zap className="w-5 h-5 text-yellow-500 mt-0.5 flex-shrink-0" />
<div> <div>
<h4 className="font-medium"> <h4 className="font-medium">
Future enhancements {t(
"managedSelfHosted.benefitFutureEnhancements.title"
)}
</h4> </h4>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
We're planning to add more {t(
analytics, alerting, and management "managedSelfHosted.benefitFutureEnhancements.description"
tools to make your deployment even )}
more robust.
</p> </p>
</div> </div>
</div> </div>
@@ -141,15 +148,14 @@ export default async function ManagedPage() {
variant="neutral" variant="neutral"
className="flex items-center gap-1" className="flex items-center gap-1"
> >
Read the docs to learn more about the Managed {t("managedSelfHosted.docsAlert.text")}{" "}
Self-Hosted option in our{" "}
<Link <Link
href="https://docs.digpangolin.com/self-host/advanced/convert-to-managed" href="https://docs.digpangolin.com/manage/managed"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:underline text-primary flex items-center gap-1" className="hover:underline text-primary flex items-center gap-1"
> >
documentation {t("managedSelfHosted.docsAlert.documentation")}
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
</Link> </Link>
. .
@@ -157,13 +163,13 @@ export default async function ManagedPage() {
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>
<Link <Link
href="https://docs.digpangolin.com/self-host/advanced/convert-to-managed" href="https://docs.digpangolin.com/self-host/convert-managed"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:underline text-primary flex items-center gap-1" className="hover:underline text-primary flex items-center gap-1"
> >
<Button> <Button>
Convert This Node to Managed Self-Hosted {t("managedSelfHosted.convertButton")}
</Button> </Button>
</Link> </Link>
</SettingsSectionFooter> </SettingsSectionFooter>

View File

@@ -22,6 +22,7 @@ export function UsersDataTable<TData, TValue>({
<DataTable <DataTable
columns={columns} columns={columns}
data={data} data={data}
persistPageSize="userServer-table"
title={t('userServer')} title={t('userServer')}
searchPlaceholder={t('userSearch')} searchPlaceholder={t('userSearch')}
searchColumn="email" searchColumn="email"

View File

@@ -48,6 +48,7 @@ export default async function InvitePage(props: {
) )
.catch((e) => { .catch((e) => {
error = formatAxiosError(e); error = formatAxiosError(e);
console.error(error);
}); });
if (res && res.status === 200) { if (res && res.status === 200) {
@@ -55,13 +56,13 @@ export default async function InvitePage(props: {
} }
function cardType() { function cardType() {
if (error.includes(t('inviteErrorWrongUser'))) { if (error.includes("Invite is not for this user")) {
return "wrong_user"; return "wrong_user";
} else if ( } else if (
error.includes(t('inviteErrorUserNotExists')) error.includes("User does not exist. Please create an account first.")
) { ) {
return "user_does_not_exist"; return "user_does_not_exist";
} else if (error.includes(t('inviteErrorLoginRequired'))) { } else if (error.includes("You must be logged in to accept an invite")) {
return "not_logged_in"; return "not_logged_in";
} else { } else {
return "rejected"; return "rejected";

View File

@@ -18,28 +18,38 @@ import { useTranslations } from "next-intl";
interface DataTablePaginationProps<TData> { interface DataTablePaginationProps<TData> {
table: Table<TData>; table: Table<TData>;
onPageSizeChange?: (pageSize: number) => void;
} }
export function DataTablePagination<TData>({ export function DataTablePagination<TData>({
table table,
onPageSizeChange
}: DataTablePaginationProps<TData>) { }: DataTablePaginationProps<TData>) {
const t = useTranslations(); const t = useTranslations();
const handlePageSizeChange = (value: string) => {
const newPageSize = Number(value);
table.setPageSize(newPageSize);
// Call the callback if provided (for persistence)
if (onPageSizeChange) {
onPageSizeChange(newPageSize);
}
};
return ( return (
<div className="flex items-center justify-between text-muted-foreground"> <div className="flex items-center justify-between text-muted-foreground">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Select <Select
value={`${table.getState().pagination.pageSize}`} value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => { onValueChange={handlePageSizeChange}
table.setPageSize(Number(value));
}}
> >
<SelectTrigger className="h-8 w-[70px]"> <SelectTrigger className="h-8 w-[70px]">
<SelectValue <SelectValue
placeholder={table.getState().pagination.pageSize} placeholder={table.getState().pagination.pageSize}
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent side="top"> <SelectContent side="bottom">
{[10, 20, 30, 40, 50, 100].map((pageSize) => ( {[10, 20, 30, 40, 50, 100].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}> <SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize} {pageSize}

View File

@@ -37,6 +37,13 @@ import { cn } from "@/lib/cn";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { build } from "@server/build"; import { build } from "@server/build";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
sanitizeInputRaw,
finalizeSubdomainSanitize,
validateByDomainType,
isValidSubdomainStructure
} from "@/lib/subdomain-utils";
import { toUnicode } from "punycode";
type OrganizationDomain = { type OrganizationDomain = {
domainId: string; domainId: string;
@@ -120,6 +127,7 @@ export default function DomainPicker2({
) )
.map((domain) => ({ .map((domain) => ({
...domain, ...domain,
baseDomain: toUnicode(domain.baseDomain),
type: domain.type as "ns" | "cname" | "wildcard" type: domain.type as "ns" | "cname" | "wildcard"
})); }));
setOrganizationDomains(domains); setOrganizationDomains(domains);
@@ -255,108 +263,64 @@ export default function DomainPicker2({
const dropdownOptions = generateDropdownOptions(); const dropdownOptions = generateDropdownOptions();
const validateSubdomain = ( const finalizeSubdomain = (sub: string, base: DomainOption): string => {
subdomain: string, const sanitized = finalizeSubdomainSanitize(sub);
baseDomain: DomainOption
): boolean => {
if (!baseDomain) return false;
if (baseDomain.type === "provided-search") { if (!sanitized) {
return /^[a-zA-Z0-9-]+$/.test(subdomain); toast({
variant: "destructive",
title: "Invalid subdomain",
description: `The input "${sub}" was removed because it's not valid.`,
});
return "";
} }
if (baseDomain.type === "organization") { const ok = validateByDomainType(sanitized, {
if (baseDomain.domainType === "cname") { type: base.type === "provided-search" ? "provided-search" : "organization",
return subdomain === ""; domainType: base.domainType
} else if (baseDomain.domainType === "ns") { });
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain);
} else if (baseDomain.domainType === "wildcard") { if (!ok) {
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain); toast({
} variant: "destructive",
title: "Invalid subdomain",
description: `"${sub}" could not be made valid for ${base.domain}.`,
});
return "";
} }
return false; if (sub !== sanitized) {
}; toast({
title: "Subdomain sanitized",
// Handle base domain selection description: `"${sub}" was corrected to "${sanitized}"`,
const handleBaseDomainSelect = (option: DomainOption) => {
setSelectedBaseDomain(option);
setOpen(false);
if (option.domainType === "cname") {
setSubdomainInput("");
}
if (option.type === "provided-search") {
setUserInput("");
setAvailableOptions([]);
setSelectedProvidedDomain(null);
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: option.domain,
baseDomain: option.domain
}); });
} }
if (option.type === "organization") { return sanitized;
if (option.domainType === "cname") {
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: option.domain,
baseDomain: option.domain
});
} else {
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: option.domain,
baseDomain: option.domain
});
}
}
}; };
const handleSubdomainChange = (value: string) => { const handleSubdomainChange = (value: string) => {
const validInput = value.replace(/[^a-zA-Z0-9.-]/g, ""); const raw = sanitizeInputRaw(value);
setSubdomainInput(validInput); setSubdomainInput(raw);
setSelectedProvidedDomain(null); setSelectedProvidedDomain(null);
if (selectedBaseDomain && selectedBaseDomain.type === "organization") { if (selectedBaseDomain?.type === "organization") {
const isValid = validateSubdomain(validInput, selectedBaseDomain); const fullDomain = raw
if (isValid) { ? `${raw}.${selectedBaseDomain.domain}`
const fullDomain = validInput : selectedBaseDomain.domain;
? `${validInput}.${selectedBaseDomain.domain}`
: selectedBaseDomain.domain; onDomainChange?.({
onDomainChange?.({ domainId: selectedBaseDomain.domainId!,
domainId: selectedBaseDomain.domainId!, type: "organization",
type: "organization", subdomain: raw || undefined,
subdomain: validInput || undefined, fullDomain,
fullDomain: fullDomain, baseDomain: selectedBaseDomain.domain
baseDomain: selectedBaseDomain.domain });
});
} else if (validInput === "") {
onDomainChange?.({
domainId: selectedBaseDomain.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: selectedBaseDomain.domain,
baseDomain: selectedBaseDomain.domain
});
}
} }
}; };
const handleProvidedDomainInputChange = (value: string) => { const handleProvidedDomainInputChange = (value: string) => {
const validInput = value.replace(/[^a-zA-Z0-9.-]/g, ""); setUserInput(value);
setUserInput(validInput);
// Clear selected domain when user types
if (selectedProvidedDomain) { if (selectedProvidedDomain) {
setSelectedProvidedDomain(null); setSelectedProvidedDomain(null);
onDomainChange?.({ onDomainChange?.({
@@ -369,6 +333,43 @@ export default function DomainPicker2({
} }
}; };
const handleBaseDomainSelect = (option: DomainOption) => {
let sub = subdomainInput;
if (sub && sub.trim() !== "") {
sub = finalizeSubdomain(sub, option) || "";
setSubdomainInput(sub);
} else {
sub = "";
setSubdomainInput("");
}
if (option.type === "provided-search") {
setUserInput("");
setAvailableOptions([]);
setSelectedProvidedDomain(null);
}
setSelectedBaseDomain(option);
setOpen(false);
if (option.domainType === "cname") {
sub = "";
setSubdomainInput("");
}
const fullDomain = sub ? `${sub}.${option.domain}` : option.domain;
onDomainChange?.({
domainId: option.domainId || "",
domainNamespaceId: option.domainNamespaceId,
type: option.type === "provided-search" ? "provided" : "organization",
subdomain: sub || undefined,
fullDomain,
baseDomain: option.domain
});
};
const handleProvidedDomainSelect = (option: AvailableOption) => { const handleProvidedDomainSelect = (option: AvailableOption) => {
setSelectedProvidedDomain(option); setSelectedProvidedDomain(option);
@@ -380,15 +381,19 @@ export default function DomainPicker2({
domainId: option.domainId, domainId: option.domainId,
domainNamespaceId: option.domainNamespaceId, domainNamespaceId: option.domainNamespaceId,
type: "provided", type: "provided",
subdomain: subdomain, subdomain,
fullDomain: option.fullDomain, fullDomain: option.fullDomain,
baseDomain: baseDomain baseDomain
}); });
}; };
const isSubdomainValid = selectedBaseDomain const isSubdomainValid = selectedBaseDomain && subdomainInput
? validateSubdomain(subdomainInput, selectedBaseDomain) ? validateByDomainType(subdomainInput, {
type: selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization",
domainType: selectedBaseDomain.domainType
})
: true; : true;
const showSubdomainInput = const showSubdomainInput =
selectedBaseDomain && selectedBaseDomain &&
selectedBaseDomain.type === "organization" && selectedBaseDomain.type === "organization" &&
@@ -396,7 +401,7 @@ export default function DomainPicker2({
const showProvidedDomainSearch = const showProvidedDomainSearch =
selectedBaseDomain?.type === "provided-search"; selectedBaseDomain?.type === "provided-search";
const sortedAvailableOptions = availableOptions.sort((a, b) => { const sortedAvailableOptions = [...availableOptions].sort((a, b) => {
const comparison = a.fullDomain.localeCompare(b.fullDomain); const comparison = a.fullDomain.localeCompare(b.fullDomain);
return sortOrder === "asc" ? comparison : -comparison; return sortOrder === "asc" ? comparison : -comparison;
}); });
@@ -408,6 +413,7 @@ export default function DomainPicker2({
const hasMoreProvided = const hasMoreProvided =
sortedAvailableOptions.length > providedDomainsShown; sortedAvailableOptions.length > providedDomainsShown;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -426,16 +432,16 @@ export default function DomainPicker2({
showProvidedDomainSearch showProvidedDomainSearch
? "" ? ""
: showSubdomainInput : showSubdomainInput
? "" ? ""
: t("domainPickerNotAvailableForCname") : t("domainPickerNotAvailableForCname")
} }
disabled={ disabled={
!showSubdomainInput && !showProvidedDomainSearch !showSubdomainInput && !showProvidedDomainSearch
} }
className={cn( className={cn(
!isSubdomainValid && !isSubdomainValid &&
subdomainInput && subdomainInput &&
"border-red-500" "border-red-500 focus:border-red-500"
)} )}
onChange={(e) => { onChange={(e) => {
if (showProvidedDomainSearch) { if (showProvidedDomainSearch) {
@@ -445,6 +451,11 @@ export default function DomainPicker2({
} }
}} }}
/> />
{showSubdomainInput && subdomainInput && !isValidSubdomainStructure(subdomainInput) && (
<p className="text-sm text-red-500">
This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.
</p>
)}
{showSubdomainInput && !subdomainInput && ( {showSubdomainInput && !subdomainInput && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("domainPickerEnterSubdomainOrLeaveBlank")} {t("domainPickerEnterSubdomainOrLeaveBlank")}
@@ -470,7 +481,7 @@ export default function DomainPicker2({
{selectedBaseDomain ? ( {selectedBaseDomain ? (
<div className="flex items-center space-x-2 min-w-0 flex-1"> <div className="flex items-center space-x-2 min-w-0 flex-1">
{selectedBaseDomain.type === {selectedBaseDomain.type ===
"organization" ? null : ( "organization" ? null : (
<Zap className="h-4 w-4 flex-shrink-0" /> <Zap className="h-4 w-4 flex-shrink-0" />
)} )}
<span className="truncate"> <span className="truncate">
@@ -564,67 +575,67 @@ export default function DomainPicker2({
</CommandGroup> </CommandGroup>
{(build === "saas" || {(build === "saas" ||
build === "enterprise") && ( build === "enterprise") && (
<CommandSeparator className="my-2" /> <CommandSeparator className="my-2" />
)} )}
</> </>
)} )}
{(build === "saas" || {(build === "saas" ||
build === "enterprise") && ( build === "enterprise") && (
<CommandGroup <CommandGroup
heading={ heading={
build === "enterprise" build === "enterprise"
? t( ? t(
"domainPickerProvidedDomains" "domainPickerProvidedDomains"
) )
: t("domainPickerFreeDomains") : t("domainPickerFreeDomains")
} }
className="py-2" className="py-2"
> >
<CommandList> <CommandList>
<CommandItem <CommandItem
key="provided-search" key="provided-search"
onSelect={() => onSelect={() =>
handleBaseDomainSelect({ handleBaseDomainSelect({
id: "provided-search", id: "provided-search",
domain: domain:
build === build ===
"enterprise" "enterprise"
? "Provided Domain"
: "Free Provided Domain",
type: "provided-search"
})
}
className="mx-2 rounded-md"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
<Zap className="h-4 w-4 text-primary" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
{build === "enterprise"
? "Provided Domain" ? "Provided Domain"
: "Free Provided Domain", : "Free Provided Domain"}
type: "provided-search" </span>
}) <span className="text-xs text-muted-foreground">
} {t(
className="mx-2 rounded-md" "domainPickerSearchForAvailableDomains"
> )}
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3"> </span>
<Zap className="h-4 w-4 text-primary" /> </div>
</div> <Check
<div className="flex flex-col flex-1 min-w-0"> className={cn(
<span className="font-medium truncate"> "h-4 w-4 text-primary",
{build === "enterprise" selectedBaseDomain?.id ===
? "Provided Domain" "provided-search"
: "Free Provided Domain"} ? "opacity-100"
</span> : "opacity-0"
<span className="text-xs text-muted-foreground">
{t(
"domainPickerSearchForAvailableDomains"
)} )}
</span> />
</div> </CommandItem>
<Check </CommandList>
className={cn( </CommandGroup>
"h-4 w-4 text-primary", )}
selectedBaseDomain?.id ===
"provided-search"
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
</CommandList>
</CommandGroup>
)}
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -680,7 +691,7 @@ export default function DomainPicker2({
htmlFor={option.domainNamespaceId} htmlFor={option.domainNamespaceId}
data-state={ data-state={
selectedProvidedDomain?.domainNamespaceId === selectedProvidedDomain?.domainNamespaceId ===
option.domainNamespaceId option.domainNamespaceId
? "checked" ? "checked"
: "unchecked" : "unchecked"
} }
@@ -760,4 +771,4 @@ function debounce<T extends (...args: any[]) => any>(
func(...args); func(...args);
}, wait); }, wait);
}; };
} }

View File

@@ -7,7 +7,7 @@ import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus"; import SupporterStatus from "@app/components/SupporterStatus";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Menu, Server } from "lucide-react"; import { ExternalLink, Menu, Server } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
@@ -117,7 +117,15 @@ export function LayoutMobileMenu({
<SupporterStatus /> <SupporterStatus />
{env?.app?.version && ( {env?.app?.version && (
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-center">
v{env.app.version} <Link
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
v{env.app.version}
<ExternalLink size={12} />
</Link>
</div> </div>
)} )}
</div> </div>

View File

@@ -7,6 +7,7 @@ import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus"; import SupporterStatus from "@app/components/SupporterStatus";
import { ExternalLink, Server, BookOpenText, Zap } from "lucide-react"; import { ExternalLink, Server, BookOpenText, Zap } from "lucide-react";
import { FaDiscord, FaGithub } from "react-icons/fa";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
@@ -151,7 +152,7 @@ export function LayoutSidebar({
{!isUnlocked() {!isUnlocked()
? t("communityEdition") ? t("communityEdition")
: t("commercialEdition")} : t("commercialEdition")}
<ExternalLink size={12} /> <FaGithub size={12} />
</Link> </Link>
</div> </div>
<div className="text-xs text-muted-foreground "> <div className="text-xs text-muted-foreground ">
@@ -165,9 +166,28 @@ export function LayoutSidebar({
<BookOpenText size={12} /> <BookOpenText size={12} />
</Link> </Link>
</div> </div>
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://discord.gg/HCJR8Xhme4"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
Discord
<FaDiscord size={12} />
</Link>
</div>
{env?.app?.version && ( {env?.app?.version && (
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-center">
v{env.app.version} <Link
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
v{env.app.version}
<ExternalLink size={12} />
</Link>
</div> </div>
)} )}
</div> </div>

View File

@@ -24,6 +24,7 @@ function getActionsCategories(root: boolean) {
[t('actionUpdateOrg')]: "updateOrg", [t('actionUpdateOrg')]: "updateOrg",
[t('actionGetOrgUser')]: "getOrgUser", [t('actionGetOrgUser')]: "getOrgUser",
[t('actionInviteUser')]: "inviteUser", [t('actionInviteUser')]: "inviteUser",
[t('actionListInvitations')]: "listInvitations",
[t('actionRemoveUser')]: "removeUser", [t('actionRemoveUser')]: "removeUser",
[t('actionListUsers')]: "listUsers", [t('actionListUsers')]: "listUsers",
[t('actionListOrgDomains')]: "listOrgDomains" [t('actionListOrgDomains')]: "listOrgDomains"
@@ -51,7 +52,12 @@ function getActionsCategories(root: boolean) {
[t('actionSetResourcePassword')]: "setResourcePassword", [t('actionSetResourcePassword')]: "setResourcePassword",
[t('actionSetResourcePincode')]: "setResourcePincode", [t('actionSetResourcePincode')]: "setResourcePincode",
[t('actionSetResourceEmailWhitelist')]: "setResourceWhitelist", [t('actionSetResourceEmailWhitelist')]: "setResourceWhitelist",
[t('actionGetResourceEmailWhitelist')]: "getResourceWhitelist" [t('actionGetResourceEmailWhitelist')]: "getResourceWhitelist",
[t('actionCreateSiteResource')]: "createSiteResource",
[t('actionDeleteSiteResource')]: "deleteSiteResource",
[t('actionGetSiteResource')]: "getSiteResource",
[t('actionListSiteResources')]: "listSiteResources",
[t('actionUpdateSiteResource')]: "updateSiteResource"
}, },
Target: { Target: {

View File

@@ -20,7 +20,7 @@ import {
TableRow TableRow
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination"; import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus, Search, RefreshCw } from "lucide-react"; import { Plus, Search, RefreshCw } from "lucide-react";
@@ -32,7 +32,42 @@ import {
} from "@app/components/ui/card"; } from "@app/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useMemo } from "react";
const STORAGE_KEYS = {
PAGE_SIZE: 'datatable-page-size',
getTablePageSize: (tableId?: string) =>
tableId ? `${tableId}-size` : STORAGE_KEYS.PAGE_SIZE
};
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
if (typeof window === 'undefined') return defaultSize;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
const stored = localStorage.getItem(key);
if (stored) {
const parsed = parseInt(stored, 10);
// Validate that it's a reasonable page size
if (parsed > 0 && parsed <= 1000) {
return parsed;
}
}
} catch (error) {
console.warn('Failed to read page size from localStorage:', error);
}
return defaultSize;
};
const setStoredPageSize = (pageSize: number, tableId?: string): void => {
if (typeof window === 'undefined') return;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
localStorage.setItem(key, pageSize.toString());
} catch (error) {
console.warn('Failed to save page size to localStorage:', error);
}
};
type TabFilter = { type TabFilter = {
id: string; id: string;
@@ -56,6 +91,8 @@ type DataTableProps<TData, TValue> = {
}; };
tabs?: TabFilter[]; tabs?: TabFilter[];
defaultTab?: string; defaultTab?: string;
persistPageSize?: boolean | string;
defaultPageSize?: number;
}; };
export function DataTable<TData, TValue>({ export function DataTable<TData, TValue>({
@@ -70,8 +107,23 @@ export function DataTable<TData, TValue>({
searchColumn = "name", searchColumn = "name",
defaultSort, defaultSort,
tabs, tabs,
defaultTab defaultTab,
persistPageSize = false,
defaultPageSize = 20
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const t = useTranslations();
// Determine table identifier for storage
const tableId = typeof persistPageSize === 'string' ? persistPageSize : undefined;
// Initialize page size from storage or default
const [pageSize, setPageSize] = useState<number>(() => {
if (persistPageSize) {
return getStoredPageSize(tableId, defaultPageSize);
}
return defaultPageSize;
});
const [sorting, setSorting] = useState<SortingState>( const [sorting, setSorting] = useState<SortingState>(
defaultSort ? [defaultSort] : [] defaultSort ? [defaultSort] : []
); );
@@ -80,7 +132,6 @@ export function DataTable<TData, TValue>({
const [activeTab, setActiveTab] = useState<string>( const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || "" defaultTab || tabs?.[0]?.id || ""
); );
const t = useTranslations();
// Apply tab filter to data // Apply tab filter to data
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
@@ -108,7 +159,7 @@ export function DataTable<TData, TValue>({
onGlobalFilterChange: setGlobalFilter, onGlobalFilterChange: setGlobalFilter,
initialState: { initialState: {
pagination: { pagination: {
pageSize: 20, pageSize: pageSize,
pageIndex: 0 pageIndex: 0
} }
}, },
@@ -119,12 +170,35 @@ export function DataTable<TData, TValue>({
} }
}); });
useEffect(() => {
const currentPageSize = table.getState().pagination.pageSize;
if (currentPageSize !== pageSize) {
table.setPageSize(pageSize);
// Persist to localStorage if enabled
if (persistPageSize) {
setStoredPageSize(pageSize, tableId);
}
}
}, [pageSize, table, persistPageSize, tableId]);
const handleTabChange = (value: string) => { const handleTabChange = (value: string) => {
setActiveTab(value); setActiveTab(value);
// Reset to first page when changing tabs // Reset to first page when changing tabs
table.setPageIndex(0); table.setPageIndex(0);
}; };
// Enhanced pagination component that updates our local state
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
table.setPageSize(newPageSize);
// Persist immediately when changed
if (persistPageSize) {
setStoredPageSize(newPageSize, tableId);
}
};
return ( return (
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
<Card> <Card>
@@ -235,7 +309,10 @@ export function DataTable<TData, TValue>({
</TableBody> </TableBody>
</Table> </Table>
<div className="mt-4"> <div className="mt-4">
<DataTablePagination table={table} /> <DataTablePagination
table={table}
onPageSizeChange={handlePageSizeChange}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -0,0 +1,29 @@
export function parseHostTarget(input: string) {
try {
const normalized = input.match(/^(https?|h2c):\/\//) ? input : `http://${input}`;
const url = new URL(normalized);
const protocol = url.protocol.replace(":", ""); // http | https | h2c
const host = url.hostname;
let defaultPort: number;
switch (protocol) {
case "https":
defaultPort = 443;
break;
case "h2c":
defaultPort = 80;
break;
default: // http
defaultPort = 80;
break;
}
const port = url.port ? parseInt(url.port, 10) : defaultPort;
return { protocol, host, port };
} catch {
return null;
}
}

View File

@@ -0,0 +1,63 @@
export type DomainType = "organization" | "provided" | "provided-search";
export const SINGLE_LABEL_RE = /^[\p{L}\p{N}-]+$/u; // provided-search (no dots)
export const MULTI_LABEL_RE = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u; // ns/wildcard
export const SINGLE_LABEL_STRICT_RE = /^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum
export function sanitizeInputRaw(input: string): string {
if (!input) return "";
return input
.toLowerCase()
.normalize("NFC") // normalize Unicode
.replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen
}
export function finalizeSubdomainSanitize(input: string): string {
if (!input) return "";
return input
.toLowerCase()
.normalize("NFC")
.replace(/[^\p{L}\p{N}.-]/gu, "") // allow Unicode
.replace(/\.{2,}/g, ".") // collapse multiple dots
.replace(/^-+|-+$/g, "") // strip leading/trailing hyphens
.replace(/^\.+|\.+$/g, "") // strip leading/trailing dots
.replace(/(\.-)|(-\.)/g, "."); // fix illegal dot-hyphen combos
}
export function validateByDomainType(subdomain: string, domainType: { type: "provided-search" | "organization"; domainType?: "ns" | "cname" | "wildcard" } ): boolean {
if (!domainType) return false;
if (domainType.type === "provided-search") {
return SINGLE_LABEL_RE.test(subdomain);
}
if (domainType.type === "organization") {
if (domainType.domainType === "cname") {
return subdomain === "";
} else if (domainType.domainType === "ns" || domainType.domainType === "wildcard") {
if (subdomain === "") return true;
if (!MULTI_LABEL_RE.test(subdomain)) return false;
const labels = subdomain.split(".");
return labels.every(l => l.length >= 1 && l.length <= 63 && SINGLE_LABEL_RE.test(l));
}
}
return false;
}
export const isValidSubdomainStructure = (input: string): boolean => {
const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(?<!-)$/u;
if (!input) return false;
if (input.includes("..")) return false;
return input.split(".").every(label => regex.test(label));
};